pick a country by its name, its own name, its code or its flag, yours first
npx shadcn add @ag/country-pickerAlready installed? Preview an update
npx shadcn add @ag/country-picker --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-13
- New: pick a country from a field over every flag. Found by its English name, its own (
españa,日本), its code, the names it goes by (usa,uk,holland) or a pasted flag emoji; the reader's countries first (the one the device's time zone is in, then the browser's languages, read on the device and sent nowhere), or the ones you pass assuggested. Arrows walk the list, Enter picks.CountryPickeris the panel;CountryPickerPopoveropens it from your own trigger and shuts on a pick. It carries every flag's art, about 180 KB (45 KB gzipped):LazyCountryPickerPopoverkeeps that off the page's first load, fetching it as soon as its trigger is drawn so it is there before it is pressed (preloadCountryPickerto fetch it earlier).
also called country select, flag picker, country dropdown
shelf controls
uses flag, fuzzy, input, material, popover, scroll-area, search
the demo above, verbatim
"use client"
import * as React from "react"
import { Sample } from "@/app/samples"
import { Button } from "@/registry/base-nova/ui/button"
import {
CountryPicker,
CountryPickerPopover,
} from "@/registry/base-nova/ui/country-picker"
import { countryName, Flag } from "@/registry/base-nova/ui/flag"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
// #region flag
/** The flag is the control: pressing it opens the picker under it, a pick shuts it.
* With no country yet, an empty dashed frame holds the flag's place. */
function FlagField() {
const [code, setCode] = React.useState<string | undefined>()
return (
<CountryPickerPopover value={code} onChange={setCode} none="none">
<Button variant="outline">
{code ? (
<Flag code={code} label="" />
) : (
<span className="inline-block h-[0.75em] w-[1.125em] border border-current/40 border-dashed" />
)}
{code ? countryName(code) : "country"}
</Button>
</CountryPickerPopover>
)
}
// #endregion
// #region panel
function Panel() {
const [code, setCode] = React.useState<string | undefined>("JP")
return (
<div className="flex w-80 flex-col gap-2">
<span className="flex items-center gap-2 font-mono text-[11px] text-foreground/60">
{code && <Flag code={code} label="" />}
{code ?? "none"}
</span>
<CountryPicker value={code} onChange={setCode} suggested={["JP", "KR"]} />
</div>
)
}
// #endregion
export default function CountryPickerDemo() {
return (
<div className="space-y-8">
<Sample
name="flag"
glance
title="the flag opens it"
description="type a name in english or its own (españa, 日本), a code (es), what people call it (usa, holland) or paste a flag emoji. arrows walk, enter picks."
with="flag"
>
<FlagField />
</Sample>
<Sample
name="panel"
title="the panel alone"
description="for a popover of your own or a page. suggested leads while nothing is typed; by default the reader's countries, from the browser's languages."
with="panel"
>
<Panel />
</Sample>
<Sample name="glass" title="in glass, over a picture" with="flag">
<MaterialProvider material="glass">
<div className="grid place-items-center rounded-2xl bg-[url(/lightbox/shore.jpg)] bg-cover bg-center p-12 text-white">
<FlagField />
</div>
</MaterialProvider>
</Sample>
</div>
)
}the source, every file an install writes
"use client"
// A COUNTRY PICKER: a field over every country the flag set draws, each a row of its
// flag, its name and its code. A country is found the way people think of it: by its
// English name ("spain"), its own ("españa", "日本"), its code ("es", the one a
// form or a URL already says), the names it goes by that are not its official one
// ("usa", "uk", "holland", "ivory coast"), or its flag emoji pasted in. The reader's
// own countries lead while nothing is typed (`readerRegions`, from the browser's
// languages: en-GB and es-ES make "yours" the UK and Spain). Arrows walk the list,
// the field keeping focus (Base UI's autocomplete, as `command`), Enter takes the lit
// row, the chosen one is checked.
//
// <CountryPickerPopover value={code} onChange={setCode}>
// <button>{code ? <Flag code={code} /> : "country"}</button>
// </CountryPickerPopover>
//
// `CountryPicker` is the panel alone, for a popover of your own or a page. Every
// country's flag is in it, about 180 KB of SVG (45 KB gzipped): load it when it is
// first opened (`React.lazy`), not with the page.
import { Autocomplete } from "@base-ui/react/autocomplete"
import { countries as FLAGGED } from "country-flag-icons"
import { Check, Search as SearchIcon } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import {
fuzzyFilter,
queryWords,
type Range,
} from "@/registry/base-nova/lib/fuzzy"
import { countryName, Flag } from "@/registry/base-nova/ui/flag"
import { InputGroup } from "@/registry/base-nova/ui/input"
import { type Material, useMaterial } from "@/registry/base-nova/ui/material"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/registry/base-nova/ui/popover"
import { ScrollArea } from "@/registry/base-nova/ui/scroll-area"
import { Highlight } from "@/registry/base-nova/ui/search"
export interface Country {
/** ISO 3166-1 alpha-2, upper case */
code: string
/** its name in English (Intl) */
name: string
/** its name in its own main language, where that differs ("España") */
own: string | null
/** the names it goes by beside those ("usa", "holland") */
aka: readonly string[]
}
/** Names people use that are not the one Intl gives, by code. Only real usage: an
* old name still said, an initialism, the part a country is called by. */
const AKA: Record<string, readonly string[]> = {
AE: ["uae", "emirates"],
BA: ["bosnia"],
CD: ["congo", "drc", "zaire"],
CG: ["congo"],
CI: ["ivory coast"],
CN: ["china", "prc"],
CZ: ["czech republic"],
GB: ["uk", "britain", "great britain", "england", "scotland", "wales"],
KP: ["north korea", "dprk"],
KR: ["korea", "south korea"],
MK: ["macedonia"],
MM: ["burma"],
NL: ["holland"],
PS: ["palestine"],
RU: ["russian federation"],
SZ: ["swaziland"],
TR: ["turkey", "türkiye"],
TW: ["taiwan", "roc"],
US: ["usa", "us", "america", "united states of america"],
VA: ["vatican", "holy see"],
VN: ["vietnam"],
}
/** Codes the set draws that name no country: the EU's flag, and two Intl cannot name. */
const NOT_COUNTRIES = new Set(["EU", "XC", "XO"])
/** One region namer per language, made once: building a namer is the costly
* part, and a namer per country made the first opening wait about a second. */
const namers = new Map<string, Intl.DisplayNames>()
function regionIn(language: string, code: string): string | undefined {
let namer = namers.get(language)
if (!namer) {
namer = new Intl.DisplayNames([language], { type: "region" })
namers.set(language, namer)
}
return namer.of(code)
}
let every: Country[] | null = null
/** Every country the flag set draws, by English name. Built once. */
export function countries(): Country[] {
every ??= FLAGGED.filter((c) => /^[A-Z]{2}$/.test(c) && !NOT_COUNTRIES.has(c))
.map((code) => {
const name = countryName(code)
if (name === code)
throw new Error(`country-picker: Intl names no ${code}`)
const language = new Intl.Locale(`und-${code}`).maximize().language
const own = regionIn(language, code)
return {
code,
name,
own: own && own !== name ? own : null,
aka: AKA[code] ?? [],
}
})
.sort((a, b) => a.name.localeCompare(b.name, "en"))
return every
}
/** A flag emoji's two regional indicators read as the code they spell ("🇪🇸" is
* "ES"), so a pasted flag finds its country. */
const unflag = (text: string) =>
text.replace(/[\u{1F1E6}-\u{1F1FF}]/gu, (c) =>
String.fromCharCode((c.codePointAt(0) as number) - 0x1f1e6 + 65),
)
export interface Found {
country: Country
/** what the query lit in its name */
ranges: Range[]
}
/** The countries a query finds, best first: the one whose code it is, then those
* whose name has a word starting with each typed word, then those whose own name or
* other names do, then any that hold them; letters scattered in order only when
* nothing holds them as text (a guess shown as a find reads as junk). */
export function findCountries(query: string): Found[] {
const q = unflag(query)
const typed = queryWords(q)
const found = fuzzyFilter(countries(), q, (c) => [
c.name,
...(c.own ? [c.own] : []),
c.code,
...c.aka,
])
const words = (texts: readonly string[]) =>
texts.flatMap((t) => queryWords(t))
const starts = (texts: readonly string[]) =>
typed.every((t) => words(texts).some((w) => w.startsWith(t)))
const tier = ({ name, own, code, aka }: Country) =>
typed.length === 1 && typed[0] === code.toLowerCase()
? 0
: starts([name])
? 1
: starts([...(own ? [own] : []), ...aka])
? 2
: typed.every((t) =>
words([name, own ?? "", ...aka]).some((w) => w.includes(t)),
)
? 3
: 4
const ranked = found.map((f, at) => ({ f, at, tier: tier(f.item) }))
const literal = ranked.filter((r) => r.tier < 4)
return (literal.length > 0 ? literal : ranked)
.sort((a, b) => a.tier - b.tier || a.at - b.at)
.map(({ f }) => ({ country: f.item, ranges: f.ranges[0] ?? [] }))
}
const NO_REGIONS: readonly string[] = []
let reader: readonly string[] | null = null
/** A country's time zones as Intl lists them (`Intl.Locale`'s `getTimeZones`, or
* the older `timeZones` property; neither in Firefox yet, which gets none). */
function zonesOf(code: string): readonly string[] {
const locale = new Intl.Locale(`und-${code}`) as Intl.Locale & {
getTimeZones?: () => string[]
timeZones?: string[]
}
return locale.getTimeZones?.() ?? locale.timeZones ?? []
}
/** The reader's countries, each once; none on the server. First where the
* device is: the country whose time zones hold the device's own (Europe/Madrid:
* Spain), the place a person is far more often than their browser's language
* says (an English browser in Spain). Then the languages it asks for (en-GB,
* es-ES: the UK, Spain). Both read on the device and sent nowhere: no
* lookup, no address, nothing about the reader leaves the browser. Read once:
* the same list every call, as a store's snapshot must be. */
export function readerRegions(): readonly string[] {
if (typeof navigator === "undefined") return NO_REGIONS
if (reader) return reader
const known = countries().map((c) => c.code)
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone
const here = known.find((code) => zonesOf(code).includes(zone))
const out: string[] = here ? [here] : []
for (const tag of navigator.languages) {
const region = new Intl.Locale(tag).maximize().region
if (region && known.includes(region) && !out.includes(region))
out.push(region)
}
reader = out
return reader
}
/** The rows drawn with the picker's first frame, more than its list shows. */
const FIRST = 40
const noRegions = () => NO_REGIONS
const subscribeNever = () => () => {}
const onClient = () => true
const onServer = () => false
type Row = { key: string; country: Country | null; ranges: Range[] }
// A type, not an interface: Base UI types a group with an index signature.
type Section = { heading: string; items: Row[] }
export interface CountryPickerProps {
/** the chosen code, or undefined for none */
value: string | undefined
onChange: (code: string | undefined) => void
/** codes listed first under "yours" while nothing is typed; default the reader's
* (`readerRegions`) */
suggested?: readonly string[]
/** the no-country choice, listed first, with what it is called ("none") */
none?: string
placeholder?: string
/** what it picks, for a screen reader */
label?: string
material?: Material
autoFocus?: boolean
className?: string
}
const ROW =
"flex scroll-my-1 cursor-default select-none items-center gap-2.5 px-2 py-1.5 text-[13px] text-current/85 outline-none data-highlighted:text-current"
const FACE: Record<Material, string> = {
plain: "rounded-md data-highlighted:bg-foreground/8",
glass: "rounded-[10px] data-highlighted:bg-current/12",
}
export function CountryPicker({
value,
onChange,
suggested,
none,
placeholder = "search countries",
label = "country",
material,
autoFocus,
className,
}: CountryPickerProps) {
const m = useMaterial(material)
const [query, setQuery] = React.useState("")
const reader = React.useSyncExternalStore(
subscribeNever,
readerRegions,
noRegions,
)
const first = suggested ?? reader
// The names come from Intl, whose data differs between a server's runtime and
// the browser (a country's own name, one's English name): the list is drawn in
// the browser only, its names the reader's.
const client = React.useSyncExternalStore(subscribeNever, onClient, onServer)
// The first rows at once, every row a frame later as an update that yields:
// two hundred and fifty rows, each with its flag, held the popover closed for
// about a second after the press. What shows at first is all that fits in
// view, so nothing is seen arriving.
const [whole, setWhole] = React.useState(false)
React.useEffect(() => {
const frame = requestAnimationFrame(() =>
React.startTransition(() => setWhole(true)),
)
return () => cancelAnimationFrame(frame)
}, [])
const sections = React.useMemo((): Section[] => {
if (!client) return []
const shown = <T,>(items: T[]) => (whole ? items : items.slice(0, FIRST))
const row = (country: Country, ranges: Range[] = []): Row => ({
key: country.code,
country,
ranges,
})
if (queryWords(query).length > 0)
return [
{
heading: "found",
items: shown(findCountries(query)).map((f) =>
row(f.country, f.ranges),
),
},
]
const byCode = new Map(countries().map((c) => [c.code, c]))
const yours = first.flatMap((code) => {
const c = byCode.get(code)
return c ? [{ ...row(c), key: `yours:${code}` }] : []
})
return [
...(none
? [
{
heading: "none",
items: [{ key: "none", country: null, ranges: [] }],
},
]
: []),
...(yours.length > 0 ? [{ heading: "yours", items: yours }] : []),
{
heading: "every country",
items: shown(countries()).map((c) => row(c)),
},
]
}, [client, whole, query, first, none])
return (
<div
data-slot="country-picker"
className={cn("flex min-h-0 min-w-0 flex-col", className)}
>
<Autocomplete.Root
open
inline
autoHighlight="always"
keepHighlight
items={sections}
filteredItems={sections}
value={query}
onValueChange={(next, details) => {
// A row pressed picks; it never fills the field.
if (details.reason !== "item-press") setQuery(next)
}}
itemToStringValue={(row: Row) => row.country?.name ?? none ?? ""}
>
<Autocomplete.Input
data-slot="country-picker-input"
render={
<InputGroup size="sm" start={<SearchIcon />} className="w-full" />
}
aria-label={`search ${label}`}
placeholder={placeholder}
spellCheck={false}
autoComplete="off"
autoFocus={autoFocus}
onKeyDown={(event) => {
if (event.key === "Escape" && query) {
event.stopPropagation()
event.preventBaseUIHandler()
setQuery("")
}
}}
/>
<ScrollArea className="max-h-[min(20rem,50dvh)] min-h-0 pt-2 pb-2">
<Autocomplete.Empty
data-slot="country-picker-empty"
className="px-2 py-6 text-center text-[13px] text-current/50 empty:hidden"
>
no country called that
</Autocomplete.Empty>
<Autocomplete.List data-slot="country-picker-list" aria-label={label}>
{(section: Section) => (
<Autocomplete.Group
key={section.heading}
items={section.items}
className="not-last:mb-2"
>
{section.heading !== "found" && section.heading !== "none" && (
<Autocomplete.GroupLabel className="px-2 pt-1 pb-1 font-mono text-[11px] text-current/45">
{section.heading}
</Autocomplete.GroupLabel>
)}
<Autocomplete.Collection>
{(row: Row) => {
const code = row.country?.code
const on = code === value
return (
<Autocomplete.Item
key={row.key}
value={row}
aria-selected={on}
onClick={() => onChange(code)}
data-slot="country-picker-item"
className={cn(ROW, FACE[m])}
>
{row.country ? (
<Flag code={row.country.code} label="" />
) : (
<span
aria-hidden
className="inline-block h-[0.75em] w-[1.125em] shrink-0 border border-current/30 border-dashed"
/>
)}
<span className="min-w-0 flex-1 truncate">
{row.country ? (
<Highlight
text={row.country.name}
ranges={row.ranges}
className="text-current decoration-current/40"
/>
) : (
none
)}
{row.country?.own && (
<span className="ml-2 text-current/40">
{row.country.own}
</span>
)}
</span>
{on ? (
<Check aria-hidden className="size-3.5 shrink-0" />
) : (
code && (
<span className="shrink-0 font-mono text-[11px] text-current/35">
{code}
</span>
)
)}
</Autocomplete.Item>
)
}}
</Autocomplete.Collection>
</Autocomplete.Group>
)}
</Autocomplete.List>
</ScrollArea>
</Autocomplete.Root>
</div>
)
}
/** The picker in a popover, opened by what it wraps (the consumer's own trigger: a
* flag, a field, a button) and shut by a pick. */
export function CountryPickerPopover({
children,
side = "bottom",
align = "start",
...picker
}: Omit<CountryPickerProps, "autoFocus"> & {
/** the trigger, one element */
children: React.ReactElement
side?: React.ComponentProps<typeof PopoverContent>["side"]
align?: React.ComponentProps<typeof PopoverContent>["align"]
}) {
const [open, setOpen] = React.useState(false)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger render={children} />
{/* Just under its trigger, growing from it (the popover's own origin),
so it reads as come out of what was pressed, not laid near it. The
trigger says it is open (`data-popup-open`, Base UI's) for the
consumer to draw. */}
<PopoverContent
side={side}
align={align}
sideOffset={4}
material={picker.material}
className="w-80 p-2"
>
<CountryPicker
{...picker}
autoFocus
onChange={(code) => {
picker.onChange(code)
setOpen(false)
}}
/>
</PopoverContent>
</Popover>
)
}"use client"
// THE COUNTRY PICKER, OFF THE PAGE'S FIRST LOAD: its list and its search, every
// country named in English and in its own language, are no part of a page's first
// paint, and a person should not wait for them either when they press the
// trigger (the flags are images, flag's own). So it is fetched as soon as
// its trigger is on the page, after the page has loaded, and the trigger is
// drawn at once as itself until the picker is there; by the time a hand reaches
// it, it is.
//
// <LazyCountryPickerPopover value={code} onChange={setCode}>
// <button>{code ? <Flag code={code} /> : "country"}</button>
// </LazyCountryPickerPopover>
//
// The same props as `CountryPickerPopover`. `preloadCountryPicker()` fetches it
// earlier still, from wherever a page knows it will be wanted.
import * as React from "react"
import type { CountryPickerPopover as Picker } from "@/registry/base-nova/ui/country-picker"
type Module = typeof import("@/registry/base-nova/ui/country-picker")
let loading: Promise<Module> | null = null
/** Fetch the picker, once however often asked, and once it is here build what
* its first opening would (every country named in English and in its own
* language, the reader's countries) while the page is idle, so a press opens
* it at once. */
export function preloadCountryPicker(): Promise<Module> {
loading ??= import("@/registry/base-nova/ui/country-picker").then((m) => {
// Rendered on a server too, where there is no reader to name for.
if (typeof window === "undefined") return m
const warm = () => {
m.countries()
m.readerRegions()
}
if ("requestIdleCallback" in window) requestIdleCallback(warm)
else setTimeout(warm, 0)
return m
})
return loading
}
const Loaded = React.lazy(() =>
preloadCountryPicker().then((m) => ({ default: m.CountryPickerPopover })),
)
export function LazyCountryPickerPopover(
props: React.ComponentProps<typeof Picker>,
) {
// Drawn, it asks for the picker; until it comes the trigger stands as itself.
return (
<React.Suspense fallback={props.children}>
<Loaded {...props} />
</React.Suspense>
)
}