Forms and inputsCountry select
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callCountry 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.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add country-selectReact
npx designme add country-select --reactThe 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.
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
- Typing filters by country name, and typing a full two-letter code such as "DE" finds that country.
- Each row shows a flag, the name and its
currencycode, with a check on the chosen country. - Twelve countries with drawn flags are built in, and your own
countriescan bring their ownflag. - The hint under the field switches from
hinttopickedHint, which defaults to "Prices shown in EUR" style text. - The highlight starts on the chosen country, and the list scrolls itself to keep the highlighted row in view without moving the page.
value,openandquerycan each be controlled, so code can open the list and type into it.
- The field is a button with
aria-haspopup="listbox"andaria-expanded, tied to a visiblelabel. - The search box is a
comboboxthat points at the highlightedoptionwitharia-activedescendant; the chosen row hasaria-selected. - Arrow Down or Arrow Up on the field opens the list, the arrows move the highlight, Enter picks and Escape closes and returns focus to the field.
- Tab out of the search box closes the list.
- The closed popover is
inert, so its contents cannot be reached by Tab.
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>
);
}
Props
Everything CountrySelect takes.
| Prop | Type | What it does |
|---|---|---|
countries | CountrySelectCountry[] | Defaults to the twelve countries on designme.dev |
value | string | The chosen country's code. Defaults to the first country |
defaultValue | string | |
onValueChange | (code: string) => void | |
open | boolean | Whether the list is open. Leave it out to let the field toggle it |
defaultOpen | boolean | |
onOpenChange | (open: boolean) => void | |
query | string | What is typed in the search box. Opening the list from the field clears it |
defaultQuery | string | |
onQueryChange | (query: string) => void | |
label | string | |
hint | string | The quiet line under the field until a country is picked |
pickedHint | (country: CountrySelectCountry) => string | The line under the field once one is picked |
placeholder | string | |
suggestedLabel | string | The heading over the full list |
emptyLabel | string | |
className | string |
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(); }));
});
Questions
What people ask about it.
- Can I use my own country list?
- Yes. Pass
countrieswithcode,nameandcurrencyfor each one. Countries outside the twelve built-in ones show a blank flag unless you give them aflagnode. - 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,hintis shown instead. - What is selected before the person picks anything?
- The first country in the list, unless you set
defaultValueor control it withvalue.
More in Forms and inputs
Buttons, fields, pickers and sign-in flows for any product.