Web InnoventixFreeCode

Language Menu

Original · free

language switcher menu

byWeb InnoventixReact + Tailwind
menulanguagemenus
Open

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.json
menu-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&apos;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 quote

Similar components

Browse all →