# Language Switcher

> A compact language selector with a listbox dropdown, an inline segmented variant and typeahead.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/language-switcher
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/language-switcher.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/language-switcher-demo.tsx"
"use client"

import * as React from "react"

import { LanguageSwitcher, defaultLanguages } from "@/components/language-switcher"

export default function LanguageSwitcherDemo() {
  const [language, setLanguage] = React.useState("es")
  const current = defaultLanguages.find((item) => item.code === language)

  return (
    <div className="grid w-full max-w-sm grid-cols-[minmax(0,1fr)] gap-8">
      <section className="grid min-w-0 gap-3">
        <p className="text-sm font-medium text-muted-foreground">Segmented</p>
        <LanguageSwitcher variant="segmented" value={language} onValueChange={setLanguage} />
      </section>
      <section className="grid min-h-80 min-w-0 content-start gap-3">
        <p className="text-sm font-medium text-muted-foreground">Dropdown</p>
        <LanguageSwitcher defaultOpen value={language} onValueChange={setLanguage} />
      </section>
      <p className="text-center text-xs text-muted-foreground">
        Interface language: <span className="font-medium text-foreground">{current?.native}</span> ({current?.code})
      </p>
    </div>
  )
}
```

## Source

```tsx title="components/language-switcher.tsx"
"use client"

import * as React from "react"
import { Check, ChevronDown, Globe } from "lucide-react"

import { cn } from "@/lib/utils"

export interface Language {
  /** BCP 47 code, e.g. "en" or "zh-Hans" */
  code: string
  /** Short label for the segmented variant, e.g. "EN" */
  short: string
  /** Name in the language itself */
  native: string
  /** Name in English */
  name: string
}

export const defaultLanguages: Language[] = [
  { code: "en", short: "EN", native: "English", name: "English" },
  { code: "es", short: "ES", native: "Español", name: "Spanish" },
  { code: "fr", short: "FR", native: "Français", name: "French" },
  { code: "de", short: "DE", native: "Deutsch", name: "German" },
  { code: "ja", short: "JA", native: "日本語", name: "Japanese" },
  { code: "zh-Hans", short: "ZH", native: "简体中文", name: "Chinese" },
]

export interface LanguageSwitcherProps extends Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> {
  languages?: Language[]
  /** Selected language code (controlled) */
  value?: string
  defaultValue?: string
  onValueChange?: (code: string) => void
  variant?: "dropdown" | "segmented"
  align?: "start" | "end"
  /** Render the listbox open on mount (dropdown variant only, without moving focus) */
  defaultOpen?: boolean
}

export function LanguageSwitcher({
  languages = defaultLanguages,
  value,
  defaultValue,
  onValueChange,
  variant = "dropdown",
  align = "start",
  defaultOpen = false,
  className,
  ...props
}: LanguageSwitcherProps) {
  const [inner, setInner] = React.useState(defaultValue ?? languages[0]?.code ?? "")
  const [open, setOpen] = React.useState<"closed" | "focus" | "quiet">(defaultOpen ? "quiet" : "closed")
  const [active, setActive] = React.useState(0)
  const root = React.useRef<HTMLDivElement>(null)
  const button = React.useRef<HTMLButtonElement>(null)
  const list = React.useRef<HTMLDivElement>(null)
  const typed = React.useRef({ text: "", at: 0 })
  const id = React.useId()
  const current = value ?? inner
  const selectedIndex = Math.max(
    0,
    languages.findIndex((language) => language.code === current)
  )
  const selected = languages[selectedIndex]
  const isOpen = variant === "dropdown" && open !== "closed"

  const select = (code: string) => {
    if (value === undefined) setInner(code)
    onValueChange?.(code)
  }

  React.useEffect(() => {
    if (open === "focus") list.current?.focus()
  }, [open])

  React.useEffect(() => {
    if (!isOpen) return
    // Keep the active option visible inside the list without scrolling the page
    const option = document.getElementById(`${id}-option-${active}`)
    const box = list.current
    if (!option || !box) return
    if (option.offsetTop < box.scrollTop) box.scrollTop = option.offsetTop
    else if (option.offsetTop + option.offsetHeight > box.scrollTop + box.clientHeight)
      box.scrollTop = option.offsetTop + option.offsetHeight - box.clientHeight
  }, [isOpen, active, id])

  React.useEffect(() => {
    if (!isOpen) return
    const onPointerDown = (event: PointerEvent) => {
      if (!root.current?.contains(event.target as Node)) setOpen("closed")
    }
    const onDocumentKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape" && !root.current?.contains(document.activeElement)) setOpen("closed")
    }
    document.addEventListener("pointerdown", onPointerDown)
    document.addEventListener("keydown", onDocumentKeyDown)
    return () => {
      document.removeEventListener("pointerdown", onPointerDown)
      document.removeEventListener("keydown", onDocumentKeyDown)
    }
  }, [isOpen])

  const openList = (at: number) => {
    setActive(at)
    setOpen("focus")
  }

  const close = () => {
    setOpen("closed")
    button.current?.focus()
  }

  const commit = (index: number) => {
    const language = languages[index]
    if (language) select(language.code)
    close()
  }

  const onListKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    const last = languages.length - 1
    switch (event.key) {
      case "ArrowDown":
        setActive((i) => Math.min(i + 1, last))
        break
      case "ArrowUp":
        setActive((i) => Math.max(i - 1, 0))
        break
      case "Home":
        setActive(0)
        break
      case "End":
        setActive(last)
        break
      case "Enter":
      case " ":
        commit(active)
        break
      case "Escape":
        close()
        break
      case "Tab":
        setOpen("closed")
        return
      default: {
        if (event.key.length !== 1 || !/\S/.test(event.key) || event.metaKey || event.ctrlKey || event.altKey) return
        // Typeahead on the native or English name
        const now = event.timeStamp
        typed.current = { text: (now - typed.current.at < 600 ? typed.current.text : "") + event.key.toLowerCase(), at: now }
        const match = languages.findIndex(
          (language) =>
            language.native.toLowerCase().startsWith(typed.current.text) || language.name.toLowerCase().startsWith(typed.current.text)
        )
        if (match >= 0) setActive(match)
        return
      }
    }
    event.preventDefault()
  }

  if (variant === "segmented") {
    const onSegmentKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>, index: number) => {
      const last = languages.length - 1
      let to: number
      if (event.key === "ArrowRight" || event.key === "ArrowDown") to = index === last ? 0 : index + 1
      else if (event.key === "ArrowLeft" || event.key === "ArrowUp") to = index === 0 ? last : index - 1
      else if (event.key === "Home") to = 0
      else if (event.key === "End") to = last
      else return
      event.preventDefault()
      const buttons = event.currentTarget.parentElement?.querySelectorAll<HTMLButtonElement>('[role="radio"]')
      buttons?.[to]?.focus()
      select(languages[to].code)
    }
    return (
      <div
        role="radiogroup"
        aria-label="Language"
        className={cn("relative inline-grid w-fit max-w-full rounded-xl bg-muted p-1", className)}
        style={{ gridTemplateColumns: `repeat(${languages.length}, minmax(2.5rem, 1fr))` }}
        {...props}
      >
        <span
          aria-hidden
          className="pointer-events-none absolute top-1 bottom-1 left-1 rounded-lg bg-background shadow-sm ring-1 ring-border transition-transform duration-300 ease-out motion-reduce:transition-none"
          style={{
            width: `calc((100% - 0.5rem) / ${languages.length})`,
            transform: `translateX(${selectedIndex * 100}%)`,
          }}
        />
        {languages.map((language, index) => {
          const checked = index === selectedIndex
          return (
            <button
              key={language.code}
              type="button"
              role="radio"
              aria-checked={checked}
              aria-label={language.name === language.native ? language.name : `${language.native} (${language.name})`}
              title={language.native}
              lang={language.code}
              tabIndex={checked ? 0 : -1}
              onClick={() => select(language.code)}
              onKeyDown={(event) => onSegmentKeyDown(event, index)}
              className={cn(
                "relative z-10 h-8 rounded-lg px-2 text-xs font-semibold tracking-wide transition-colors outline-none focus-visible:ring-4 focus-visible:ring-ring/40",
                checked ? "text-foreground" : "text-muted-foreground hover:text-foreground"
              )}
            >
              {language.short}
            </button>
          )
        })}
      </div>
    )
  }

  return (
    <div ref={root} className={cn("relative inline-block", className)} {...props}>
      <button
        ref={button}
        type="button"
        aria-label={`Language: ${selected?.native}`}
        aria-haspopup="listbox"
        aria-expanded={isOpen}
        aria-controls={isOpen ? `${id}-list` : undefined}
        onClick={() => (isOpen ? setOpen("closed") : openList(selectedIndex))}
        onKeyDown={(event) => {
          if (event.key === "ArrowDown" || event.key === "ArrowUp") {
            event.preventDefault()
            openList(selectedIndex)
          }
        }}
        className="inline-flex h-9 items-center gap-2 rounded-lg border bg-background px-3 text-sm font-medium shadow-xs transition-colors outline-none hover:bg-accent focus-visible:ring-4 focus-visible:ring-ring/40 aria-expanded:bg-accent"
      >
        <Globe className="size-4 text-muted-foreground" aria-hidden />
        <span lang={selected?.code}>{selected?.native}</span>
        <ChevronDown className={cn("size-4 text-muted-foreground transition-transform motion-reduce:transition-none", isOpen && "rotate-180")} aria-hidden />
      </button>
      {isOpen && (
        <div
          ref={list}
          id={`${id}-list`}
          role="listbox"
          aria-label="Language"
          tabIndex={-1}
          aria-activedescendant={`${id}-option-${active}`}
          onKeyDown={onListKeyDown}
          className={cn(
            "absolute top-full z-30 mt-1.5 max-h-72 w-64 max-w-[calc(100vw-2rem)] overflow-y-auto rounded-xl border bg-popover p-1 text-popover-foreground shadow-lg outline-none animate-in fade-in-0 zoom-in-95 slide-in-from-top-1 motion-reduce:animate-none",
            align === "end" ? "right-0 origin-top-right" : "left-0 origin-top-left"
          )}
        >
          {languages.map((language, index) => (
            <div
              key={language.code}
              id={`${id}-option-${index}`}
              role="option"
              aria-selected={index === selectedIndex}
              data-active={index === active || undefined}
              onPointerMove={() => setActive(index)}
              onClick={() => commit(index)}
              className="flex h-10 cursor-default items-center gap-3 rounded-md px-2 text-sm select-none data-[active]:bg-accent data-[active]:text-accent-foreground"
            >
              <span className="min-w-0 flex-1">
                <span lang={language.code} className="block truncate font-medium">
                  {language.native}
                </span>
                {language.name !== language.native && <span className="block truncate text-xs text-muted-foreground">{language.name}</span>}
              </span>
              <Check className={cn("size-4 shrink-0", index !== selectedIndex && "invisible")} aria-hidden />
            </div>
          ))}
        </div>
      )}
    </div>
  )
}
```
