Searchable language and region picker that previews dates and numbers before you choose.
01Preview
Language and region
Used for the interface, dates and numbers across Acme Web.
Preview
- Date
- 22 September 2026
- Short date
- 22/09/2026
- Time
- 14:30
- Number
- 1,234,567.89
- Currency
- £1,299.50
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install motion @base-ui/react03Usage
import { LocalePreview, LocaleSelect } from "@/components/ui/locale-select";
const [locale, setLocale] = useState(user.locale);
<LocaleSelect value={locale} onValueChange={(code) => { setLocale(code); api.me.patch({ locale: code }); }} />
<LocalePreview locale={locale} />04Source
"use client";
import { Combobox } from "@base-ui/react/combobox";
import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, type AnimationPlaybackControls } from "motion/react";
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { cn } from "@/lib/cn";
import { Check, ChevronsUpDown, Globe, Search } from "@/lib/icons";
import { ease, spring, stagger } from "@/lib/motion";
import { useControllableState } from "@/lib/use-controllable-state";
export type LocaleOption = { code: string; currency: string };
/** The languages and regions most products ship, each with the currency its region uses. */
export const COMMON_LOCALES: LocaleOption[] = [
{ code: "en-US", currency: "USD" },
{ code: "en-GB", currency: "GBP" },
{ code: "en-IN", currency: "INR" },
{ code: "es-ES", currency: "EUR" },
{ code: "es-MX", currency: "MXN" },
{ code: "fr-FR", currency: "EUR" },
{ code: "fr-CA", currency: "CAD" },
{ code: "de-DE", currency: "EUR" },
{ code: "de-CH", currency: "CHF" },
{ code: "it-IT", currency: "EUR" },
{ code: "pt-BR", currency: "BRL" },
{ code: "pt-PT", currency: "EUR" },
{ code: "nl-NL", currency: "EUR" },
{ code: "sv-SE", currency: "SEK" },
{ code: "nb-NO", currency: "NOK" },
{ code: "da-DK", currency: "DKK" },
{ code: "fi-FI", currency: "EUR" },
{ code: "pl-PL", currency: "PLN" },
{ code: "cs-CZ", currency: "CZK" },
{ code: "tr-TR", currency: "TRY" },
{ code: "uk-UA", currency: "UAH" },
{ code: "el-GR", currency: "EUR" },
{ code: "he-IL", currency: "ILS" },
{ code: "ar-EG", currency: "EGP" },
{ code: "hi-IN", currency: "INR" },
{ code: "th-TH", currency: "THB" },
{ code: "id-ID", currency: "IDR" },
{ code: "vi-VN", currency: "VND" },
{ code: "ja-JP", currency: "JPY" },
{ code: "ko-KR", currency: "KRW" },
{ code: "zh-CN", currency: "CNY" },
{ code: "zh-TW", currency: "TWD" },
];
type Item = LocaleOption & { native: string; english: string; haystack: string };
type Group = { value: string; label: string; items: Item[] };
const fold = (s: string) => s.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase();
function upperFirst(s: string, locale: string) {
return s ? s.charAt(0).toLocaleUpperCase(locale) + s.slice(1) : s;
}
/** A locale's name in its own language and in the interface's, plus what search should match. */
export function describeLocale(option: LocaleOption, displayLocale = "en"): Item {
let native = option.code;
let english = option.code;
try {
native = upperFirst(new Intl.DisplayNames([option.code], { type: "language", languageDisplay: "standard" }).of(option.code) ?? option.code, option.code);
english = new Intl.DisplayNames([displayLocale], { type: "language", languageDisplay: "standard" }).of(option.code) ?? option.code;
} catch {}
return { ...option, native, english, haystack: fold(`${native} ${english} ${option.code}`) };
}
// The browser's preferred languages, following changes. Null on the server.
const subscribeLanguages = (fn: () => void) => {
window.addEventListener("languagechange", fn);
return () => window.removeEventListener("languagechange", fn);
};
function useDeviceLanguages() {
const joined = useSyncExternalStore(
subscribeLanguages,
() => (navigator.languages?.length ? navigator.languages : [navigator.language]).join(","),
() => null,
);
return useMemo(() => (joined ? joined.split(",") : []), [joined]);
}
/** The best match in `options` for the device: an exact locale first, then the same language. */
function matchDevice(options: LocaleOption[], preferred: string[]) {
for (const p of preferred) {
const exact = options.find((o) => o.code.toLowerCase() === p.toLowerCase());
if (exact) return exact.code;
}
for (const p of preferred) {
const lang = p.split("-")[0].toLowerCase();
const same = options.find((o) => o.code.split("-")[0].toLowerCase() === lang);
if (same) return same.code;
}
return null;
}
// A fixed moment, formatted in UTC, so server and client agree on every sample.
const SAMPLE = new Date(Date.UTC(2026, 8, 22, 14, 30));
/** The four formats people actually notice when they change locale. */
export function formatSamples(option: LocaleOption, date: Date = SAMPLE) {
const f = (o: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat(option.code, { timeZone: "UTC", ...o }).format(date);
return {
date: f({ dateStyle: "long" }),
short: f({ dateStyle: "short" }),
time: f({ timeStyle: "short" }),
number: new Intl.NumberFormat(option.code).format(1234567.89),
currency: new Intl.NumberFormat(option.code, { style: "currency", currency: option.currency }).format(1299.5),
};
}
export type LocaleSelectProps = {
value?: string;
defaultValue?: string;
onValueChange?: (code: string) => void;
/** Which locales to offer. Defaults to COMMON_LOCALES. */
locales?: LocaleOption[];
/** The language the English-side names are written in: your interface's language. */
displayLocale?: string;
/** Hides the live format preview at the bottom of the list. */
hidePreview?: boolean;
disabled?: boolean;
name?: string;
className?: string;
"aria-label"?: string;
"aria-labelledby"?: string;
/** Portal target for the list. */
container?: Combobox.Portal.Props["container"];
};
/**
* Language and region, searchable in any language it lists. Names appear in
* their own language with yours beside them, the current and device locales
* come first, and a footer previews dates and numbers for whichever row is
* highlighted, before anything is committed.
*/
export function LocaleSelect({
value,
defaultValue,
onValueChange,
locales = COMMON_LOCALES,
displayLocale = "en",
hidePreview = false,
disabled = false,
name,
className,
"aria-label": ariaLabel = "Language and region",
"aria-labelledby": labelledBy,
container,
}: LocaleSelectProps) {
const reduce = !!useReducedMotion();
const device = useDeviceLanguages();
const deviceCode = useMemo(() => matchDevice(locales, device), [locales, device]);
const [code, setCode] = useControllableState<string | undefined>({ value, defaultValue, onChange: (c) => c && onValueChange?.(c) });
const current = code ?? deviceCode ?? locales[0]?.code;
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const [highlighted, setHighlighted] = useState<Item | null>(null);
const items = useMemo(() => locales.map((l) => describeLocale(l, displayLocale)), [locales, displayLocale]);
const selected = items.find((i) => i.code === current) ?? null;
const groups = useMemo<Group[]>(() => {
const q = fold(query.trim());
if (q) {
// Prefix matches on a word beat matches in the middle.
const starts = (i: Item) => (` ${i.haystack}`.includes(` ${q}`) ? 0 : 1);
const hits = items.filter((i) => i.haystack.includes(q)).sort((a, b) => starts(a) - starts(b));
return hits.length ? [{ value: "results", label: `${hits.length} ${hits.length === 1 ? "match" : "matches"}`, items: hits }] : [];
}
const top = [current, deviceCode].filter((c, i, a): c is string => !!c && a.indexOf(c) === i);
const suggested = top.map((c) => items.find((i) => i.code === c)).filter((i): i is Item => !!i);
const rest = items.filter((i) => !top.includes(i.code)).sort((a, b) => a.native.localeCompare(b.native, displayLocale));
return [
...(suggested.length ? [{ value: "suggested", label: "Suggested", items: suggested }] : []),
{ value: "all", label: "All languages", items: rest },
];
}, [items, query, current, deviceCode, displayLocale]);
const preview = highlighted ?? selected;
const samples = preview ? formatSamples(preview) : null;
const { setList, y, height, opacity } = useGlide(reduce);
return (
<Combobox.Root<Item>
items={groups}
filteredItems={groups}
filter={null}
value={selected}
onValueChange={(item) => item && setCode(item.code)}
inputValue={query}
onInputValueChange={setQuery}
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) {
setQuery("");
setHighlighted(null);
}
}}
onItemHighlighted={(item) => setHighlighted(item ?? null)}
autoHighlight
isItemEqualToValue={(a, b) => a.code === b.code}
itemToStringLabel={(i) => i.native}
itemToStringValue={(i) => i.code}
disabled={disabled}
name={name}
>
<Combobox.Trigger
aria-label={labelledBy ? undefined : ariaLabel}
aria-labelledby={labelledBy}
className={cn(
"group/trigger relative flex h-8 w-full min-w-0 max-w-[320px] items-center gap-2 rounded-lg border border-line-2 bg-raised pr-2 pl-2.5 text-left text-[13px] text-fg shadow-[var(--shadow)] select-none",
"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
"transition-[background-color,border-color,scale] duration-150 ease-out hover:border-fg-4 hover:bg-hover active:scale-[0.985] active:duration-75",
"data-popup-open:border-fg-4 data-popup-open:bg-hover data-disabled:pointer-events-none data-disabled:opacity-50",
"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:-inset-y-1.5 pointer-coarse:after:content-['']",
className,
)}
>
<Globe size={16} className="shrink-0 text-fg-3" />
<span className="grid min-w-0 flex-1 grid-cols-[minmax(0,1fr)] overflow-hidden py-1">
<AnimatePresence initial={false}>
<motion.span
key={selected?.code ?? "none"}
className="col-start-1 row-start-1 flex min-w-0 items-baseline gap-2"
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8, filter: "blur(2px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8, filter: "blur(2px)" }}
transition={{ duration: reduce ? 0.12 : 0.22, ease: ease.out }}
>
{selected ? (
<>
<span lang={selected.code} dir="auto" className="truncate" suppressHydrationWarning>
{selected.native}
</span>
<span className="shrink-0 font-mono text-2xs text-fg-4">{selected.code}</span>
</>
) : (
<span className="text-fg-4">Choose a language</span>
)}
</motion.span>
</AnimatePresence>
</span>
<ChevronsUpDown size={16} className="shrink-0 text-fg-3 transition-colors duration-150 group-hover/trigger:text-fg-2" />
</Combobox.Trigger>
<Combobox.Portal container={container}>
<Combobox.Positioner align="start" sideOffset={6} collisionPadding={8} className="z-(--z-popover) outline-none">
<Combobox.Popup
aria-label="Choose a language and region"
className={cn(
"flex w-(--anchor-width) max-w-(--available-width) sm:w-[max(var(--anchor-width),21rem)] origin-(--transform-origin) flex-col overflow-hidden rounded-xl border border-line-2 bg-raised text-fg shadow-pop outline-none",
"transition-[opacity,scale] duration-150 ease-out-expo data-ending-style:duration-100",
"data-starting-style:scale-[0.96] data-starting-style:opacity-0 data-ending-style:scale-[0.98] data-ending-style:opacity-0",
"motion-reduce:data-starting-style:scale-100 motion-reduce:data-ending-style:scale-100",
)}
>
<div className="relative flex items-center border-b border-line">
<Search size={14} className="pointer-events-none absolute left-3 text-fg-3" />
<Combobox.Input
placeholder="Search in any language"
aria-label="Search languages"
className="h-10 w-full bg-transparent pr-3 pl-8 text-base text-fg outline-none placeholder:text-fg-4 sm:text-[13px]"
/>
</div>
<Combobox.Empty className="empty:hidden">
<div className="flex flex-col gap-0.5 px-3 py-6 text-center">
<p className="truncate text-[13px] text-fg-2">No languages match “{query.trim()}”</p>
<p className="text-[12px] text-fg-3">Try the name in English, in the language itself, or a code like pt-BR.</p>
</div>
</Combobox.Empty>
<Combobox.List
ref={setList}
className="relative max-h-[min(calc(var(--available-height)-7rem),14rem)] scroll-pt-8 scroll-pb-1 overflow-y-auto overscroll-contain px-1 pb-1 outline-none empty:hidden"
>
<motion.div aria-hidden style={{ y, height, opacity }} className="pointer-events-none absolute inset-x-1 top-0 rounded-lg bg-line" />
{groups.map((group) => (
<Combobox.Group key={group.value} items={group.items} className="block">
<Combobox.GroupLabel className="sticky top-0 z-[2] flex h-8 items-end bg-raised px-2 pb-1.5 font-mono text-2xs tracking-[0.08em] text-fg-3 uppercase select-none">
{group.label}
</Combobox.GroupLabel>
<Combobox.Collection>
{(item: Item) => (
<Combobox.Item
key={`${group.value}:${item.code}`}
value={item}
className="group/item relative z-[1] flex h-8 cursor-default scroll-my-1 items-center gap-2 rounded-lg pr-2 pl-2 text-[13px] outline-none select-none pointer-coarse:h-10"
>
<span className="grid size-4 shrink-0 place-items-center">
<Combobox.ItemIndicator>
<Check size={14} />
</Combobox.ItemIndicator>
</span>
<span className="flex min-w-0 flex-1 items-baseline gap-2">
{/* Named in its own language, so the person who reads it can find it. */}
<span lang={item.code} dir="auto" className="shrink-0 truncate text-fg group-data-selected/item:font-medium">
{item.native}
</span>
{item.english !== item.native && <span className="min-w-0 truncate text-[12px] text-fg-3">{item.english}</span>}
</span>
{item.code === deviceCode && (
<span className="shrink-0 rounded-full border border-line-2 px-1.5 text-[10.5px] leading-4 text-fg-3">Device</span>
)}
</Combobox.Item>
)}
</Combobox.Collection>
</Combobox.Group>
))}
</Combobox.List>
{!hidePreview && samples && preview && (
// Follows the highlighted row, so formats can be compared before choosing. Instant: it moves with the arrow keys.
<div aria-hidden className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-0.5 border-t border-line bg-frame px-3 py-2.5 text-[12px] leading-[18px]">
<span className="text-fg-4">Date</span>
<span lang={preview.code} dir="auto" className="truncate text-left text-fg-2 tabular">
{samples.date} · {samples.time}
</span>
<span className="text-fg-4">Number</span>
<span lang={preview.code} dir="auto" className="truncate text-left text-fg-2 tabular">
{samples.number} · {samples.currency}
</span>
</div>
)}
</Combobox.Popup>
</Combobox.Positioner>
</Combobox.Portal>
</Combobox.Root>
);
}
export type LocalePreviewProps = Omit<React.ComponentProps<"dl">, "children"> & {
/** A locale code from the list, or any BCP 47 tag with a currency. */
locale: string | LocaleOption;
locales?: LocaleOption[];
};
/** How dates, times and numbers will look in a locale. Values roll over when it changes. */
export function LocalePreview({ locale, locales = COMMON_LOCALES, className, ...rest }: LocalePreviewProps) {
const reduce = !!useReducedMotion();
const option = typeof locale === "string" ? (locales.find((l) => l.code === locale) ?? { code: locale, currency: "USD" }) : locale;
const s = formatSamples(option);
const rows: [string, string][] = [
["Date", s.date],
["Short date", s.short],
["Time", s.time],
["Number", s.number],
["Currency", s.currency],
];
return (
<dl className={cn("grid grid-cols-[auto_minmax(0,1fr)] gap-x-6 gap-y-1.5 text-[12.5px] leading-[18px]", className)} {...rest}>
{rows.map(([label, text], i) => (
<div key={label} className="contents">
<dt className="text-fg-3">{label}</dt>
<dd className="grid min-w-0 grid-cols-[minmax(0,1fr)] overflow-hidden text-fg tabular">
<AnimatePresence initial={false}>
<motion.span
key={`${option.code}:${text}`}
lang={option.code}
dir="auto"
suppressHydrationWarning
className="col-start-1 row-start-1 truncate text-left"
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: "blur(2px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: 0.22, ease: ease.out, delay: reduce ? 0 : i * stagger.items } }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: "blur(2px)", transition: { duration: 0.14, ease: ease.out } }}
>
{text}
</motion.span>
</AnimatePresence>
</dd>
</div>
))}
</dl>
);
}
// One highlight for the whole list: it glides after the pointer on a spring and
// jumps for arrow keys, where motion would read as lag.
function useGlide(reduce: boolean) {
const [list, setList] = useState<HTMLDivElement | null>(null);
const y = useMotionValue(0);
const height = useMotionValue(32);
const opacity = useMotionValue(0);
useEffect(() => {
if (!list) return;
let keyboard = false;
let shown = false;
let running: AnimationPlaybackControls[] = [];
const stop = () => {
running.forEach((c) => c.stop());
running = [];
};
const place = () => {
const el = list.querySelector<HTMLElement>("[data-highlighted]");
stop();
if (!el) {
shown = false;
running.push(animate(opacity, 0, { duration: reduce ? 0 : 0.12 }));
return;
}
// offsetTop ignores the popup's entrance scale, which would skew a measured rect.
let top = 0;
for (let n: HTMLElement | null = el; n && n !== list; n = n.offsetParent as HTMLElement | null) top += n.offsetTop;
if (!shown || keyboard || reduce) {
y.jump(top);
height.jump(el.offsetHeight);
opacity.jump(1);
} else {
running.push(animate(y, top, spring.follow), animate(height, el.offsetHeight, spring.follow));
opacity.jump(1);
}
shown = true;
};
const onKey = () => (keyboard = true);
const onPointer = () => (keyboard = false);
const observer = new MutationObserver(place);
observer.observe(list, { subtree: true, childList: true, attributes: true, attributeFilter: ["data-highlighted"] });
document.addEventListener("keydown", onKey, true);
list.addEventListener("pointermove", onPointer);
place();
return () => {
observer.disconnect();
document.removeEventListener("keydown", onKey, true);
list.removeEventListener("pointermove", onPointer);
stop();
};
}, [list, reduce, y, height, opacity]);
return { setList, y, height, opacity };
}05Props
LocaleSelect
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | — | Controlled BCP 47 code, like pt-BR. |
| defaultValue | string | — | Uncontrolled initial code. Falls back to the device's best match. |
| onValueChange | (code: string) => void | — | Called with the chosen code. |
| locales | { code: string; currency: string }[] | COMMON_LOCALES | What to offer. The currency drives the currency sample. |
| displayLocale | string | "en" | Language of the secondary names: your interface's. |
| hidePreview | boolean | false | Hides the format preview in the list's footer. |
| disabled | boolean | false | Ignores input. |
| name | string | — | Submits the code with a form. |
| aria-labelledby | string | — | Id of a visible label. Otherwise aria-label, default “Language and region”. |
| container | HTMLElement | ref | — | Portal target for the list. |
LocalePreview
| Prop | Type | Default | Description |
|---|---|---|---|
| locale* | string | { code: string; currency: string } | — | The locale to show. Values roll over when it changes. |
| locales | LocaleOption[] | COMMON_LOCALES | Where a code's currency is looked up. |
describeLocale / formatSamples
| Prop | Type | Default | Description |
|---|---|---|---|
| describeLocale | (option, displayLocale?) => { native, english, … } | — | A locale's name in its own language and in yours. |
| formatSamples | (option, date?) => { date, short, time, number, currency } | — | The formatted samples, for your own preview. |
06Notes
Behavior
- Every locale is named in its own language, with your language beside it, so the person who reads it can always find it. Names come from Intl, not a hand-kept table.
- Search ignores accents and matches native names, English names and codes; word-start matches rank first. The current and device locales lead the list under Suggested.
- The footer previews dates and numbers for the highlighted row, so formats can be compared with the arrow keys before anything is committed.
- Samples use a fixed moment in UTC, so the server and browser render the same text. Right-to-left names are bidi-isolated and stay left-aligned in the column.
Motion
- The popup grows from the trigger, 150ms on the expo ease-out, and fades out in 100ms. One highlight glides between rows on the follow spring and jumps for arrow keys.
- The trigger's label slides 8px in the direction of change with a 2px blur, 220ms. LocalePreview's values rise 6px and settle, 20ms apart.
- The footer preview swaps instantly because it moves with the arrow keys. Reduced motion keeps only short fades.
Accessibility
- A Base UI combobox with the search inside the popup: arrows move, Enter chooses, Escape closes and returns focus to the trigger.
- Each native name carries its lang attribute, so screen readers pronounce it in its own language.
- The footer preview is hidden from assistive tech; LocalePreview is a real description list.