# Flag Picker

> A searchable, keyboard-accessible flag combobox with typed country codes.

A shadcn/ui block from the [@flagcn](https://flagcn.dev) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/flagcn/flag-picker).

- Page, with a live preview: https://shadcn.exchange/r/flagcn/flag-picker
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/flagcn/flag-picker-light-6c3bd6dc5dc8f51b.png
- Registry item: https://flagcn.dev/r/flag-picker.json
- Categories: flags, combobox, forms, internationalization
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://flagcn.dev/r/flag-picker.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@tabler/icons-react`
- Registry items: `@flagcn/flag`, `@flagcn/country-data`

## Notes from the registry

Use value/onValueChange for controlled state. Pass kinds=['country'] to omit subdivisions and organizations.

## Files

### `src/components/flags/flag-picker.tsx`

```tsx
// SPDX-License-Identifier: MIT

import { IconCheck, IconChevronDown, IconSearch } from "@tabler/icons-react"
import * as React from "react"

import { cn } from "@/lib/utils"

import { Flag } from "./flag"
import { getCountry, normalizeCountrySearch } from "./country-utils"
import { flagNames, flags, type FlagCode, type FlagKind } from "./flag-data"
import type { FlagFormat, FlagRatio } from "./flag-utils"

export interface FlagPickerProps {
  value?: FlagCode
  defaultValue?: FlagCode
  onValueChange?: (code: FlagCode) => void
  format?: FlagFormat
  ratio?: FlagRatio
  placeholder?: string
  searchPlaceholder?: string
  emptyMessage?: string
  kinds?: FlagKind[]
  codes?: readonly FlagCode[]
  disabledCodes?: readonly FlagCode[]
  showCode?: boolean
  showCallingCode?: boolean
  disabled?: boolean
  className?: string
  triggerClassName?: string
  contentClassName?: string
  triggerDisplay?: "name" | "code" | "calling-code" | "flag"
  name?: string
  "aria-label"?: string
}

export function FlagPicker({
  value,
  defaultValue,
  onValueChange,
  format = "svg",
  ratio = "4x3",
  placeholder = "Select a flag",
  searchPlaceholder = "Search by name or code…",
  emptyMessage = "No flags found.",
  kinds,
  codes,
  disabledCodes = [],
  showCode = true,
  showCallingCode = false,
  disabled = false,
  className,
  triggerClassName,
  contentClassName,
  triggerDisplay = "name",
  name,
  "aria-label": ariaLabel,
}: FlagPickerProps) {
  const [internalValue, setInternalValue] = React.useState<FlagCode | undefined>(defaultValue)
  const [phase, setPhase] = React.useState<"closed" | "open" | "closing">("closed")
  const [query, setQuery] = React.useState("")
  const [activeIndex, setActiveIndex] = React.useState(0)
  const rootRef = React.useRef<HTMLDivElement>(null)
  const triggerRef = React.useRef<HTMLButtonElement>(null)
  const searchRef = React.useRef<HTMLInputElement>(null)
  const closeTimerRef = React.useRef<number | undefined>(undefined)
  const listId = React.useId()
  const currentValue = value ?? internalValue
  const currentCountry = currentValue ? getCountry(currentValue) : undefined
  const open = phase === "open"
  const allowedCodes = React.useMemo(() => codes ? new Set(codes) : undefined, [codes])
  const disabledCodeSet = React.useMemo(() => new Set(disabledCodes), [disabledCodes])

  const filteredFlags = React.useMemo(() => {
    const normalizedQuery = normalizeCountrySearch(query)
    return flags.filter((flag) => {
      const inKind = !kinds?.length || kinds.includes(flag.kind)
      const country = getCountry(flag.code)
      const searchValue = country
        ? [flag.name, flag.code, country.alpha2, country.alpha3, country.nativeName, ...country.aliases, ...country.callingCodes].join(" ")
        : `${flag.name} ${flag.code}`
      return inKind && (!allowedCodes || allowedCodes.has(flag.code)) && (!normalizedQuery || normalizeCountrySearch(searchValue).includes(normalizedQuery))
    })
  }, [allowedCodes, kinds, query])

  const closePicker = React.useCallback((restoreFocus = false) => {
    window.clearTimeout(closeTimerRef.current)
    setPhase((current) => current === "closed" ? current : "closing")
    closeTimerRef.current = window.setTimeout(() => {
      setPhase("closed")
      if (restoreFocus) triggerRef.current?.focus()
    }, 150)
  }, [])

  React.useEffect(() => () => window.clearTimeout(closeTimerRef.current), [])

  React.useEffect(() => {
    if (!open) return
    const onPointerDown = (event: PointerEvent) => {
      if (!rootRef.current?.contains(event.target as Node)) closePicker()
    }
    document.addEventListener("pointerdown", onPointerDown)
    return () => document.removeEventListener("pointerdown", onPointerDown)
  }, [closePicker, open])

  React.useEffect(() => {
    if (!open) return
    requestAnimationFrame(() => searchRef.current?.focus())
  }, [open])

  function selectFlag(code: FlagCode) {
    if (disabledCodeSet.has(code)) return
    if (value === undefined) setInternalValue(code)
    onValueChange?.(code)
    setQuery("")
    closePicker(true)
  }

  function handleKeyDown(event: React.KeyboardEvent) {
    if (event.key === "Escape") {
      closePicker(true)
      return
    }
    if (event.key === "ArrowDown") {
      event.preventDefault()
      setActiveIndex((index) => Math.min(index + 1, filteredFlags.length - 1))
    }
    if (event.key === "ArrowUp") {
      event.preventDefault()
      setActiveIndex((index) => Math.max(index - 1, 0))
    }
    if (event.key === "Enter" && filteredFlags[activeIndex]) {
      event.preventDefault()
      selectFlag(filteredFlags[activeIndex].code)
    }
  }

  function toggleOpen() {
    if (open) closePicker()
    else {
      window.clearTimeout(closeTimerRef.current)
      setActiveIndex(0)
      setPhase("open")
    }
  }

  return (
    <div ref={rootRef} data-slot="flag-picker" className={cn("relative w-full", className)}>
      {name ? <input type="hidden" name={name} value={currentValue ?? ""} /> : null}
      <button
        ref={triggerRef}
        type="button"
        role="combobox"
        aria-expanded={open}
        aria-controls={listId}
        aria-haspopup="listbox"
        aria-label={ariaLabel ?? (currentValue ? `${placeholder}: ${flagNames[currentValue]}` : placeholder)}
        disabled={disabled}
        className={cn(
          "border-input bg-background ring-offset-background flex h-10 w-full items-center justify-between rounded-md border px-3 text-sm shadow-xs outline-none transition-[color,box-shadow] sm:h-9",
          "focus-visible:border-ring focus-visible:ring-ring/30 focus-visible:ring-[3px]",
          "disabled:cursor-not-allowed disabled:opacity-50",
          triggerClassName,
        )}
        onClick={toggleOpen}
        onKeyDown={(event) => {
          if (!open && (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ")) {
            event.preventDefault()
            setActiveIndex(0)
            window.clearTimeout(closeTimerRef.current)
            setPhase("open")
          }
        }}
      >
        <span className={cn("flex min-w-0 items-center gap-2", !currentValue && "text-muted-foreground")}>
          {currentValue ? (
            <Flag
              code={currentValue}
              format={format}
              width={24}
              ratio={ratio}
              alt=""
              decorative
              className="size-5 object-contain outline outline-1 -outline-offset-1 outline-black/10 dark:outline-white/15"
            />
          ) : null}
          {triggerDisplay !== "flag" ? (
            <span className="truncate">
              {currentValue
                ? triggerDisplay === "code"
                  ? currentValue.toUpperCase()
                  : triggerDisplay === "calling-code"
                    ? currentCountry?.callingCodes[0] ?? currentValue.toUpperCase()
                    : flagNames[currentValue]
                : placeholder}
            </span>
          ) : null}
        </span>
        <IconChevronDown aria-hidden="true" className="text-muted-foreground size-4 shrink-0" />
      </button>

      {phase !== "closed" ? (
        <div
          data-origin="top"
          className={cn(
            "t-dropdown bg-popover text-popover-foreground absolute z-50 mt-1 w-full min-w-64 overflow-hidden rounded-md border shadow-md",
            phase === "open" ? "is-open" : "is-closing",
            contentClassName,
          )}
          onKeyDown={handleKeyDown}
        >
          <div className="border-b p-2">
            <div className="relative">
              <IconSearch
                aria-hidden="true"
                className="text-muted-foreground pointer-events-none absolute start-2.5 top-1/2 size-4 -translate-y-1/2"
              />
              <input
                ref={searchRef}
                value={query}
                onChange={(event) => {
                  setQuery(event.target.value)
                  setActiveIndex(0)
                }}
                placeholder={searchPlaceholder}
                aria-label={searchPlaceholder}
                aria-controls={listId}
                aria-activedescendant={filteredFlags[activeIndex] ? `${listId}-${filteredFlags[activeIndex].code}` : undefined}
                className="placeholder:text-muted-foreground h-9 w-full rounded-sm bg-transparent ps-8 pe-3 text-sm focus-visible:ring-ring/30 focus-visible:ring-[3px]"
              />
            </div>
          </div>
          <div id={listId} role="listbox" className="max-h-72 overflow-y-auto p-1">
            {filteredFlags.length ? filteredFlags.map((flag, index) => (
              <button
                key={flag.code}
                id={`${listId}-${flag.code}`}
                type="button"
                role="option"
                aria-selected={currentValue === flag.code}
                disabled={disabledCodeSet.has(flag.code)}
                data-active={index === activeIndex || undefined}
                className="data-[active]:bg-accent data-[active]:text-accent-foreground flex min-h-10 w-full items-center gap-2 rounded-sm px-2 py-2 text-start text-sm hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring/30 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-40 sm:min-h-9"
                onPointerMove={() => setActiveIndex(index)}
                onClick={() => selectFlag(flag.code)}
              >
                <Flag
                  code={flag.code}
                  format={format}
                  width={24}
                  ratio={ratio}
                  alt=""
                  decorative
                  className="h-[15px] w-5 shrink-0 object-contain outline outline-1 -outline-offset-1 outline-black/10 dark:outline-white/15"
                />
                <span className="min-w-0 flex-1 truncate">{flag.name}</span>
                {showCallingCode && getCountry(flag.code)?.callingCodes[0] ? (
                  <span className="text-muted-foreground font-mono text-[11px]">{getCountry(flag.code)?.callingCodes[0]}</span>
                ) : showCode ? (
                  <span className="text-muted-foreground font-mono text-[11px] uppercase">{flag.code}</span>
                ) : null}
                <IconCheck
                  aria-hidden="true"
                  className={cn("size-4", currentValue === flag.code ? "opacity-100" : "opacity-0")}
                />
              </button>
            )) : (
              <p className="text-muted-foreground px-3 py-8 text-center text-sm">{emptyMessage}</p>
            )}
          </div>
        </div>
      ) : null}
    </div>
  )
}
```

### `src/components/flags/flag-data.ts`

```ts
// SPDX-License-Identifier: MIT

export const flagNames = {
  ad: "Andorra",
  ae: "United Arab Emirates",
  af: "Afghanistan",
  ag: "Antigua and Barbuda",
  ai: "Anguilla",
  al: "Albania",
  am: "Armenia",
  ao: "Angola",
  aq: "Antarctica",
  ar: "Argentina",
  as: "American Samoa",
  at: "Austria",
  au: "Australia",
  aw: "Aruba",
  ax: "Åland Islands",
  az: "Azerbaijan",
  ba: "Bosnia and Herzegovina",
  bb: "Barbados",
  bd: "Bangladesh",
  be: "Belgium",
  bf: "Burkina Faso",
  bg: "Bulgaria",
  bh: "Bahrain",
  bi: "Burundi",
  bj: "Benin",
  bl: "Saint Barthélemy",
  bm: "Bermuda",
  bn: "Brunei",
  bo: "Bolivia",
  bq: "Caribbean Netherlands",
  br: "Brazil",
  bs: "Bahamas",
  bt: "Bhutan",
  bv: "Bouvet Island",
  bw: "Botswana",
  by: "Belarus",
  bz: "Belize",
  ca: "Canada",
  cc: "Cocos (Keeling) Islands",
  cd: "DR Congo",
  cf: "Central African Republic",
  cg: "Republic of the Congo",
  ch: "Switzerland",
  ci: "Côte d'Ivoire (Ivory Coast)",
  ck: "Cook Islands",
  cl: "Chile",
  cm: "Cameroon",
  cn: "China",
  co: "Colombia",
  cr: "Costa Rica",
  cu: "Cuba",
  cv: "Cape Verde",
  cw: "Curaçao",
  cx: "Christmas Island",
  cy: "Cyprus",
  cz: "Czechia",
  de: "Germany",
  dj: "Djibouti",
  dk: "Denmark",
  dm: "Dominica",
  do: "Dominican Republic",
  dz: "Algeria",
  ec: "Ecuador",
  ee: "Estonia",
  eg: "Egypt",
  eh: "Western Sahara",
  er: "Eritrea",
  es: "Spain",
  et: "Ethiopia",
  eu: "European Union",
  fi: "Finland",
  fj: "Fiji",
  fk: "Falkland Islands",
  fm: "Micronesia",
  fo: "Faroe Islands",
  fr: "France",
  ga: "Gabon",
  gb: "United Kingdom",
  "gb-eng": "England",
  "gb-nir": "Northern Ireland",
  "gb-sct": "Scotland",
  "gb-wls": "Wales",
  gd: "Grenada",
  ge: "Georgia",
  gf: "French Guiana",
  gg: "Guernsey",
  gh: "Ghana",
  gi: "Gibraltar",
  gl: "Greenland",
  gm: "Gambia",
  gn: "Guinea",
  gp: "Guadeloupe",
  gq: "Equatorial Guinea",
  gr: "Greece",
  gs: "South Georgia",
  gt: "Guatemala",
  gu: "Guam",
  gw: "Guinea-Bissau",
  gy: "Guyana",
  hk: "Hong Kong",
  hm: "Heard Island and McDonald Islands",
  hn: "Honduras",
  hr: "Croatia",
  ht: "Haiti",
  hu: "Hungary",
  id: "Indonesia",
  ie: "Ireland",
  il: "Israel",
  im: "Isle of Man",
  in: "India",
  io: "British Indian Ocean Territory",
  iq: "Iraq",
  ir: "Iran",
  is: "Iceland",
  it: "Italy",
  je: "Jersey",
  jm: "Jamaica",
  jo: "Jordan",
  jp: "Japan",
  ke: "Kenya",
  kg: "Kyrgyzstan",
  kh: "Cambodia",
  ki: "Kiribati",
  km: "Comoros",
  kn: "Saint Kitts and Nevis",
  kp: "North Korea",
  kr: "South Korea",
  kw: "Kuwait",
  ky: "Cayman Islands",
  kz: "Kazakhstan",
  la: "Laos",
  lb: "Lebanon",
  lc: "Saint Lucia",
  li: "Liechtenstein",
  lk: "Sri Lanka",
  lr: "Liberia",
  ls: "Lesotho",
  lt: "Lithuania",
  lu: "Luxembourg",
  lv: "Latvia",
  ly: "Libya",
  ma: "Morocco",
  mc: "Monaco",
  md: "Moldova",
  me: "Montenegro",
  mf: "Saint Martin",
  mg: "Madagascar",
  mh: "Marshall Islands",
  mk: "North Macedonia",
  ml: "Mali",
  mm: "Myanmar",
  mn: "Mongolia",
  mo: "Macau",
  mp: "Northern Mariana Islands",
  mq: "Martinique",
  mr: "Mauritania",
  ms: "Montserrat",
  mt: "Malta",
  mu: "Mauritius",
  mv: "Maldives",
  mw: "Malawi",
  mx: "Mexico",
  my: "Malaysia",
  mz: "Mozambique",
  na: "Namibia",
  nc: "New Caledonia",
  ne: "Niger",
  nf: "Norfolk Island",
  ng: "Nigeria",
  ni: "Nicaragua",
  nl: "Netherlands",
  no: "Norway",
  np: "Nepal",
  nr: "Nauru",
  nu: "Niue",
  nz: "New Zealand",
  om: "Oman",
  pa: "Panama",
  pe: "Peru",
  pf: "French Polynesia",
  pg: "Papua New Guinea",
  ph: "Philippines",
  pk: "Pakistan",
  pl: "Poland",
  pm: "Saint Pierre and Miquelon",
  pn: "Pitcairn Islands",
  pr: "Puerto Rico",
  ps: "Palestine",
  pt: "Portugal",
  pw: "Palau",
  py: "Paraguay",
  qa: "Qatar",
  re: "Réunion",
  ro: "Romania",
  rs: "Serbia",
  ru: "Russia",
  rw: "Rwanda",
  sa: "Saudi Arabia",
  sb: "Solomon Islands",
  sc: "Seychelles",
  sd: "Sudan",
  se: "Sweden",
  sg: "Singapore",
  sh: "Saint Helena, Ascension and Tristan da Cunha",
  si: "Slovenia",
  sj: "Svalbard and Jan Mayen",
  sk: "Slovakia",
  sl: "Sierra Leone",
  sm: "San Marino",
  sn: "Senegal",
  so: "Somalia",
  sr: "Suriname",
  ss: "South Sudan",
  st: "São Tomé and Príncipe",
  sv: "El Salvador",
  sx: "Sint Maarten",
  sy: "Syria",
  sz: "Eswatini (Swaziland)",
  tc: "Turks and Caicos Islands",
  td: "Chad",
  tf: "French Southern and Antarctic Lands",
  tg: "Togo",
  th: "Thailand",
  tj: "Tajikistan",
  tk: "Tokelau",
  tl: "Timor-Leste",
  tm: "Turkmenistan",
  tn: "Tunisia",
  to: "Tonga",
  tr: "Turkey",
  tt: "Trinidad and Tobago",
  tv: "Tuvalu",
  tw: "Taiwan",
  tz: "Tanzania",
  ua: "Ukraine",
  ug: "Uganda",
  um: "United States Minor Outlying Islands",
  un: "United Nations",
  us: "United States",
  "us-ak": "Alaska",
  "us-al": "Alabama",
  "us-ar": "Arkansas",
  "us-az": "Arizona",
  "us-ca": "California",
  "us-co": "Colorado",
  "us-ct": "Connecticut",
  "us-de": "Delaware",
  "us-fl": "Florida",
  "us-ga": "Georgia",
  "us-hi": "Hawaii",
  "us-ia": "Iowa",
  "us-id": "Idaho",
  "us-il": "Illinois",
  "us-in": "Indiana",
  "us-ks": "Kansas",
  "us-ky": "Kentucky",
  "us-la": "Louisiana",
  "us-ma": "Massachusetts",
  "us-md": "Maryland",
  "us-me": "Maine",
  "us-mi": "Michigan",
  "us-mn": "Minnesota",
  "us-mo": "Missouri",
  "us-ms": "Mississippi",
  "us-mt": "Montana",
  "us-nc": "North Carolina",
  "us-nd": "North Dakota",
  "us-ne": "Nebraska",
  "us-nh": "New Hampshire",
  "us-nj": "New Jersey",
  "us-nm": "New Mexico",
  "us-nv": "Nevada",
  "us-ny": "New York",
  "us-oh": "Ohio",
  "us-ok": "Oklahoma",
  "us-or": "Oregon",
  "us-pa": "Pennsylvania",
  "us-ri": "Rhode Island",
  "us-sc": "South Carolina",
  "us-sd": "South Dakota",
  "us-tn": "Tennessee",
  "us-tx": "Texas",
  "us-ut": "Utah",
  "us-va": "Virginia",
  "us-vt": "Vermont",
  "us-wa": "Washington",
  "us-wi": "Wisconsin",
  "us-wv": "West Virginia",
  "us-wy": "Wyoming",
  uy: "Uruguay",
  uz: "Uzbekistan",
  va: "Vatican City (Holy See)",
  vc: "Saint Vincent and the Grenadines",
  ve: "Venezuela",
  vg: "British Virgin Islands",
  vi: "United States Virgin Islands",
  vn: "Vietnam",
  vu: "Vanuatu",
  wf: "Wallis and Futuna",
  ws: "Samoa",
  xk: "Kosovo",
  ye: "Yemen",
  yt: "Mayotte",
  za: "South Africa",
  zm: "Zambia",
  zw: "Zimbabwe",
} as const

export type FlagCode = keyof typeof flagNames
export type FlagKind = "country" | "subdivision" | "organization"

export interface FlagMeta {
  code: FlagCode
  name: (typeof flagNames)[FlagCode]
  kind: FlagKind
}

export const flags = Object.entries(flagNames).map(([code, name]) => ({
  code: code as FlagCode,
  name,
  kind: code === "eu" || code === "un"
    ? "organization"
    : code.startsWith("us-") || code.startsWith("gb-")
      ? "subdivision"
      : "country",
})) satisfies FlagMeta[]

export const flagCount = flags.length

export function isFlagCode(value: string): value is FlagCode {
  return Object.prototype.hasOwnProperty.call(flagNames, value.toLowerCase())
}
```
