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…

Dependencies
lucide-react
Registry dependencies
utils
"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. 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-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 disambiguation — dial 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 localisation — Intl.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