Language Picker
A language switcher listing each language in its own script with its English name, searchable in either.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/language-picker.json'use client'
import { useEffect, useId, useRef, useState } from 'react'
import './language-picker.css'
export interface Language { code: string; native: string; english: string }
export interface LanguagePickerProps {
languages: Language[]
defaultValue?: string
onValueChange?: (code: string) => void
triggerShows?: 'code' | 'name'
fill?: 'dark' | 'light'
className?: string
}
/** Searchable language switcher with native names. */
export function LanguagePicker({ languages, defaultValue = 'en', onValueChange, triggerShows = 'code', fill = 'dark', className }: LanguagePickerProps) {
const [v, setV] = useState(defaultValue)
const [open, setOpen] = useState(false)
const [q, setQ] = useState('')
const [act, setAct] = useState(0)
const [sugg, setSugg] = useState<string[]>([])
const trig = useRef<HTMLButtonElement>(null)
const id = useId()
useEffect(() => { setSugg((navigator.languages ?? []).map((l) => l.split('-')[0]).filter((c, i, a) => a.indexOf(c) === i)) }, [])
const ql = q.toLowerCase()
const match = languages.filter((l) => !q || l.native.toLowerCase().includes(ql) || l.english.toLowerCase().includes(ql) || l.code === ql)
const top = q ? [] : match.filter((l) => sugg.includes(l.code)).slice(0, 2)
const rest = match.filter((l) => !top.includes(l))
const flat = [...top, ...rest]
const cur = languages.find((l) => l.code === v)!
const pick = (l: Language) => { setV(l.code); onValueChange?.(l.code); setOpen(false); setQ(''); trig.current?.focus() }
const opt = (l: Language) => {
const i = flat.indexOf(l)
return (
<div key={l.code} id={`${id}-${i}`} role="option" aria-selected={l.code === v} className="sd-lng__o" data-active={i === act || undefined} onMouseEnter={() => setAct(i)} onMouseDown={(e) => { e.preventDefault(); pick(l) }}>
<span lang={l.code}>{l.native}</span><span>{l.english}</span>
{l.code === v && <svg viewBox="0 0 14 14" aria-hidden><path d="m3 7.5 2.5 2.5L11 4" /></svg>}
</div>
)
}
return (
<div className={['sd-lng', className].filter(Boolean).join(' ')} data-fill={fill} onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setOpen(false) }}>
<button ref={trig} type="button" className="sd-lng__trig" aria-haspopup="listbox" aria-expanded={open} aria-label={`Language: ${cur.english}`} onClick={() => setOpen(!open)}>
<svg viewBox="0 0 16 16" aria-hidden><circle cx="8" cy="8" r="6" /><path d="M2 8h12M8 2c2 2 2 10 0 12M8 2c-2 2-2 10 0 12" /></svg>
<span lang={cur.code}>{triggerShows === 'code' ? cur.code.toUpperCase() : cur.native}</span>
</button>
{open && (
<div className="sd-lng__pop">
<input className="sd-lng__q" autoFocus role="combobox" aria-expanded="true" aria-controls={`${id}-l`} aria-activedescendant={flat[act] ? `${id}-${act}` : undefined} aria-label="Search languages" placeholder="Search languages" value={q}
onChange={(e) => { setQ(e.target.value); setAct(0) }}
onKeyDown={(e) => {
if (e.key === 'ArrowDown') (e.preventDefault(), setAct((a) => Math.min(flat.length - 1, a + 1)))
if (e.key === 'ArrowUp') (e.preventDefault(), setAct((a) => Math.max(0, a - 1)))
if (e.key === 'Enter' && flat[act]) (e.preventDefault(), pick(flat[act]))
if (e.key === 'Escape') { setOpen(false); trig.current?.focus() }
}} />
<div className="sd-lng__list" role="listbox" id={`${id}-l`} aria-label="Languages">
{top.length > 0 && <><div className="sd-lng__g" aria-hidden>Suggested</div>{top.map(opt)}<div className="sd-lng__g" aria-hidden>All languages</div></>}
{rest.map(opt)}
</div>
</div>
)}
</div>
)
}.sd-lng { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-lng[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-lng button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-lng :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-lng .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-lng { position: relative; display: inline-block; align-self: start; margin-top: 30px; }
.sd-lng__trig { display: inline-flex; align-items: center; gap: 7px; height: 36px; padding: 0 12px; border-radius: 10px; box-shadow: inset 0 0 0 1px var(--line); font-size: 13px; font-weight: 600; background: var(--bg) !important; }
.sd-lng__trig svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 1.4; }
.sd-lng__pop { position: absolute; top: 44px; left: 0; width: 250px; padding: 5px; border-radius: 13px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line), 0 18px 40px -16px rgb(0 0 0 / 0.55); z-index: 4; animation: sd-lng-in 150ms ease; }
@keyframes sd-lng-in { from { opacity: 0; transform: translateY(-4px); } }
.sd-lng__q { width: 100%; height: 34px; padding: 0 9px; margin-bottom: 4px; border: 0; border-radius: 8px; outline: 0; background: var(--soft); color: var(--fg); font: inherit; font-size: 13px; }
.sd-lng__list { max-height: 220px; overflow: auto; }
.sd-lng__g { padding: 6px 9px 3px; font-size: 10.5px; color: var(--mute); text-transform: uppercase; letter-spacing: 0.07em; }
.sd-lng__o { display: flex; align-items: center; gap: 8px; height: 36px; padding: 0 9px; border-radius: 8px; font-size: 13.5px; cursor: pointer; }
.sd-lng__o[data-active] { background: var(--soft); }
.sd-lng__o span:nth-child(2) { color: var(--mute); font-size: 12px; }
.sd-lng__o svg { margin-left: auto; width: 13px; height: 13px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
@media (prefers-reduced-motion: reduce) { .sd-lng *, .sd-lng *::before, .sd-lng *::after { animation: none !important; transition: none !important; } }Overview
Each option shows the native name (日本語, Español) with the English name beside it, and search matches both, so people can find their language without reading yours.
The trigger shows a short code; suggested languages from the browser are pinned at the top.
When to use
- Site headers, footers, settings.
When not to use
- Region pickers (currency, shipping): those are different settings.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/language-picker.json$
npx shadcn@latest add https://sundaeui.dev/r/language-picker.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/language-picker.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/language-picker.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy language-picker.tsx and language-picker.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { LanguagePicker } from '@/components/sundae/language-picker'
<LanguagePicker />Props
| Prop | Type | Default |
|---|---|---|
languages*Options. | { code: string; native: string; english: string }[] | — |
value / defaultValueCurrent code. | string | — |
onValueChangeCalled on change. | (code: string) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- A listbox with each option’s lang attribute set so screen readers pronounce native names correctly.
- Search field labelled; Escape closes.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-lng; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- ↑ / ↓
- Move
- Enter
- Choose
- Esc
- Close
FAQ
- Is Language Picker free?
- Yes. Language Picker is free for personal and commercial projects, with full source on this page.
- How do I install Language Picker?
- Run npx shadcn@latest add https://sundaeui.dev/r/language-picker.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Language Picker support keyboard and screen readers?
- A listbox with each option’s lang attribute set so screen readers pronounce native names correctly. Search field labelled; Escape closes.
- Does Language Picker respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →Regional Format
Forms · RefinedRadio Cards
Forms · RefinedMulti Select
Forms · RefinedTransfer List
Forms · RefinedBranch Picker
Forms · RefinedOTP Field
Forms · Refined