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-jspnpm add country-flag-icons libphonenumber-jsyarn add country-flag-icons libphonenumber-jsbun add country-flag-icons libphonenumber-jsnpm install country-flag-icons libphonenumber-jspnpm add country-flag-icons libphonenumber-jsyarn add country-flag-icons libphonenumber-jsbun add country-flag-icons libphonenumber-jsInstallation
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
| Prop | Default | Description |
|---|---|---|
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. |
disabled | false | Disables 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`. |
| Prop | Default | Description |
|---|---|---|
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. |
disabled | false | Disables 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>