Skip to content
Star on GitHub
Forms and inputsCountry select
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Country select

A searchable select with country codes, currency and keyboard support. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add country-select
Used for tax and currency
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add country-select

React

$npx designme add country-select --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

Country select is a dropdown for picking a country, with a search box, a flag on each row and the country's currency at the end. Once a country is picked, the line under the field changes to say which currency prices will be shown in. It fits billing and account setup, where the country decides tax and currency.

  • When a billing or checkout form needs the customer's country
  • When the chosen country should set the currency people see prices in
  • When the list is long enough that people would rather type than scroll
  • When an agent fills in a form and the person should be able to correct the country quickly
03

Usage

Import it and drive it from your own state.

Import

import { CountrySelect } from '@/components/designme/country-select/CountrySelect';

Example

The demo above as a React component, 36 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useRef, useState } from 'react';
import { CountrySelect, type CountrySelectCountry } from './CountrySelect';
import { useStory } from '../lib/useScript';

const COUNTRIES: CountrySelectCountry[] = [
  { code: 'US', name: 'United States', currency: 'USD' }, { code: 'GB', name: 'United Kingdom', currency: 'GBP' },
  { code: 'DE', name: 'Germany', currency: 'EUR' }, { code: 'FR', name: 'France', currency: 'EUR' },
  { code: 'NL', name: 'Netherlands', currency: 'EUR' }, { code: 'IE', name: 'Ireland', currency: 'EUR' },
  { code: 'IT', name: 'Italy', currency: 'EUR' }, { code: 'JP', name: 'Japan', currency: 'JPY' },
  { code: 'CA', name: 'Canada', currency: 'CAD' }, { code: 'ES', name: 'Spain', currency: 'EUR' },
  { code: 'SE', name: 'Sweden', currency: 'SEK' }, { code: 'AU', name: 'Australia', currency: 'AUD' }
];

/* ---------- Country select: the list opens, someone types "ger" and picks Germany ---------- */
export default function CountrySelectDemo() {
  const ref = useRef<HTMLDivElement>(null);
  const [open, setOpen] = useState(false);
  const [query, setQuery] = useState('');
  const [value, setValue] = useState('GB');
  // the story stops at the visitor's first click or key, so it never types into their search or picks over their choice
  useStory(ref, async ({ wait }) => {
    await wait(900); setQuery(''); setOpen(true);
    await wait(500);
    let typed = '';
    for (const ch of 'ger') { typed += ch; setQuery(typed); await wait(160); }
    await wait(1100); setValue('DE'); setOpen(false);
  });
  return (
    <div ref={ref} style={{ width: '100%' }}>
      <CountrySelect countries={COUNTRIES} value={value} onValueChange={setValue} open={open} onOpenChange={setOpen} query={query} onQueryChange={setQuery} />
    </div>
  );
}
04

Props

Everything CountrySelect takes.

PropTypeWhat it does
countriesCountrySelectCountry[]Defaults to the twelve countries on designme.dev
valuestringThe chosen country's code. Defaults to the first country
defaultValuestring
onValueChange(code: string) => void
openbooleanWhether the list is open. Leave it out to let the field toggle it
defaultOpenboolean
onOpenChange(open: boolean) => void
querystringWhat is typed in the search box. Opening the list from the field clears it
defaultQuerystring
onQueryChange(query: string) => void
labelstring
hintstringThe quiet line under the field until a country is picked
pickedHint(country: CountrySelectCountry) => stringThe line under the field once one is picked
placeholderstring
suggestedLabelstringThe heading over the full list
emptyLabelstring
classNamestring
05

Code

The full source, as the CLI installs it.

CountrySelect.tsxReact · 137 lines
'use client';
import { useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable, useIsoLayoutEffect } from '../lib/hooks';
import { Flag, type FlagCode } from '../lib/flags';
import { Icon } from '../lib/icons';
import { Swap } from '../lib/motion';
import '../designme.css';
import './country-select.css';

/** A country in the list */
export interface CountrySelectCountry {
  /** Its two-letter code, like "DE". Typing the whole code finds it */
  code: string;
  name: string;
  /** Its currency code, like "EUR", shown at the end of the row */
  currency: string;
  /** Your own flag. The countries designme.dev draws (US, GB, DE, FR, NL, IE, IT, JP, CA, SE, AU, ES) don't need one */
  flag?: ReactNode;
}

export interface CountrySelectProps {
  /** Defaults to the twelve countries on designme.dev */
  countries?: CountrySelectCountry[];
  /** The chosen country's code. Defaults to the first country */
  value?: string;
  defaultValue?: string;
  onValueChange?: (code: string) => void;
  /** Whether the list is open. Leave it out to let the field toggle it */
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  /** What is typed in the search box. Opening the list from the field clears it */
  query?: string;
  defaultQuery?: string;
  onQueryChange?: (query: string) => void;
  label?: string;
  /** The quiet line under the field until a country is picked */
  hint?: string;
  /** The line under the field once one is picked */
  pickedHint?: (country: CountrySelectCountry) => string;
  placeholder?: string;
  /** The heading over the full list */
  suggestedLabel?: string;
  emptyLabel?: string;
  className?: string;
}

const COUNTRIES: CountrySelectCountry[] = [
  { code: 'US', name: 'United States', currency: 'USD' }, { code: 'GB', name: 'United Kingdom', currency: 'GBP' },
  { code: 'DE', name: 'Germany', currency: 'EUR' }, { code: 'FR', name: 'France', currency: 'EUR' },
  { code: 'NL', name: 'Netherlands', currency: 'EUR' }, { code: 'IE', name: 'Ireland', currency: 'EUR' },
  { code: 'IT', name: 'Italy', currency: 'EUR' }, { code: 'JP', name: 'Japan', currency: 'JPY' },
  { code: 'CA', name: 'Canada', currency: 'CAD' }, { code: 'ES', name: 'Spain', currency: 'EUR' },
  { code: 'SE', name: 'Sweden', currency: 'SEK' }, { code: 'AU', name: 'Australia', currency: 'AUD' }
];
const DRAWN = new Set(COUNTRIES.map(c => c.code));
const flagOf = (c?: CountrySelectCountry) => <span className="dm-csx-fl">{c?.flag ?? (c && DRAWN.has(c.code) ? <Flag code={c.code as FlagCode} /> : <span className="dm-flag" />)}</span>;

/** A searchable country select with flags and currencies. Type to filter, arrows to move, Enter to pick, Escape to close. */
export function CountrySelect({ countries = COUNTRIES, value, defaultValue, onValueChange, open, defaultOpen = false, onOpenChange, query, defaultQuery = '',
  onQueryChange, label = 'Billing country', hint = 'Used for tax and currency', pickedHint = c => `Prices shown in ${c.currency}`,
  placeholder = 'Search countries', suggestedLabel = 'Suggested', emptyLabel = 'No country found', className }: CountrySelectProps) {
  const [code, setCode] = useControllable(value, defaultValue ?? countries[0]?.code ?? '', onValueChange);
  const [isOpen, setOpen] = useControllable(open, defaultOpen, onOpenChange);
  const [q, setQ] = useControllable(query, defaultQuery, onQueryChange);
  const [picked, setPicked] = useState(false);
  const chosen = countries.find(c => c.code === code);
  const v = q.trim().toLowerCase();
  const shown = countries.filter(c => !v || c.name.toLowerCase().includes(v) || c.code.toLowerCase() === v);

  const root = useRef<HTMLDivElement>(null), field = useRef<HTMLButtonElement>(null), search = useRef<HTMLInputElement>(null);
  const list = useRef<HTMLDivElement>(null), opts = useRef<(HTMLButtonElement | null)[]>([]);
  const id = useId();
  // the highlight starts on the chosen country, and again whenever the list changes or opens
  const [opens, setOpens] = useState(0), [wasOpen, setWasOpen] = useState(isOpen);
  if (wasOpen !== isOpen) { setWasOpen(isOpen); if (isOpen) setOpens(opens + 1); }
  const paint = `${opens}|${v}`;
  const [hi, setHi] = useState({ at: 0, for: '' });
  const at = hi.for === paint ? Math.min(hi.at, shown.length - 1) : Math.max(0, shown.findIndex(c => c.code === code));

  // keep the highlighted row in view by scrolling the list, never the page
  useIsoLayoutEffect(() => {
    const l = list.current, o = opts.current[at]; if (!isOpen || !l || !o) return;
    const lr = l.getBoundingClientRect(), r = o.getBoundingClientRect();
    if (r.top < lr.top) l.scrollTop -= lr.top - r.top; else if (r.bottom > lr.bottom) l.scrollTop += r.bottom - lr.bottom;
  }, [at, isOpen, paint]);

  // a click outside closes it
  useEffect(() => {
    if (!isOpen) return;
    const down = (e: PointerEvent) => { if (!root.current?.contains(e.target as Node)) setOpen(false); };
    document.addEventListener('pointerdown', down);
    return () => document.removeEventListener('pointerdown', down);
  }, [isOpen, setOpen]);

  const openList = () => { setQ(''); setOpen(true); requestAnimationFrame(() => search.current?.focus({ preventScroll: true })); };
  const close = (refocus = false) => { setOpen(false); if (refocus) field.current?.focus({ preventScroll: true }); };
  const choose = (c: CountrySelectCountry) => { setCode(c.code); setPicked(true); close(root.current?.contains(document.activeElement) ?? false); };
  const move = (d: number) => { if (shown.length) setHi({ at: (at + d + shown.length) % shown.length, for: paint }); };
  const key = (e: KeyboardEvent<HTMLInputElement>) => {
    if (e.key === 'ArrowDown') { move(1); e.preventDefault(); }
    else if (e.key === 'ArrowUp') { move(-1); e.preventDefault(); }
    else if (e.key === 'Enter') { if (shown[at]) choose(shown[at]); e.preventDefault(); }
    else if (e.key === 'Escape') close(true);
    // Tab moves on from the search box and closes the list
    else if (e.key === 'Tab') close();
  };

  return (
    <div data-dm="" ref={root} className={cx('dm-csx', className)}>
      <label className="dm-csx-l" htmlFor={`${id}-f`}>{label}</label>
      <button ref={field} id={`${id}-f`} type="button" className="dm-csx-f" aria-haspopup="listbox" aria-expanded={isOpen}
        onClick={() => (isOpen ? close() : openList())} onKeyDown={e => { if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return; e.preventDefault(); if (isOpen) search.current?.focus({ preventScroll: true }); else openList(); }}>
        {flagOf(chosen)}<Swap className="dm-csx-v">{chosen?.name ?? ''}</Swap><Icon name="chev-d" size="xs" />
      </button>
      <div className={cx('dm-csx-pop', isOpen && 'dm-open')} inert={!isOpen}>
        <input ref={search} className="dm-csx-q" placeholder={placeholder} aria-label={placeholder} spellCheck={false} value={q} role="combobox"
          aria-expanded={isOpen} aria-controls={`${id}-list`} aria-autocomplete="list" aria-activedescendant={shown[at] ? `${id}-${shown[at].code}` : undefined}
          onChange={e => setQ(e.target.value)} onKeyDown={key} />
        <div ref={list} className="dm-csx-list" role="listbox" id={`${id}-list`} aria-label={label}>
          {!v && <div className="dm-csx-g">{suggestedLabel}</div>}
          {shown.map((c, i) => (
            <button key={c.code} ref={el => { opts.current[i] = el; }} id={`${id}-${c.code}`} type="button" tabIndex={-1} role="option"
              aria-selected={c.code === code} className={cx('dm-csx-o', i === at && 'dm-hl')}
              onMouseEnter={() => setHi({ at: i, for: paint })} onClick={() => choose(c)}>
              {flagOf(c)}{c.name}<span className="dm-cd">{c.currency}</span><span className="dm-ck"><Icon name="check" size="xs" /></span>
            </button>
          ))}
          {!shown.length && <div className="dm-csx-g">{emptyLabel}</div>}
        </div>
      </div>
      <Swap className="dm-muted dm-csx-h">{picked && chosen ? pickedHint(chosen) : hint}</Swap>
    </div>
  );
}
country-select.cssReact · 28 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Country select ---------- */
.dm-csx{width:100%;max-width:var(--dm-field-w);margin:0 auto;display:flex;flex-direction:column;gap:6px;position:relative}
.dm-csx-l{font-size:12.5px;font-weight:500}
.dm-csx-f{display:flex;align-items:center;gap:10px;height:var(--dm-field-h);padding:0 var(--dm-s-3);border-radius:var(--dm-r-field);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line) 50%,transparent);font-size:13.5px;text-align:left;transition:box-shadow var(--dm-t-fast)}
@supports (corner-shape:squircle){.dm-csx-f{corner-shape:squircle;border-radius:var(--dm-r-field-sq)}}
/* a dropdown's field steps up its edge while it has the focus or its list is open; it takes no ring */
.dm-csx-f:focus-visible{outline:0;box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
.dm-csx-v{flex:1}
.dm-csx-f>svg{color:var(--dm-muted);transition:transform .35s var(--dm-ease)}
.dm-csx-f[aria-expanded="true"]>svg{transform:rotate(180deg)}
.dm-csx-fl{display:grid;place-items:center;flex:none}
.dm-csx-h{font-size:12px}
/* closed, the list is hidden as well as clear, so Tab passes over its search box and options */
.dm-csx-pop{position:absolute;top:calc(var(--dm-field-h) + 35px);left:0;right:0;z-index:5;background:var(--dm-raised);border-radius:14px;box-shadow:var(--dm-shadow-pop);padding:6px;opacity:0;visibility:hidden;transform:translateY(-4px) scale(.98);transform-origin:top;filter:blur(3px);pointer-events:none;transition:opacity .2s,transform .35s var(--dm-ease),filter .2s,visibility 0s .2s}
.dm-csx-pop.dm-open{opacity:1;visibility:visible;transform:none;filter:none;pointer-events:auto;transition-property:opacity,transform,filter}
.dm-csx-q{width:100%;height:32px;border:0;outline:0;border-radius:var(--dm-r-field);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line) 50%,transparent);padding:0 10px;font:inherit;font-size:13px;color:var(--dm-fg);margin-bottom:var(--dm-s-1);transition:box-shadow var(--dm-t-fast)}
@supports (corner-shape:squircle){.dm-csx-q{corner-shape:squircle;border-radius:var(--dm-r-field-sq)}}
.dm-csx-q::placeholder{color:var(--dm-muted)}
.dm-csx-q:focus{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
.dm-csx-list{max-height:200px;overflow-y:auto;display:flex;flex-direction:column;gap:2px}
.dm-csx-o{display:flex;align-items:center;gap:10px;padding:7px var(--dm-s-2);border-radius:9px;font-size:13px;text-align:left;flex:none}
.dm-csx-o.dm-hl{background:var(--dm-row)}
.dm-csx-o .dm-cd{margin-left:auto;font-size:11.5px;color:var(--dm-muted)}
.dm-csx-o[aria-selected="true"] .dm-ck{color:var(--dm-accent-fg,var(--dm-accent));display:grid}
.dm-csx-o .dm-ck{display:none}
.dm-csx-g{font-size:11px;color:var(--dm-muted);padding:6px var(--dm-s-2) 2px}
country-select.htmlHTML · 11 lines
<div class="card" data-c="country-select">
  <div class="stage-in">
    <div class="csx">
      <label class="csx-l">Billing country</label>
      <button class="csx-f" aria-haspopup="listbox" aria-expanded="false"><span class="csx-fl"></span><span class="csx-v"></span><svg class="i xs"><use href="#i-chev-d"/></svg></button>
      <div class="csx-pop"><input class="csx-q" placeholder="Search countries" aria-label="Search countries" spellcheck="false"><div class="csx-list" role="listbox"></div></div>
      <span class="muted csx-h">Used for tax and currency</span>
    </div>
  </div>
</div>
country-select.cssHTML · 27 lines
/* ---------- Country select ---------- */
.csx{width:100%;max-width:var(--field-w);margin:0 auto;display:flex;flex-direction:column;gap:6px;position:relative}
.csx-l{font-size:12.5px;font-weight:500}
.csx-f{display:flex;align-items:center;gap:10px;height:var(--field-h);padding:0 var(--s-3);border-radius:var(--r-field);background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line) 50%,transparent);font-size:13.5px;text-align:left;transition:box-shadow var(--t-fast)}
@supports (corner-shape:squircle){.csx-f{corner-shape:squircle;border-radius:var(--r-field-sq)}}
/* a dropdown's field steps up its edge while it has the focus or its list is open; it takes no ring */
.csx-f:focus-visible{outline:0;box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line-strong) 50%,transparent)}
.csx-v{flex:1}
.csx-f>svg{color:var(--muted);transition:transform .35s var(--ease)}
.csx-f[aria-expanded="true"]>svg{transform:rotate(180deg)}
.csx-fl{display:grid;place-items:center;flex:none}
.csx-h{font-size:12px}
/* closed, the list is hidden as well as clear, so Tab passes over its search box and options */
.csx-pop{position:absolute;top:calc(var(--field-h) + 35px);left:0;right:0;z-index:5;background:var(--raised);border-radius:14px;box-shadow:var(--shadow-pop);padding:6px;opacity:0;visibility:hidden;transform:translateY(-4px) scale(.98);transform-origin:top;filter:blur(3px);pointer-events:none;transition:opacity .2s,transform .35s var(--ease),filter .2s,visibility 0s .2s}
.csx-pop.open{opacity:1;visibility:visible;transform:none;filter:none;pointer-events:auto;transition-property:opacity,transform,filter}
.csx-q{width:100%;height:32px;border:0;outline:0;border-radius:var(--r-field);background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line) 50%,transparent);padding:0 10px;font:inherit;font-size:13px;color:var(--fg);margin-bottom:var(--s-1);transition:box-shadow var(--t-fast)}
@supports (corner-shape:squircle){.csx-q{corner-shape:squircle;border-radius:var(--r-field-sq)}}
.csx-q::placeholder{color:var(--muted)}
.csx-q:focus{box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line-strong) 50%,transparent)}
.csx-list{max-height:200px;overflow-y:auto;display:flex;flex-direction:column;gap:2px}
.csx-o{display:flex;align-items:center;gap:10px;padding:7px var(--s-2);border-radius:9px;font-size:13px;text-align:left;flex:none}
.csx-o.hl{background:var(--row)}
.csx-o .cd{margin-left:auto;font-size:11.5px;color:var(--muted)}
.csx-o[aria-selected="true"] .ck{color:var(--accent-fg,var(--accent));display:grid}
.csx-o .ck{display:none}
.csx-g{font-size:11px;color:var(--muted);padding:6px var(--s-2) 2px}
country-select.jsHTML · 47 lines
DM.def('country-select', (r, c) => {
  r.innerHTML = r.innerHTML.replace('{chev}', I('chev-d','xs'));
  const C = [
    ['United States','US',['#b22234','#fff','#3c3b6e'],1,'USD'],['United Kingdom','GB',['#012169','#fff','#c8102e'],0,'GBP'],['Germany','DE',['#000','#dd0000','#ffce00'],1,'EUR'],['France','FR',['#0055a4','#fff','#ef4135'],0,'EUR'],
    ['Netherlands','NL',['#ae1c28','#fff','#21468b'],1,'EUR'],['Ireland','IE',['#169b62','#fff','#ff883e'],0,'EUR'],['Italy','IT',['#009246','#fff','#ce2b37'],0,'EUR'],['Japan','JP',['#fff','#bc002d','#fff'],0,'JPY'],
    ['Canada','CA',['#d80621','#fff','#d80621'],0,'CAD'],['Spain','ES',['#aa151b','#f1bf00','#aa151b'],1,'EUR'],['Sweden','SE',['#006aa7','#fecc00','#006aa7'],1,'SEK'],['Australia','AU',['#012169','#fff','#e4002b'],0,'AUD']
  ];
  const f = $('.csx-f', r), pop = $('.csx-pop', r), q = $('.csx-q', r), list = $('.csx-list', r), h = $('.csx-h', r);
  let sel = C[1], hi = 0, opts = [];
  const flag = co => `<span class="csx-fl">${FLAG(co[1])}</span>`;
  /* the name and the hint crossfade to the country picked and always end on the latest one, so the name never stays on
     an earlier country while the flag (swapped at once) shows the new one */
  const put = (el, t) => { el._to = t; if (el._t || el.textContent === t) return; el.classList.add('sw', 'out');
    el._t = setTimeout(() => { el._t = 0; el.textContent = el._to; el.classList.remove('out'); }, REDUCE ? 0 : 170); };
  function setSel(co){ sel = co; $('.csx-fl', f).outerHTML = flag(co); put($('.csx-v', f), co[0]); put(h, 'Prices shown in ' + co[4]); }
  function paint(){
    const v = q.value.trim().toLowerCase(); const m = C.filter(co => !v || co[0].toLowerCase().includes(v) || co[1].toLowerCase() === v);
    list.innerHTML = (v ? '' : '<div class="csx-g">Suggested</div>') + m.map(co => `<button class="csx-o" role="option" id="${list.id}-${co[1]}" tabindex="-1" aria-selected="${co === sel}" data-c="${co[1]}">${flag(co)}${co[0]}<span class="cd">${co[4]}</span><span class="ck">${I('check','xs')}</span></button>`).join('') || '<div class="csx-g">No country found</div>';
    opts = $$('.csx-o', list); hi = Math.max(0, opts.findIndex(o => o.dataset.c === sel[1])); move(0);
    opts.forEach((o, i) => { o.onmouseenter = () => { hi = i; move(0); }; o.onclick = () => { setSel(C.find(x => x[1] === o.dataset.c)); close(); }; });
  }
  function move(d){ if (!opts.length){ q.removeAttribute('aria-activedescendant'); return; } hi = (hi + d + opts.length) % opts.length; opts.forEach((o, i) => o.classList.toggle('hl', i === hi)); opts[hi].scrollIntoView({ block:'nearest' }); q.setAttribute('aria-activedescendant', opts[hi].id); }
  list.id = 'csx-l-' + Math.random().toString(36).slice(2, 7); q.setAttribute('role', 'combobox'); q.setAttribute('aria-autocomplete', 'list'); q.setAttribute('aria-expanded', 'true'); q.setAttribute('aria-controls', list.id);
  // the field sits centred on its own, and glides up once as the list opens; it makes room for the list at its tallest,
  // so it holds still while typing filters the list
  const box = $('.csx', r), full = () => pop.offsetHeight - list.offsetHeight + parseFloat(getComputedStyle(list).maxHeight);
  const centred = centre(c, box, () => pop.classList.contains('open') ? Math.max(box.offsetHeight, pop.offsetTop + full()) : box.offsetHeight);
  function open(demo){ pop.classList.add('open'); f.setAttribute('aria-expanded', 'true'); q.value = ''; paint(); centred(); if (!demo) q.focus({ preventScroll:true }); }
  // on closing, the field waits for the list to fade before it glides back to the middle
  function close(){ pop.classList.remove('open'); f.setAttribute('aria-expanded', 'false'); c.later(REDUCE ? 0 : 180, centred); }
  f.onclick = () => pop.classList.contains('open') ? close() : open();
  q.oninput = paint;
  // in the search box the arrows move through the countries and wrap, Enter picks, and Escape closes the list and gives the
  // focus back to the field (Home and End stay with the text)
  q.onkeydown = e => { if (e.key === 'ArrowDown'){ move(1); e.preventDefault(); } else if (e.key === 'ArrowUp'){ move(-1); e.preventDefault(); } else if (e.key === 'Enter'){ e.preventDefault(); if (opts[hi]){ opts[hi].click(); f.focus({ preventScroll:true }); } } else if (e.key === 'Escape'){ e.preventDefault(); close(); f.focus({ preventScroll:true }); } };
  // Tab out of the list closes it
  c.on(pop, 'focusout', e => { if (pop.classList.contains('open') && e.relatedTarget && !pop.contains(e.relatedTarget) && e.relatedTarget !== f) close(); });
  // the field opens the list with the down and up arrows too, or takes the focus into it when it is already open
  c.on(f, 'keydown', e => { if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return; e.preventDefault(); if (pop.classList.contains('open')) q.focus({ preventScroll:true }); else open(); });
  c.on(document, 'pointerdown', e => { if (!r.contains(e.target)) close(); });
  $('.csx-v', f).textContent = sel[0]; f.insertAdjacentHTML('afterbegin', ''); $('.csx-fl', f).outerHTML = flag(sel);
  // the preview's story: open the list, type "ger", and pick Germany, which closes it on the new country. It stops at the
  // visitor's first click or key in the component, so it never types into their search or picks over their choice
  c.later(900, () => c.run(async () => { const on = () => { if (c.mine) throw 'stop'; };
    on(); open(true); await c.wait(500); for (const ch of 'ger'){ on(); q.value += ch; paint(); await c.wait(160); } await c.wait(1100); on(); if (opts[hi]) opts[hi].click(); }));
});
06

Questions

What people ask about it.

Can I use my own country list?
Yes. Pass countries with code, name and currency for each one. Countries outside the twelve built-in ones show a blank flag unless you give them a flag node.
How do I change the text under the field after a pick?
Pass pickedHint, a function that receives the chosen country and returns a string. Before anything is picked, hint is shown instead.
What is selected before the person picks anything?
The first country in the list, unless you set defaultValue or control it with value.
07

More in Forms and inputs

Buttons, fields, pickers and sign-in flows for any product.

ButtonsEvery button style, with loading, success and hold-to-confirm states.
BadgeSmall labels for plans, environments and status, with a dot, an icon or a remove button.
TooltipQuiet labels on hover, with shortcuts and a richer preview card.
Search inputSearch with scoped filters, recent queries and an agent answer.
Multi-selectA combobox that filters as you type and keeps your picks as chips.
Social sign-inOne-tap providers that remember the last one you used.
Sign inEmail first, then the one-time code from the email, copied or typed in.
OTP fieldA one-time code, one box per digit, that fills from a paste.
All 11 in Forms and inputs →