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…
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.
- 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-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.