Skip to content
  1. Selection
  2. Country select

Country select

A country picker on the Input base: the flag and the name in the field, a searchable list of every country in a popover. The value is the ISO code.

import { CountrySelect } from '@/components/country-select'
import { Field, Label } from '@/components/fieldset'
import { useState } from 'react'

export default function Example() {
  let [country, setCountry] = useState('')

  return (
    <Field>
      <Label>Country</Label>
      <CountrySelect value={country} onChange={setCountry} name="country" />
    </Field>
  )
}
<script setup lang="ts">
import CountrySelect from '@/components/country-select.vue'
import Field from '@/components/field.vue'
import Label from '@/components/label.vue'
import { ref } from 'vue'

const country = ref('')
</script>

<template>
  <Field>
    <Label>Country</Label>
    <CountrySelect v-model="country" name="country" />
  </Field>
</template>

Dependencies

Flags come from country-flag-icons as inline SVG, and the country list types its codes with libphonenumber-js, which the Phone input shares:

npm install country-flag-icons libphonenumber-js
pnpm add country-flag-icons libphonenumber-js
yarn add country-flag-icons libphonenumber-js
bun add country-flag-icons libphonenumber-js
npm install country-flag-icons libphonenumber-js
pnpm add country-flag-icons libphonenumber-js
yarn add country-flag-icons libphonenumber-js
bun add country-flag-icons libphonenumber-js

Installation

Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.

'use client'

import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'
import { CheckIcon, ChevronDownIcon, MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/16/solid'
import clsx from 'clsx'
import type React from 'react'
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import { countries, findCountryByCode, normalizeForSearch, type Country } from './countries'
import { CountryFlag } from './country-flag'

export function CountryList({
  selected,
  onSelect,
  dialCodes = false,
  placeholder = 'Search countries…',
}: {
  selected?: string
  onSelect: (country: Country) => void
  dialCodes?: boolean
  placeholder?: string
}) {
  let [query, setQuery] = useState('')
  let optionRefs = useRef<(HTMLButtonElement | null)[]>([])

  let filtered = useMemo(() => {
    let normalized = normalizeForSearch(query.trim())
    if (normalized === '') return countries
    return countries.filter(
      (country) =>
        normalizeForSearch(country.name).includes(normalized) || (dialCodes && country.dialCode.includes(normalized))
    )
  }, [query, dialCodes])

  let searchRef = useRef<HTMLInputElement>(null)
  useEffect(() => {
    searchRef.current?.focus({ preventScroll: true })
  }, [])

  function moveFocus(index: number) {
    let options = optionRefs.current.filter(Boolean)
    if (options.length === 0) return
    options[Math.min(Math.max(index, 0), options.length - 1)]?.focus()
  }

  return (
    <>
      <div className="flex items-center gap-2 border-b border-zinc-950/5 px-3 py-2.5 dark:border-white/5">
        <MagnifyingGlassIcon className="size-4 shrink-0 fill-zinc-500 dark:fill-zinc-400" />
        <input
          ref={searchRef}
          type="search"
          value={query}
          aria-label={placeholder}
          placeholder={placeholder}
          onChange={(event) => setQuery(event.target.value)}
          onKeyDown={(event) => {
            if (event.key === 'ArrowDown') {
              event.preventDefault()
              moveFocus(0)
            }
          }}
          className="h-7 w-full min-w-0 bg-transparent text-base/6 text-zinc-950 outline-none placeholder:text-zinc-500 sm:text-sm/6 dark:text-white"
        />
        {query && (
          <button
            type="button"
            aria-label="Clear search"
            onClick={() => setQuery('')}
            className="flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-sm text-zinc-500 hover:bg-zinc-950/5 hover:text-zinc-950 dark:hover:bg-white/10 dark:hover:text-white"
          >
            <XMarkIcon className="size-3.5" />
          </button>
        )}
      </div>
      <div className="max-h-64 overflow-y-auto p-1">
        {filtered.length === 0 && (
          <p className="px-3 py-6 text-center text-zinc-500 dark:text-zinc-400 text-base/6 sm:text-sm/6">
            No countries found.
          </p>
        )}
        <div role="listbox" aria-label="Countries">
          {filtered.map((country, index) => (
            <button
              key={country.code}
              role="option"
              aria-selected={country.code === selected}
              ref={(element) => {
                optionRefs.current[index] = element
              }}
              type="button"
              onClick={() => onSelect(country)}
              onKeyDown={(event) => {
                if (event.key === 'ArrowDown') {
                  event.preventDefault()
                  moveFocus(index + 1)
                } else if (event.key === 'ArrowUp') {
                  event.preventDefault()
                  moveFocus(index - 1)
                }
              }}
              className={clsx(
                'flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-left text-base/6 focus:outline-hidden focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent-500 sm:text-sm/6',
                country.code === selected
                  ? 'bg-zinc-950/5 text-zinc-950 dark:bg-white/10 dark:text-white'
                  : 'text-zinc-950 hover:bg-zinc-950/5 dark:text-white dark:hover:bg-white/5'
              )}
            >
              <CountryFlag code={country.code} />
              <span className="min-w-0 flex-1 truncate">{country.name}</span>
              {dialCodes ? (
                <span className="shrink-0 text-xs text-zinc-500 tabular-nums dark:text-zinc-400">{country.dialCode}</span>
              ) : (
                country.code === selected && <CheckIcon className="size-4 shrink-0 text-zinc-950 dark:text-white" />
              )}
            </button>
          ))}
        </div>
      </div>
    </>
  )
}

function useFieldLabel(control: React.RefObject<HTMLElement | null>) {
  let [field, setField] = useState<{ id?: string; labelId?: string; descriptionId?: string }>({})

  useEffect(() => {
    let parent = control.current?.closest('[data-slot=control]')?.parentElement
    let label = parent?.querySelector<HTMLLabelElement>(':scope > [data-slot=label]')
    let description = parent?.querySelector<HTMLElement>(':scope > [data-slot=description]')
    setField({ id: label?.htmlFor || undefined, labelId: label?.id, descriptionId: description?.id })
  }, [control])

  return field
}

const wrapper = {
  layout: 'relative block w-full',
  background: 'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-sm before:bg-white dark:before:hidden',
  focus: [
    'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-inset after:ring-transparent',
    'sm:focus-within:after:ring-2 sm:focus-within:after:ring-accent-500',
  ],
  disabled: 'opacity-50 before:shadow-none before:bg-zinc-950/5',
}

export function CountrySelect({
  value,
  onChange,
  placeholder = 'Select country',
  disabled = false,
  name,
  className,
  ...props
}: {
  value: string
  onChange: (value: string) => void
  placeholder?: string
  disabled?: boolean
  name?: string
} & Omit<React.ComponentPropsWithoutRef<'span'>, 'onChange' | 'children'>) {
  let selected = findCountryByCode(value)
  let button = useRef<HTMLButtonElement>(null)
  let field = useFieldLabel(button)
  let valueId = useId()

  return (
    <span
      data-slot="control"
      {...props}
      className={clsx(
        className,
        wrapper.layout,
        wrapper.background,
        wrapper.focus,
        disabled && wrapper.disabled
      )}
    >
      {name && <input type="hidden" name={name} value={value} />}
      <Popover>
        {({ open, close }) => (
          <>
            <PopoverButton
              ref={button}
              id={field.id}
              disabled={disabled}
              aria-haspopup="listbox"
              aria-labelledby={field.labelId ? `${field.labelId} ${valueId}` : undefined}
              aria-describedby={field.descriptionId}
              className="relative flex w-full cursor-pointer items-center gap-2 rounded-lg border border-zinc-950/10 bg-transparent px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] text-left text-base/6 text-zinc-950 hover:border-zinc-950/20 focus:outline-hidden disabled:cursor-not-allowed sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)] sm:text-sm/6 dark:border-white/10 dark:bg-white/5 dark:text-white dark:hover:border-white/20"
            >
              {selected ? (
                <>
                  <CountryFlag code={selected.code} />
                  <span id={valueId} className="min-w-0 flex-1 truncate">
                    {selected.name}
                  </span>
                </>
              ) : (
                <span id={valueId} className="min-w-0 flex-1 truncate text-zinc-500">
                  {placeholder}
                </span>
              )}
              {selected && !disabled && <span aria-hidden="true" className="size-5 shrink-0" />}
              <ChevronDownIcon
                className={clsx(
                  'size-4 shrink-0 text-zinc-500 transition-transform dark:text-zinc-400',
                  open && 'rotate-180'
                )}
              />
            </PopoverButton>
            <PopoverPanel
              anchor={{ to: 'bottom start', gap: 8, padding: 8 }}
              className="z-50 w-[min(24rem,calc(100vw-1rem))] overflow-hidden rounded-xl border border-zinc-950/10 bg-white shadow-lg outline-none dark:border-white/10 dark:bg-zinc-800"
            >
              <CountryList
                selected={value}
                onSelect={(country) => {
                  onChange(country.code)
                  close()
                }}
              />
            </PopoverPanel>
          </>
        )}
      </Popover>
      {selected && !disabled && (
        <button
          type="button"
          aria-label={`Clear ${selected.name}`}
          onClick={() => {
            onChange('')
            button.current?.focus()
          }}
          className="absolute top-1/2 right-[calc(--spacing(9.5)-2px)] flex size-6 -translate-y-1/2 cursor-pointer items-center justify-center rounded-md text-zinc-400 hover:bg-zinc-950/5 hover:text-zinc-950 focus:outline-hidden focus-visible:outline-2 focus-visible:outline-accent-500 sm:right-[calc(--spacing(9)-2px)] dark:text-zinc-500 dark:hover:bg-white/10 dark:hover:text-white"
        >
          <XMarkIcon className="size-3.5 shrink-0" />
        </button>
      )}
    </span>
  )
}
<script setup lang="ts">
import { ChevronDownIcon, XMarkIcon } from '@heroicons/vue/16/solid'
import { PopoverContent, PopoverPortal, PopoverRoot, PopoverTrigger } from 'reka-ui'
import { computed, ref, useAttrs, useId } from 'vue'
import { findCountryByCode, type Country } from './countries'
import CountryFlag from './country-flag.vue'
import CountryList from './country-list.vue'
import { useDisabled, useFieldControl } from './fields'

defineOptions({ inheritAttrs: false })

const props = withDefaults(
  defineProps<{
    placeholder?: string
    disabled?: boolean
    name?: string
  }>(),
  { placeholder: 'Select country', disabled: false, name: undefined }
)

const model = defineModel<string>({ default: '' })

const attrs = useAttrs()
const { field, id, describedBy } = useFieldControl(attrs)
const valueId = useId()
const trigger = ref<InstanceType<typeof PopoverTrigger>>()

const triggerAttrs = computed(() => {
  const { class: _class, ...rest } = attrs
  return rest
})

const labelledBy = computed(
  () => (attrs['aria-labelledby'] as string | undefined) ?? (field?.labelledBy.value ? `${field.labelledBy.value} ${valueId}` : undefined)
)

const isDisabled = useDisabled(() => props.disabled)

const wrapper = {
  layout: 'relative block w-full',
  background: 'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-sm before:bg-white dark:before:hidden',
  focus: [
    'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-inset after:ring-transparent',
    'sm:focus-within:after:ring-2 sm:focus-within:after:ring-accent-500',
  ],
  disabled: 'opacity-50 before:shadow-none before:bg-zinc-950/5',
}

const open = ref(false)
const selected = computed(() => findCountryByCode(model.value))

function select(country: Country) {
  model.value = country.code
  open.value = false
}

function clear() {
  model.value = ''
  ;(trigger.value?.$el as HTMLElement | undefined)?.focus()
}
</script>

<template>
  <span
    data-slot="control"
    :class="[
      attrs.class,
      wrapper.layout,
      wrapper.background,
      wrapper.focus,
      isDisabled && wrapper.disabled,
    ]"
  >
    <input v-if="name" type="hidden" :name="name" :value="model" />
    <PopoverRoot v-model:open="open">
      <PopoverTrigger
        ref="trigger"
        v-bind="triggerAttrs"
        type="button"
        :id="id"
        :disabled="isDisabled"
        aria-haspopup="listbox"
        :aria-labelledby="labelledBy"
        :aria-describedby="describedBy"
        class="relative flex w-full cursor-pointer items-center gap-2 rounded-lg border border-zinc-950/10 bg-transparent px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] text-left text-base/6 text-zinc-950 hover:border-zinc-950/20 focus:outline-hidden disabled:cursor-not-allowed sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)] sm:text-sm/6 dark:border-white/10 dark:bg-white/5 dark:text-white dark:hover:border-white/20"
      >
        <template v-if="selected">
          <CountryFlag :code="selected.code" />
          <span :id="valueId" class="min-w-0 flex-1 truncate">{{ selected.name }}</span>
        </template>
        <span v-else :id="valueId" class="min-w-0 flex-1 truncate text-zinc-500">{{ placeholder }}</span>
        <span v-if="selected && !isDisabled" aria-hidden="true" class="size-5 shrink-0" />
        <ChevronDownIcon
          class="size-4 shrink-0 text-zinc-500 transition-transform dark:text-zinc-400"
          :class="open && 'rotate-180'"
        />
      </PopoverTrigger>
      <PopoverPortal>
        <PopoverContent
          align="start"
          :side-offset="8"
          :collision-padding="8"
          class="z-50 w-[min(24rem,calc(100vw-1rem))] overflow-hidden rounded-xl border border-zinc-950/10 bg-white shadow-lg outline-none dark:border-white/10 dark:bg-zinc-800"
          @open-auto-focus.prevent
        >
          <CountryList :selected="model" @select="select" />
        </PopoverContent>
      </PopoverPortal>
    </PopoverRoot>
    <button
      v-if="selected && !isDisabled"
      type="button"
      :aria-label="`Clear ${selected.name}`"
      class="absolute top-1/2 right-[calc(--spacing(9.5)-2px)] flex size-6 -translate-y-1/2 cursor-pointer items-center justify-center rounded-md text-zinc-400 hover:bg-zinc-950/5 hover:text-zinc-950 focus:outline-hidden focus-visible:outline-2 focus-visible:outline-accent-500 sm:right-[calc(--spacing(9)-2px)] dark:text-zinc-500 dark:hover:bg-white/10 dark:hover:text-white"
      @click="clear"
    >
      <XMarkIcon class="size-3.5 shrink-0" />
    </button>
  </span>
</template>

Component API

PropDefaultDescription
CountrySelectbuilt on the Headless UI <Headless.Popover> component
value-ISO 3166-1 alpha-2 code of the selected country, `''` for none.
onChange-Called with the code picked, or `''` when cleared.
placeholder'Select country'Shown when nothing is selected.
disabledfalseDisables the trigger.
name-Adds a hidden input carrying the code, for a plain form post.
CountryFlagrenders a <span> element
code-ISO 3166-1 alpha-2 code. An unknown code renders nothing.
label-Accessible name. Without it the flag is decorative.
countries
countries-Every country with a dialing code, as `{ code, name, dialCode }`, alphabetical.
findCountryByCode-The entry for a code, or `null`.
normalizeForSearch-Lowercase, accents stripped, for a search that finds Côte d’Ivoire from `cote`.
PropDefaultDescription
CountrySelectbuilt on the Reka UI <PopoverRoot> component
v-model''ISO 3166-1 alpha-2 code of the selected country, `''` for none.
placeholder'Select country'Shown when nothing is selected.
disabledfalseDisables the trigger. Also follows the `Field`.
name-Adds a hidden input carrying the code, for a plain form post.
CountryFlagrenders a <span> element
code-ISO 3166-1 alpha-2 code. An unknown code renders nothing.
label-Accessible name. Without it the flag is decorative.
countries
countries-Every country with a dialing code, as `{ code, name, dialCode }`, alphabetical.
findCountryByCode-The entry for a code, or `null`.
normalizeForSearch-Lowercase, accents stripped, for a search that finds Côte d’Ivoire from `cote`.

Examples

Basic example

Wrap it in a Field with a Label. Type to search by name, accents ignored; the arrow keys walk the list. The cross in the field clears the choice:

import { CountrySelect } from '@/components/country-select'
import { Field, Label } from '@/components/fieldset'
import { useState } from 'react'

export default function Example() {
  let [country, setCountry] = useState('')

  return (
    <Field>
      <Label>Country</Label>
      <CountrySelect value={country} onChange={setCountry} name="country" />
    </Field>
  )
}
<script setup lang="ts">
import CountrySelect from '@/components/country-select.vue'
import Field from '@/components/field.vue'
import Label from '@/components/label.vue'
import { ref } from 'vue'

const country = ref('')
</script>

<template>
  <Field>
    <Label>Country</Label>
    <CountrySelect v-model="country" name="country" />
  </Field>
</template>

With a value

Pass the code and the field shows the flag and the name:

Where the money comes from, for the compliance file.

<CountrySelect value="SN" onChange={setCountry} />
<CountrySelect v-model="origin" />

Disabled

Disabled, the field fades and loses its clear button:

<Field disabled>
  <Label>Country</Label>
  <CountrySelect value="FR" onChange={setCountry} disabled />
</Field>
<Field disabled>
  <Label>Country</Label>
  <CountrySelect model-value="FR" disabled />
</Field>

Flag on its own

CountryFlag renders the flag alone, at 24 by 16 pixels with a hairline ring. Give it a label when it is not next to the name:

import { CountryFlag } from '@/components/country-flag'

<CountryFlag code="SN" />
<CountryFlag code="NG" label="Nigeria" />
<script setup lang="ts">
import CountryFlag from '@/components/country-flag.vue'
</script>

<template>
  <CountryFlag code="SN" />
  <CountryFlag code="NG" label="Nigeria" />
</template>