Language Menu
Original · freelanguage switcher menu
byWeb InnoventixReact + Tailwind
menulanguagemenus
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/menu-language.jsonmenu-language.tsx
"use client";
import {
useEffect,
useId,
useMemo,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Lang = {
code: string;
native: string;
english: string;
region: string;
greeting: string;
dir: "ltr" | "rtl";
};
const LANGS: Lang[] = [
{ code: "en", native: "English", english: "English", region: "United States", greeting: "Hello! Everything is ready for you.", dir: "ltr" },
{ code: "es", native: "Español", english: "Spanish", region: "España", greeting: "¡Hola! Todo está listo para ti.", dir: "ltr" },
{ code: "fr", native: "Français", english: "French", region: "France", greeting: "Bonjour ! Tout est prêt pour vous.", dir: "ltr" },
{ code: "de", native: "Deutsch", english: "German", region: "Deutschland", greeting: "Hallo! Alles ist für dich bereit.", dir: "ltr" },
{ code: "pt", native: "Português", english: "Portuguese", region: "Brasil", greeting: "Olá! Está tudo pronto para você.", dir: "ltr" },
{ code: "ja", native: "日本語", english: "Japanese", region: "日本", greeting: "こんにちは!準備が整いました。", dir: "ltr" },
{ code: "zh", native: "中文", english: "Chinese", region: "中国", greeting: "你好!一切都已为你准备就绪。", dir: "ltr" },
{ code: "ar", native: "العربية", english: "Arabic", region: "السعودية", greeting: "مرحبًا! كل شيء جاهز من أجلك.", dir: "rtl" },
{ code: "hi", native: "हिन्दी", english: "Hindi", region: "भारत", greeting: "नमस्ते! सब कुछ आपके लिए तैयार है।", dir: "ltr" },
{ code: "ru", native: "Русский", english: "Russian", region: "Россия", greeting: "Привет! Всё готово для вас.", dir: "ltr" },
];
function starPoints(cx: number, cy: number, r: number, rot = -90): string {
const pts: string[] = [];
for (let i = 0; i < 5; i++) {
const o = ((rot + i * 72) * Math.PI) / 180;
const n = ((rot + i * 72 + 36) * Math.PI) / 180;
pts.push(`${cx + r * Math.cos(o)},${cy + r * Math.sin(o)}`);
pts.push(`${cx + r * 0.4 * Math.cos(n)},${cy + r * 0.4 * Math.sin(n)}`);
}
return pts.join(" ");
}
function flagBody(code: string) {
switch (code) {
case "en":
return (
<>
<rect width="20" height="14" fill="#B22234" />
<rect y="1.08" width="20" height="1.08" fill="#fff" />
<rect y="3.23" width="20" height="1.08" fill="#fff" />
<rect y="5.38" width="20" height="1.08" fill="#fff" />
<rect y="7.54" width="20" height="1.08" fill="#fff" />
<rect y="9.69" width="20" height="1.08" fill="#fff" />
<rect y="11.85" width="20" height="1.08" fill="#fff" />
<rect width="8.4" height="7.54" fill="#3C3B6E" />
<g fill="#fff">
<circle cx="1.7" cy="1.4" r="0.35" />
<circle cx="4.2" cy="1.4" r="0.35" />
<circle cx="6.7" cy="1.4" r="0.35" />
<circle cx="2.95" cy="2.9" r="0.35" />
<circle cx="5.45" cy="2.9" r="0.35" />
<circle cx="1.7" cy="4.4" r="0.35" />
<circle cx="4.2" cy="4.4" r="0.35" />
<circle cx="6.7" cy="4.4" r="0.35" />
<circle cx="2.95" cy="5.9" r="0.35" />
<circle cx="5.45" cy="5.9" r="0.35" />
</g>
</>
);
case "es":
return (
<>
<rect width="20" height="14" fill="#AA151B" />
<rect y="3.5" width="20" height="7" fill="#F1BF00" />
</>
);
case "fr":
return (
<>
<rect width="20" height="14" fill="#fff" />
<rect width="6.67" height="14" fill="#0055A4" />
<rect x="13.33" width="6.67" height="14" fill="#EF4135" />
</>
);
case "de":
return (
<>
<rect width="20" height="14" fill="#000" />
<rect y="4.67" width="20" height="4.67" fill="#DD0000" />
<rect y="9.33" width="20" height="4.67" fill="#FFCE00" />
</>
);
case "pt":
return (
<>
<rect width="20" height="14" fill="#009C3B" />
<polygon points="10,1.6 18.4,7 10,12.4 1.6,7" fill="#FFDF00" />
<circle cx="10" cy="7" r="2.9" fill="#002776" />
<path d="M7.3 6.4 A3 3 0 0 1 12.7 7.6" fill="none" stroke="#fff" strokeWidth="0.5" />
</>
);
case "ja":
return (
<>
<rect width="20" height="14" fill="#fff" />
<circle cx="10" cy="7" r="3.2" fill="#BC002D" />
</>
);
case "zh":
return (
<>
<rect width="20" height="14" fill="#DE2910" />
<polygon points={starPoints(4, 3.6, 2.2)} fill="#FFDE00" />
<polygon points={starPoints(8, 1.5, 0.7)} fill="#FFDE00" />
<polygon points={starPoints(9.6, 3.1, 0.7)} fill="#FFDE00" />
<polygon points={starPoints(9.6, 5.4, 0.7)} fill="#FFDE00" />
<polygon points={starPoints(8, 7, 0.7)} fill="#FFDE00" />
</>
);
case "ar":
return (
<>
<rect width="20" height="14" fill="#006C35" />
<rect x="4" y="5" width="12" height="0.9" rx="0.45" fill="#fff" />
<rect x="4" y="7.4" width="9.5" height="0.7" rx="0.35" fill="#fff" />
<rect x="4" y="9.2" width="11" height="0.7" rx="0.35" fill="#fff" />
</>
);
case "hi":
return (
<>
<rect width="20" height="14" fill="#FF9933" />
<rect y="4.67" width="20" height="4.67" fill="#fff" />
<rect y="9.33" width="20" height="4.67" fill="#138808" />
<circle cx="10" cy="7" r="1.7" fill="none" stroke="#000080" strokeWidth="0.4" />
<circle cx="10" cy="7" r="0.35" fill="#000080" />
</>
);
case "ru":
return (
<>
<rect width="20" height="14" fill="#fff" />
<rect y="4.67" width="20" height="4.67" fill="#0039A6" />
<rect y="9.33" width="20" height="4.67" fill="#D52B1E" />
</>
);
default:
return <rect width="20" height="14" fill="#94a3b8" />;
}
}
function Flag({ code, className }: { code: string; className?: string }) {
const uid = useId().replace(/:/g, "");
const clip = `mlang-clip-${uid}`;
return (
<svg viewBox="0 0 20 14" className={className} aria-hidden="true" role="presentation">
<defs>
<clipPath id={clip}>
<rect width="20" height="14" rx="2.6" />
</clipPath>
</defs>
<g clipPath={`url(#${clip})`}>{flagBody(code)}</g>
<rect
x="0.3"
y="0.3"
width="19.4"
height="13.4"
rx="2.3"
fill="none"
strokeWidth="0.6"
className="stroke-black/10 dark:stroke-white/20"
/>
</svg>
);
}
function GlobeIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18" />
<path d="M12 3c2.7 2.4 4.2 5.6 4.2 9s-1.5 6.6-4.2 9c-2.7-2.4-4.2-5.6-4.2-9S9.3 5.4 12 3Z" />
</svg>
);
}
function ChevronIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m6 9 6 6 6-6" />
</svg>
);
}
function CheckIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m5 12.5 4.5 4.5L19 6.5" />
</svg>
);
}
export default function LanguageSwitcher() {
const reduce = useReducedMotion();
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState("en");
const [activeIndex, setActiveIndex] = useState(0);
const [announce, setAnnounce] = useState("");
const triggerRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
const typeahead = useRef<{ str: string; t: number }>({ str: "", t: 0 });
const headingId = useId();
const menuId = useId();
const current = useMemo(
() => LANGS.find((l) => l.code === selected) ?? LANGS[0],
[selected],
);
const len = LANGS.length;
const openMenu = () => {
const idx = Math.max(0, LANGS.findIndex((l) => l.code === selected));
setActiveIndex(idx);
setOpen(true);
};
const closeMenu = (focusTrigger = true) => {
setOpen(false);
if (focusTrigger) requestAnimationFrame(() => triggerRef.current?.focus());
};
const choose = (i: number) => {
const lang = LANGS[i];
setSelected(lang.code);
setAnnounce(`Language set to ${lang.english}`);
closeMenu();
};
useEffect(() => {
if (open) itemRefs.current[activeIndex]?.focus();
}, [open, activeIndex]);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
const t = e.target as Node;
if (!panelRef.current?.contains(t) && !triggerRef.current?.contains(t)) {
setOpen(false);
}
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open]);
const handleTypeahead = (ch: string) => {
const now = Date.now();
const buf = (now - typeahead.current.t < 600 ? typeahead.current.str + ch : ch).toLowerCase();
typeahead.current = { str: buf, t: now };
const idx = LANGS.findIndex(
(l) => l.english.toLowerCase().startsWith(buf) || l.native.toLowerCase().startsWith(buf),
);
if (idx >= 0) setActiveIndex(idx);
};
const onTriggerKeyDown = (e: ReactKeyboardEvent<HTMLButtonElement>) => {
if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
e.preventDefault();
openMenu();
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex(len - 1);
setOpen(true);
}
};
const onMenuKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setActiveIndex((i) => (i + 1) % len);
break;
case "ArrowUp":
e.preventDefault();
setActiveIndex((i) => (i - 1 + len) % len);
break;
case "Home":
e.preventDefault();
setActiveIndex(0);
break;
case "End":
e.preventDefault();
setActiveIndex(len - 1);
break;
case "Escape":
e.preventDefault();
closeMenu();
break;
case "Tab":
setOpen(false);
break;
case "Enter":
case " ":
e.preventDefault();
choose(activeIndex);
break;
default:
if (e.key.length === 1 && /\S/.test(e.key)) handleTypeahead(e.key);
}
};
return (
<section className="relative w-full overflow-hidden bg-neutral-50 py-20 sm:py-28 dark:bg-neutral-950">
<style>{`
@keyframes mlang-pulse { 0%,100%{ opacity:.35; transform:scale(1) } 50%{ opacity:1; transform:scale(1.4) } }
@keyframes mlang-sheen { 0%{ background-position:-150% 0 } 100%{ background-position:150% 0 } }
.mlang-pulse{ animation: mlang-pulse 2.4s ease-in-out infinite }
.mlang-sheen{
background-image: linear-gradient(90deg, transparent, rgba(99,102,241,0.9), transparent);
background-size: 200% 100%;
animation: mlang-sheen 5s linear infinite;
}
@media (prefers-reduced-motion: reduce){
.mlang-pulse, .mlang-sheen { animation: none !important }
}
`}</style>
{/* atmospheric backdrop */}
<div aria-hidden className="pointer-events-none absolute inset-0">
<div className="absolute -left-24 -top-24 h-72 w-72 rounded-full bg-indigo-300/30 blur-3xl dark:bg-indigo-600/20" />
<div className="absolute -bottom-28 right-0 h-80 w-80 rounded-full bg-emerald-300/25 blur-3xl dark:bg-emerald-600/15" />
<div
className="absolute inset-0 opacity-[0.04] dark:opacity-[0.07]"
style={{
backgroundImage:
"linear-gradient(to right, currentColor 1px, transparent 1px), linear-gradient(to bottom, currentColor 1px, transparent 1px)",
backgroundSize: "44px 44px",
color: "#6366f1",
}}
/>
</div>
<div className="relative mx-auto max-w-lg px-6">
<header className="mb-8 text-center">
<span className="inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-white/70 px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-indigo-700 backdrop-blur dark:border-indigo-500/30 dark:bg-white/5 dark:text-indigo-300">
<span className="mlang-pulse inline-block h-1.5 w-1.5 rounded-full bg-emerald-500" />
Localization
</span>
<h2 className="mt-4 text-3xl font-semibold tracking-tight text-neutral-900 sm:text-4xl dark:text-neutral-50">
Speak the room's language
</h2>
<p className="mx-auto mt-3 max-w-sm text-sm leading-relaxed text-neutral-600 dark:text-neutral-400">
Switch the interface instantly. Your choice applies across every workspace, invoice and notification.
</p>
<span aria-hidden className="mlang-sheen mx-auto mt-5 block h-px w-24 rounded-full" />
</header>
<div className="rounded-2xl border border-neutral-200 bg-white/80 p-1.5 shadow-xl shadow-neutral-900/5 backdrop-blur-sm dark:border-white/10 dark:bg-neutral-900/70 dark:shadow-black/40">
{/* control row */}
<div className="flex items-center justify-between gap-4 rounded-xl p-4">
<div className="flex min-w-0 items-center gap-3">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 dark:bg-indigo-500/15 dark:text-indigo-300">
<GlobeIcon className="h-5 w-5" />
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-semibold text-neutral-900 dark:text-neutral-100">
Interface language
</span>
<span className="block truncate text-xs text-neutral-500 dark:text-neutral-400">
{len} languages available
</span>
</span>
</div>
<div className="relative shrink-0">
<button
ref={triggerRef}
type="button"
onClick={() => (open ? setOpen(false) : openMenu())}
onKeyDown={onTriggerKeyDown}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? menuId : undefined}
className="inline-flex items-center gap-2 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm font-medium text-neutral-800 shadow-sm transition-colors hover:border-neutral-400 hover:bg-neutral-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-white/15 dark:bg-white/5 dark:text-neutral-100 dark:hover:border-white/25 dark:hover:bg-white/10 dark:focus-visible:ring-offset-neutral-900"
>
<Flag code={current.code} className="h-4 w-[1.43rem] shrink-0" />
<span className="max-w-[7rem] truncate">{current.native}</span>
<ChevronIcon
className={`h-4 w-4 text-neutral-500 transition-transform duration-200 motion-reduce:transition-none dark:text-neutral-400 ${open ? "rotate-180" : ""}`}
/>
</button>
<AnimatePresence>
{open && (
<motion.div
ref={panelRef}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.97 }}
transition={{ duration: reduce ? 0.12 : 0.18, ease: [0.16, 1, 0.3, 1] }}
style={{ transformOrigin: "top right" }}
className="absolute right-0 z-50 mt-2 w-72 overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-2xl shadow-neutral-900/10 dark:border-white/10 dark:bg-neutral-900 dark:shadow-black/50"
>
<div className="border-b border-neutral-100 px-4 py-3 dark:border-white/10">
<p id={headingId} className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">
Choose your language
</p>
<p className="mt-0.5 text-xs text-neutral-500 dark:text-neutral-400">
Use arrow keys or type to jump
</p>
</div>
<div
ref={menuRef}
id={menuId}
role="menu"
aria-labelledby={headingId}
aria-orientation="vertical"
onKeyDown={onMenuKeyDown}
className="max-h-72 overflow-y-auto p-1.5"
>
{LANGS.map((l, i) => {
const isSelected = l.code === selected;
const isActive = i === activeIndex;
return (
<button
key={l.code}
ref={(el) => {
itemRefs.current[i] = el;
}}
role="menuitemradio"
aria-checked={isSelected}
tabIndex={isActive ? 0 : -1}
onClick={() => choose(i)}
onMouseEnter={() => setActiveIndex(i)}
className={`group flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left outline-none transition-colors ${
isActive
? "bg-indigo-50 dark:bg-indigo-500/15"
: "hover:bg-neutral-50 dark:hover:bg-white/5"
}`}
>
<Flag code={l.code} className="h-5 w-[1.79rem] shrink-0" />
<span className="min-w-0 flex-1">
<span
className={`block truncate text-sm font-medium ${
isActive
? "text-indigo-900 dark:text-indigo-100"
: "text-neutral-900 dark:text-neutral-100"
}`}
>
{l.native}
</span>
<span className="block truncate text-xs text-neutral-500 dark:text-neutral-400">
{l.english} · {l.region}
</span>
</span>
{isSelected ? (
<CheckIcon className="h-4 w-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
) : (
<span className="h-4 w-4 shrink-0" />
)}
</button>
);
})}
</div>
<div className="border-t border-neutral-100 px-4 py-2.5 text-center text-xs text-neutral-400 dark:border-white/10 dark:text-neutral-500">
Missing a language?{" "}
<a
href="#request-language"
className="font-medium text-indigo-600 underline-offset-2 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-indigo-400"
>
Request it
</a>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* live localized preview */}
<div className="rounded-xl border border-neutral-100 bg-neutral-50/70 p-5 dark:border-white/5 dark:bg-white/[0.03]">
<p className="mb-3 text-[0.7rem] font-semibold uppercase tracking-[0.16em] text-neutral-400 dark:text-neutral-500">
Live preview
</p>
<p
dir={current.dir}
lang={current.code}
className={`text-lg font-medium leading-snug text-neutral-900 dark:text-neutral-50 ${
current.dir === "rtl" ? "text-right" : "text-left"
}`}
>
{current.greeting}
</p>
<dl className="mt-4 grid grid-cols-3 gap-3 border-t border-neutral-200 pt-4 dark:border-white/10">
<div>
<dt className="text-[0.7rem] uppercase tracking-wide text-neutral-400 dark:text-neutral-500">
Language
</dt>
<dd className="mt-0.5 text-sm font-medium text-neutral-800 dark:text-neutral-200">
{current.native}
</dd>
</div>
<div>
<dt className="text-[0.7rem] uppercase tracking-wide text-neutral-400 dark:text-neutral-500">
Region
</dt>
<dd className="mt-0.5 truncate text-sm font-medium text-neutral-800 dark:text-neutral-200">
{current.region}
</dd>
</div>
<div>
<dt className="text-[0.7rem] uppercase tracking-wide text-neutral-400 dark:text-neutral-500">
Direction
</dt>
<dd className="mt-0.5 text-sm font-medium uppercase text-neutral-800 dark:text-neutral-200">
{current.dir}
</dd>
</div>
</dl>
</div>
</div>
<p aria-live="polite" className="sr-only">
{announce}
</p>
</div>
</section>
);
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Dropdown Menu
Originalbasic dropdown menu with items
Dropdown Icons Menu
Originaldropdown with icons and shortcuts

User Menu
Originaluser account dropdown with avatar

Mega Menu
Originalmega menu with grouped links

Mega Cols Menu
Originalmulti-column mega menu with featured

Context Menu
Originalright-click context menu

Nested Menu
Originaldropdown with nested submenus

Select Menu
Originalcustom single select dropdown

Multiselect Menu
Originalmulti-select with checkable items

Command Menu
Originalcommand palette style menu with search

Actions Menu
Originalactions/kebab menu

Share Menu
Originalshare menu with options

