Inputs

Language Picker

An interface-language field that lists every option under its own name — 简体中文 next to Chinese (Simplified) — with endonym, English name and BCP 47 tag all searchable, RTL rows rendered in their own direction, optional translation completeness and a browser-language matching ladder.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { createPortal } from "react-dom"
import { Check, ChevronsUpDown, Languages, Search, Wand2 } from "lucide-react"
import { cn } from "@/lib/utils"

/* ------------------------------------------------------------------ *
 * Language table
 * ------------------------------------------------------------------ */

export interface LanguageOption {
  /** BCP 47 tag — this is the component's value, e.g. "zh-Hans", "pt-BR". */
  code: string

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/language-picker.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "LanguagePicker" component (React 19 +
react-dom + lucide-react only; no i18n library, no positioning library, no
Radix — the language table is data and the panel is hand-placed).

Contract
- export const LanguagePicker = React.forwardRef<HTMLDivElement,
  LanguagePickerProps>, where LanguagePickerProps extends
  Omit<React.HTMLAttributes<HTMLDivElement>, "onChange" | "defaultValue">:
  - value? / defaultValue? : string — a BCP 47 tag ("zh-Hans", "pt-BR").
    Controlled when `value` is passed, uncontrolled otherwise.
  - onValueChange?: (code: string) => void — fires on every commit: row click,
    Enter on the highlighted row, and a successful "Detect my language".
  - languages?: LanguageOption[] (defaults to the exported LANGUAGES table)
  - showProgress?: boolean (default false)
  - placeholder? / searchPlaceholder? / emptyText? / disabled? /
    detectable? (default true — shows the footer detect button)
  - className merged onto the root through cn(); remaining props spread there.
- export interface LanguageOption { code (BCP 47 tag — this is the value),
  name (English name), endonym (the language's own name for itself),
  region?: string (English, e.g. "Brazil"), dir?: "ltr" | "rtl",
  aliases?: string[] (search-only), progress?: number (0–1) }.
- export const LANGUAGES: LanguageOption[] — ~49 entries: script subtags where
  script is what differs (zh-Hans / zh-Hant), region subtags where region is
  what differs (pt-BR / pt-PT, es-419, en-GB, fr-CA), bare tags elsewhere.
- export function matchLanguage(requested: readonly string[],
  languages: readonly LanguageOption[]): string | null — the negotiation ladder
  below, exported because a server rendering Accept-Language needs the same
  answer the button gives.

Behavior — why the endonym is the primary label
- Every row shows the endonym first and the English name (plus region) under
  it, and BOTH are searchable. Someone who has landed in a language they cannot
  read will never find "Chinese (Simplified)" — they will find 简体中文. The
  English line stays because the person configuring a locale for somebody else
  reads it instead.
- NO flag emoji. A language is not a country: Spanish is not Spain's, English
  is not the UK's, Arabic has twenty-odd candidate flags and Hindi none that is
  not also six other languages'. Flags in a language list are a well-known
  localization anti-pattern; where a region genuinely matters, spell it out as
  text (`region`, and the tag itself in the right-hand column).
- No Intl.DisplayNames fallback for unknown tags either: its output depends on
  the runtime's ICU data (Node and the browser disagree, which breaks
  hydration), and in an English UI it returns the English name — the exact
  thing the endonym exists to replace. An unrecognised tag is synthesized into
  a row that prints the tag verbatim with "Not in the language list" under it,
  so the field always has something checked and something to show.

Behavior — right-to-left rows
- Each endonym renders inside <bdi dir={row.dir} lang={row.code}>. `dir` is
  data, not a guess: dir="auto" would infer from the first strong character and
  get an endonym that starts with a Latin brand name wrong.
- This matters twice. (1) Bidi isolation: an RTL run sitting next to Latin text
  reorders the neutral characters between them. (2) Truncation side: in a
  ~112px box, "العربية (المملكة العربية السعودية)" with dir="rtl" keeps the
  start of the name and clips the tail; with the surrounding LTR direction the
  browser instead drops the FIRST 13 characters — the word العربية itself — and
  shows you the end of the string. Measured, not theorised.
- Add `text-left` alongside the dir: `dir` also flips the box's alignment, and
  in an LTR panel a right-aligned endonym breaks the column. Direction should
  govern bidi ordering and the ellipsis end; alignment should keep following
  the host UI. (In an RTL host UI, swap that one class.)
- Also set lang on the same element so a screen reader switches voice, and set
  dir="auto" on the SEARCH input — that is the one field whose text comes from
  the user, so the browser should pick its direction from what they type.

Behavior — search
- Each row carries a folded haystack: endonym + English name + region + the tag
  (raw and with "-" replaced by a space) + aliases. Folding = NFD normalize,
  strip combining marks, lowercase — so "espanol" finds Español, "turkce" finds
  Türkçe and "portugues" finds both Portuguese rows. Fold BOTH sides of the
  comparison; that is what makes it safe for scripts the folder does not
  understand, because Hangul and Arabic decompose identically on both sides.
- Split the query on whitespace; every token must appear somewhere in the
  haystack. That makes "简", "Deutsch", "pt-BR", "zh-cn", "latam" and
  "chinese simplified" all work against the same index.
- Sort alphabetically by English name, tie-broken by tag, with an explicit
  locale ("en") — the ambient one sorts differently on the server.
- The current value always gets a row (see the synthesized tag above), and the
  incoming list is de-duplicated by tag: a repeat means two React keys and two
  role="option" nodes with the same name.

Behavior — completeness (showProgress)
- `progress` is a 0–1 fraction. Non-finite values are treated as "not tracked",
  not as 0; a row with no progress renders nothing rather than a misleading 0%.
- Pin BOTH ends of the percentage so it cannot lie: only a literal 1 may print
  "100%" (0.996 prints 99%, because a catalogue that is 99.6% done still has
  broken screens), and anything above 0 prints at least "1%" (0.004 prints 1%,
  because three translated strings is not "nothing"). Exactly 0 gets an
  "Untranslated" badge instead of a number.
- The percentage carries an sr-only " translated" so the row's accessible name
  reads "简体中文 Chinese (Simplified) zh-Hans 83% translated"; the little bar
  beside it is aria-hidden decoration.

Behavior — detecting the browser's language
- Read navigator.languages (falling back to navigator.language) INSIDE the
  click handler, never during render: the server has no navigator and the two
  would disagree.
- matchLanguage walks each requested tag through a ladder before moving to the
  next one: exact tag → region rewrite → RFC 4647 lookup truncation (dropping
  a single-character subtag together with the segment it introduces) → widening
  to the first entry sharing the primary subtag.
- The region rewrite is a small data table, because truncation alone gives the
  wrong answer: zh-CN truncates to "zh", which says nothing about script, so
  {zh: {cn: hans, tw: hant, …}} rewrites it to zh-Hans first. Same for
  {es: {mx: "419", …}} and for {en: {au: "gb", …}} — that last one is an
  opinion expressed as data (an Australian is better served by en-GB than
  en-US, which is also what CLDR's parent-locale chain says); delete the row
  and en-AU truncates to en instead.
- Exhausting one tag before the next means a coarse hit on someone's first
  choice beats an exact hit on their fifth: pt-AO resolves to pt-BR rather than
  to the "en" their browser lists third.
- When nothing matches, do not silently commit anything — print the tags that
  were seen ("Your browser asks for is-IS — none of those is available here"),
  which is the difference between "it did not work" and "ship a pt-PT file".

Behavior — the floating panel
- The panel is rendered through createPortal into document.body with
  position: fixed. An absolutely positioned panel inside an overflow-hidden
  card is drawn INSIDE the clip: present in the DOM, invisible and unclickable.
- Boundary = the window intersected with every ancestor whose computed overflow
  is auto|scroll. Merely overflow:hidden ancestors are NOT boundaries — the
  portal already escaped them, and clamping into a decorative 144px card would
  trade an invisible panel for a crushed one.
- One synchronous pass: width → natural height → flip → cap → shift.
  Width (max(trigger, 280px), clamped) is written to the DOM before the height
  is read, because a fixed panel with no width shrink-wraps its widest row.
  Natural height is measured with the inline max-height momentarily set to
  "none" (and the list's scrollTop saved and restored, or every reposition
  yanks the user's scrolled list back to the top). Flip above only when below
  cannot hold it AND above is roomier.
- SHIFT back inside that boundary on BOTH axes, not just the cross axis. The
  panel is `position: fixed`, so anything hanging past the boundary can never be
  scrolled to. 180px is the *preferred* height on a short side — the panel keeps
  it and the list scrolls — but the cap yields to a boundary too small to hold
  that much, because a squeezed panel is usable and an escaped one is not.
  Measured on the shipped build with a cap but no main-axis shift: a trigger
  parked at the top of a 60px scrollable box left 168 of the panel's 180px
  off-screen, `elementFromPoint` on its centre returned the page behind it, and
  neither the search field nor a single option could be clicked; the same
  trigger near the bottom of a 120px box put the panel 121px above the box, over
  unrelated content. With the shift both sit fully inside, 0px outside the
  boundary, search field hit-testable. The honest cost: under ~180px of boundary
  the list is squeezed below one row, so type-and-Enter still commits but
  pointer-picking a row needs more room.
- Measure in a ResizeObserver callback observing the panel and the root — RO
  fires once right after observe(), after layout and before paint, so that
  first callback IS the initial measurement and no setState happens
  synchronously in an effect body. Also reposition on capture-phase window
  scroll (passive, rAF-throttled, ignoring scrolls from inside the panel) and
  on resize; disconnect everything on close. Guard the state write with a
  field-by-field equality check, or applying the max-height re-fires the
  observer forever.
- Until coordinates exist the panel carries opacity-0 (NOT visibility:hidden —
  a hidden subtree cannot take focus, and the search field is focused the
  moment the panel mounts).
- Dismissal: document pointerdown outside (not click, so the panel is gone
  before the press lands underneath) and focusin outside, since Tab out of a
  portalled panel lands somewhere unrelated in DOM order. Escape and a commit
  return focus to the trigger after an isConnected check, because committing a
  language routinely re-renders the surrounding form and focusing a detached
  node silently drops focus onto <body>.
- The focusin guard is NOT enough on its own: the panel is appended to
  document.body, so its last tab stop is usually the last tabbable element in
  the document — and stepping off THAT fires no focusin at all, because focus
  leaves the document instead of moving inside it. Measured on the shipped build
  without the guard below: one Tab past "Detect my language" left
  `document.activeElement === document.body` with the listbox still open, and
  the next Tab jumped to the page header. So handle Tab on the panel: when focus
  sits on the first stop (Shift+Tab) or the last one (Tab), preventDefault,
  close and hand focus back to the trigger — one more Tab then continues from
  the combobox in document order.
- Keyboard: trigger opens on click, Enter/Space (it is a real button) and
  Arrow keys. Inside, the search field owns ↑ ↓ Home End, Enter (commit) and
  Escape (close + return focus, with stopPropagation so a picker inside a
  dialog closes only itself). The highlight is DERIVED: an explicit index is
  honoured only while it points at a live row, otherwise it falls back to the
  checked row (empty query) or the first hit (while typing), so filtering never
  has to repair state.

Rendering & styling
- Semantic tokens only: bg-popover / text-popover-foreground / border /
  border-input / bg-accent / text-accent-foreground / text-muted-foreground /
  bg-muted / bg-primary / ring-ring / text-destructive. No hardcoded colors.
- Trigger: h-10 w-full rounded-md border, a Languages glyph, the endonym in
  medium weight with the English name muted beside it, and the tag (over the
  completeness, when shown) right-aligned in tabular-nums.
- Panel: rounded-md border bg-popover shadow-md, a search row with its own
  border-b, a max-h-64 min-h-0 flex-1 overflow-auto list, optional footer
  button. tabIndex={-1} on the list keeps Chromium from making the scroll
  container its own tab stop.
- a11y: trigger is a <button role="combobox"> with aria-haspopup="listbox",
  aria-expanded and aria-controls (only while open, so the id always resolves).
  The input carries aria-autocomplete="list", aria-controls and
  aria-activedescendant. The list is role="listbox", rows are role="option"
  with aria-selected, and the empty-state <li> is role="presentation" — a bare
  <li> between the listbox and its options breaks the ownership chain and
  screen readers announce an empty list. The check mark is aria-hidden.
- Only colour transitions animate, so prefers-reduced-motion has nothing to
  disable beyond motion-reduce:transition-none; the panel appears instantly.
- "use client" is required: state, effects, portal, DOM measurement.

Customization levers
- The table: pass `languages` to replace it (your shipped locales, a per-tenant
  allow-list), or filter the exported one:
  LANGUAGES.filter(l => SHIPPED.includes(l.code)). `aliases` is invisible search
  fodder — add romanisations, legacy tags ("iw", "in") or your own team's slang.
- Row density: rows are two-line (endonym over English name). Drop the second
  line for a compact list, or drop the tag column if your users never think in
  tags. Raise max-h-64 for a taller panel; MIN_PANEL_WIDTH (280) and
  MIN_PANEL_HEIGHT (180) are *preferred* floors — the panel keeps them on a
  short side and scrolls instead of shrinking, but the height floor yields to a
  scrollable boundary too small to hold it rather than hanging outside it.
- Sorting: swap the comparator for a.endonym.localeCompare(b.endonym) to sort by
  native name, or keep the incoming order to let the consumer decide.
- Completeness: showProgress switches the column on; change the two pinning
  rules in progressPercent if your project would rather round honestly than
  never print 100%. Recolour the bar with any token — it is bg-primary on
  bg-muted, not a chart colour.
- Detection: detectable={false} hides the footer button; matchLanguage is
  exported, so the same ladder can run on the server against Accept-Language
  and pre-select a value. Extend SUBTAG_REWRITES for your own opinions
  (sr: { rs: "cyrl", me: "latn" }).
- Labels: LIST_LABEL / UNTRANSLATED_LABEL / UNKNOWN_TAG_LABEL are module
  constants at the top of the file — this component's own chrome is the one
  thing that must already be in the user's language, so it belongs in your
  message catalogue, not in props.

Concepts

  • Endonym-first labelling — the row's primary label is the language's own name for itself (简体中文, Deutsch, العربية), with the English name demoted to the second line. The person most likely to open this control is the one who cannot read the current interface, and "Chinese (Simplified)" is invisible to them. Both lines stay searchable so the admin configuring a colleague's locale is served too.
  • Flags are not languages — the emoji flag is the classic wrong affordance here. Spanish is not Spain's, English is not the UK's, Arabic spans twenty-odd states and Hindi's flag is also six other languages'. Where region genuinely disambiguates (pt-BR vs pt-PT, es vs es-419) this component spells it out in text and shows the tag.
  • Bidi isolation, per row — each endonym renders in a <bdi> carrying that row's own dir and lang. Without it an RTL name next to Latin text reorders the neutral characters between them, and — measured in a 112px column — truncation drops the first 13 characters of العربية (المملكة العربية السعودية), throwing away the word العربية itself. dir is stored data, not dir="auto", because an endonym that opens with a Latin brand name would guess wrong.
  • Direction ≠ alignment — setting dir on a block also flips its text alignment, which right-aligns one row inside an otherwise left-aligned LTR panel. The fix is one class: keep the direction (it governs bidi order and which end the ellipsis eats) and pin the alignment back to the host UI's.
  • Diacritic-folded matching — the haystack and the query are both NFD-normalized, stripped of combining marks and lowercased, so espanol finds Español and turkce finds Türkçe. Nobody switches keyboard layout to search for the keyboard layout they want. Folding both sides is also what makes it safe for scripts the folder does not model: Hangul and Arabic decompose identically on each side, so substring matching still lines up.
  • The lookup ladder is data, not magic — exact tag, then a region→script rewrite table, then RFC 4647 truncation, then widening to any variant of the same language. Truncation alone gets zh-CN wrong (it becomes zh, which says nothing about script), so the rewrite runs first. Each requested tag is exhausted before the next, on purpose: a coarse hit on someone's first choice beats an exact hit on their fifth.
  • A percentage that cannot lie — only a literal 1 prints "100%", so 0.996 prints 99%; anything above zero prints at least 1%, so 0.004 does not round down into a lie; exactly 0 is a badge, not a number; and an absent value renders nothing at all rather than an accusatory 0%.
  • Portal before geometry — the cure for "the panel is in the DOM but I cannot click it" is not smarter math, it is not being a descendant of the thing that clips. overflow: hidden ancestors are decoration and are escaped; overflow: auto | scroll ancestors are real viewports and do clamp the panel — flipped, capped and then shifted back inside on both axes, because a position: fixed panel that hangs past its boundary can never be scrolled to (measured before the main-axis shift existed: 168 of 180px off-screen inside a 60px scrollable box, with zero clickable rows). The 180px height floor is a preference, not a guarantee: it yields to a boundary too small to hold it, since a squeezed panel is usable and an escaped one is not.

On This Page