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.
Loading preview…
- Dependencies
- lucide-react
- Registry dependencies
- utils
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/country-picker.jsonPrompt
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. With none of aria-label,
aria-labelledby or id (for a <label htmlFor>), the trigger falls back to aria-label =
placeholder, because role="combobox" takes no name from its content.
- 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. Keys that
belong to an IME composition (nativeEvent.isComposing, or keyCode 229 in Safari) are left to
the IME, so the Enter that turns "deguo" into 德国 never commits the highlighted row.
- 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-setsizeall 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 disambiguation —
dialis the ITU country calling code and nothing else;dialPrefixis 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 localisation —
Intl.DisplayNamesanswers 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.
Char Counter
A character budget for any input or textarea — grapheme-accurate counting, a warning band, an explicit over-limit state, and a hard cap that trims the paste instead of swallowing it.
Tax ID Input
A tax identifier field that knows what it is asking for — the country picks the local name, prefix and mask, and validation runs that jurisdiction's real checksum instead of a generic regex.