Inputs

Country Picker

A country field whose value is always the ISO 3166-1 alpha-2 code — 243 countries, searchable by name, either ISO code or calling code, pinned rows on top and UN M49 region groups.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, ChevronsUpDown, Globe, Search } from "lucide-react"
import { cn } from "@/lib/utils"

/* ------------------------------------------------------------------ *
 * Country table
 * ------------------------------------------------------------------ */

/**
 * UN M49 continental regions — the grouping the list uses. M49 is a statistical
 * standard rather than a political one, and it files a few places where a user
 * would not go looking: Türkiye and Cyprus sit in Asia (Western Asia), Russia in

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/country-picker.json

Prompt

Build a React + TypeScript + Tailwind "CountryPicker" component (lucide-react for icons,
cn() from the project's utils, no other runtime dependency).

Contract
- export const CountryPicker = forwardRef<HTMLDivElement, CountryPickerProps>, ref on the root.
- CountryOption = { code: alpha-2, the value; alpha3; name (English); region: "Africa" |
  "Americas" | "Asia" | "Europe" | "Oceania"; dial: ITU calling code, digits only, no "+";
  dialPrefix?: the block inside a shared calling code (NANP area code, +44 1481, Mayotte's 269);
  aliases?: search-only strings }.
- export const COUNTRIES: every ISO 3166-1 alpha-2 code with a permanent civilian population.
  Omit the uninhabited ones (AQ, BV, HM, TF, UM, GS) and say so in a comment; Kosovo has no ISO
  code at all, so mention XK as something the consumer adds rather than shipping it as standard.
- Props: value / defaultValue (alpha-2, case-insensitive in), onChange(code, option) with the
  canonical upper-case code, pinned: string[] (default []), showDialCode (false), regionGroups
  (true), locale (BCP 47, undefined = English), countries (replaces the table), flagDisplay:
  "auto" | "emoji" | "code", groupLabels: Partial<Record<region | "pinned" | "all" | "unlisted",
  string>>, invalid, disabled, name (hidden input for plain form posts), placeholder,
  searchPlaceholder, emptyText, and labels: { list?; count?(visible, total); empty?(query) } for
  the listbox's accessible name, the polite footer count and the empty state — callbacks, because
  a localised count is not a string a Record can hold. id and aria-label/-labelledby/-describedby
  go to the trigger, everything else spreads on the root.
- Helpers worth exporting: flagEmoji(code) built from regional indicators, dialDigits(option)
  ("1268") and formatDialCode(option) ("+1 268").

Behavior
- Value discipline: the label may be localised, abbreviated or pinned — onChange always emits
  alpha-2, upper-cased. A value the table does not contain still gets its own row in an
  "unlisted" group so the field has something checked instead of silently showing a placeholder.
- Search: fold both sides (NFD, strip combining marks, lower-case) and AND the whitespace
  tokens, each with a leading "+" stripped. One haystack per row: localised name, English name,
  alpha-2, alpha-3, calling code, calling code + prefix, aliases.
- Grouping: unlisted first, then pinned in the caller's order (de-duplicated, and removed from
  their region so no country renders twice), then either the five regions or one "all" group.
  Empty groups disappear while filtering.
- Keyboard: a printable key on the closed trigger opens the panel seeded with that character;
  ArrowUp/Down, PageUp/Down (10 rows), Home/End walk one flat index across every group; Enter
  commits; Escape closes and restores focus (stopPropagation so a picker inside a dialog closes
  only itself); Tab off the search field closes and hands focus back to the trigger.
- Dismissal: document pointerdown and focusin listeners while open, both removed on close and on
  unmount. Pointer handling on rows is delegated to the scroll container, and mousedown on a row
  is preventDefault-ed so focus stays in the search field.
- Placement: measure the trigger rect in the handler that opens the panel — flip above when
  there is no room below and above is roomier, cap max-height to the space that exists — and
  re-measure on scroll (capture, passive) and resize behind one rAF, cancelled on cleanup.
- Flags: draw one flag emoji to a 16×16 offscreen canvas once per tab and scan for a coloured
  pixel; letters render grey, so grey means "no flag glyphs" and the row shows an alpha-2 chip
  instead. Render the chip on the server and the hydrating frame, then upgrade — never the
  reverse. Treat a throwing or blocked canvas as "no flags".
- Locale: build Intl.DisplayNames only after hydration (Node and browser ICU data differ). Every
  Intl entry point is a throw site, so treat all three as one rule: wrap the DisplayNames and the
  explicit Intl.Collator constructions, because a tag like "en_US" is malformed and unguarded it
  takes the whole render down instead of costing only localisation and the locale's sort order;
  and guard the resolver itself, because of() throws on anything that is not a region subtag and
  an unlisted row's code came straight from the consumer's value.
- Do not virtualise ~250 rows; say in a comment where the threshold is and what to swap in.

Rendering & styling
- Semantic tokens only: border-input, bg-popover / text-popover-foreground, bg-accent /
  text-accent-foreground, text-muted-foreground, border-destructive, ring-ring. No hex, no
  palette classes. Merge the consumer's className with cn().
- Trigger: role="combobox" + aria-haspopup="listbox" + aria-expanded + aria-invalid. The filter
  field inside stays a textbox that carries aria-autocomplete="list", aria-controls and
  aria-activedescendant — focus lives there, so it is the element that may own an active option.
- Options are role="option" inside role="group" sections with sticky headings; carry
  aria-posinset and aria-setsize from the flat index so "12 of 243" is what gets announced, and
  give rows a scroll-margin equal to the heading height so arrowing up never parks a row under it.
- One polite role="status" footer with the visible/total count; focus-visible rings everywhere;
  every transition paired with motion-reduce:transition-none.

Customization levers
- Data: replace `countries` wholesale for a shipping allow-list, add XK or the uninhabited codes,
  or extend CountryOption with your own fields (tax zone, EU membership) and read them in the row.
- Density: row padding (py-1.5), trigger height (h-10), panel min/max height (176 / 336px),
  panel min width (min-w-64) — the widest safe knobs.
- Which sub-blocks: drop the dial column (showDialCode), the region split (regionGroups), the
  pinned section (pinned=[]), the footer count, or the flag column entirely (render nothing in
  place of the chip and shrink the w-7 gutter).
- Labels: groupLabels for the headings, labels for the listbox's accessible name / the footer
  count / the empty state, placeholder / searchPlaceholder / emptyText for the copy, locale for
  the country names themselves. Nothing user-visible or announced is left hard-coded in English.
- Colour: the accent row and the ring follow --accent and --ring; --destructive drives the
  invalid state. Point the chip at a different token pair for a quieter fallback.
- Layout: swap the in-flow absolute panel for a portal + position:fixed if your host clips with
  overflow:hidden — you lose the token cascade from any wrapper, which is why it is not the default.

Concepts

  • alpha-2 as the value — the row can say 德国, Deutschland or Germany and the field still emits DE. Every label is presentation; the ISO code is the contract with your backend.
  • Pinned, then grouped — pinning is a ranking, so pinned rows keep the caller's order and leave their region behind. One country, one row: duplicates would make the announced position and the Enter target disagree.
  • One flat index behind many groups — sections are a rendering concern; the keyboard, the option ids and aria-setsize all count through a single flattened array, so a screen reader hears "12 of 243" rather than a position inside whichever group it wandered into.
  • Dial-code disambiguationdial is the ITU country calling code and nothing else; dialPrefix is the block inside a shared one. Every NANP member is +1, and the picker prints "+1 268" instead of pretending 1268 is a country code.
  • Fallback before flag — the code chip is what the server and the hydrating frame render, because no server can know whether the visitor's platform has flag glyphs. A single canvas paint test upgrades it; the reverse order would flash a broken glyph.
  • Post-hydration localisationIntl.DisplayNames answers differently under Node and under a browser, so localised names arrive one frame after hydration rather than during the render that has to match the server byte for byte.

On This Page