Language Selector

Minimal locale control with scaled blur-sm menu and staggered options — no native select chrome.

Add to your project

Pick the way you like to build.

Let your AI assistant add it

Copy this prompt and paste it into the AI tool building your site.

# Integrate this UI component

Add this component to my project.

1. Install any missing dependencies.
2. Make sure Tailwind CSS is set up and scans the new file.
3. Prefer passing props over editing the component source — changing the component itself may break it.
4. Adjust colors, size, and layout via props or wrapper classes to match my project. You know my codebase better than this snippet does.

## Component source

```tsx
'use client';

import { AnimatePresence, motion } from 'framer-motion';
import { useEffect, useId, useRef, useState } from 'react';

// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
//
// locales         Available locales — code + label (+ optional short)
// currentLocale   Controlled selected code
// onChange        Selection callback
// variant         text | compact
// placement       Menu opens down | up
// className       Styles for the outer shell
//
type Locale = {
  code: string;
  label: string;
  short?: string;
};

type LanguageSelectorProps = {
  locales?: Locale[];
  currentLocale?: string;
  onChange?: (code: string) => void;
  variant?: 'text' | 'compact';
  placement?: 'down' | 'up';
  className?: string;
};

const DEFAULT_LOCALES: Locale[] = [
  { code: 'en', label: 'English', short: 'EN' },
  { code: 'es', label: 'Español', short: 'ES' },
  { code: 'fr', label: 'Français', short: 'FR' },
  { code: 'de', label: 'Deutsch', short: 'DE' },
  { code: 'pt', label: 'Português', short: 'PT' },
];

export default function LanguageSelector({
  locales = DEFAULT_LOCALES,
  currentLocale,
  onChange,
  variant = 'text',
  placement = 'down',
  className = '',
}: LanguageSelectorProps) {
  const list = locales.length ? locales : DEFAULT_LOCALES;
  const [open, setOpen] = useState(false);
  const [selected, setSelected] = useState(
    currentLocale ?? list[0]?.code ?? 'en'
  );
  const rootRef = useRef<HTMLDivElement>(null);
  const menuId = useId();

  useEffect(() => {
    if (currentLocale) setSelected(currentLocale);
  }, [currentLocale]);

  useEffect(() => {
    if (!open) return;
    function onPointer(e: MouseEvent) {
      if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
    }
    function onKey(e: KeyboardEvent) {
      if (e.key === 'Escape') setOpen(false);
    }
    window.addEventListener('mousedown', onPointer);
    window.addEventListener('keydown', onKey);
    return () => {
      window.removeEventListener('mousedown', onPointer);
      window.removeEventListener('keydown', onKey);
    };
  }, [open]);

  const active = list.find((l) => l.code === selected) ?? list[0];
  const triggerLabel =
    variant === 'compact'
      ? (active?.short ?? active?.code.toUpperCase())
      : active?.label;

  function choose(code: string) {
    setSelected(code);
    onChange?.(code);
    setOpen(false);
  }

  return (
    <div
      ref={rootRef}
      className={['relative inline-block', className].filter(Boolean).join(' ')}
    >
      <motion.button
        type='button'
        aria-haspopup='listbox'
        aria-expanded={open}
        aria-controls={menuId}
        onClick={() => setOpen((v) => !v)}
        className='group inline-flex items-center gap-2 rounded-full border border-gray-200/80 bg-white/70 px-3 py-1.5 text-xs font-medium tracking-wide text-gray-700 shadow-[0_1px_2px_rgba(0,0,0,0.04)] backdrop-blur-md transition-[border-color,box-shadow] duration-300 hover:border-gray-300 hover:shadow-[0_4px_16px_rgba(0,0,0,0.06)] focus:outline-hidden focus-visible:ring-2 focus-visible:ring-gray-900/15'
        whileTap={{ scale: 0.98 }}
      >
        <span className='relative'>
          {triggerLabel}
          <span
            className={[
              'absolute -bottom-0.5 left-0 h-px w-full origin-left bg-gray-900 transition-transform duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]',
              open ? 'scale-x-100' : 'scale-x-0 group-hover:scale-x-100',
            ].join(' ')}
          />
        </span>
        <motion.svg
          viewBox='0 0 12 12'
          className='h-3 w-3 text-gray-400'
          animate={{ rotate: open ? 180 : 0 }}
          transition={{ duration: 0.25 }}
          aria-hidden
        >
          <path
            d='M3 4.5L6 7.5L9 4.5'
            fill='none'
            stroke='currentColor'
            strokeWidth='1.4'
            strokeLinecap='round'
            strokeLinejoin='round'
          />
        </motion.svg>
      </motion.button>

      <AnimatePresence>
        {open ? (
          <motion.ul
            id={menuId}
            role='listbox'
            aria-label='Select language'
            className={[
              'absolute z-50 min-w-42 overflow-hidden rounded-2xl border border-gray-200/80 bg-white/90 p-1 shadow-[0_16px_40px_rgba(0,0,0,0.1)] backdrop-blur-xl',
              placement === 'up' ? 'bottom-full mb-2' : 'top-full mt-2',
              'left-0',
            ].join(' ')}
            initial={{ opacity: 0, y: placement === 'up' ? 6 : -6, scale: 0.96, filter: 'blur(4px)' }}
            animate={{ opacity: 1, y: 0, scale: 1, filter: 'blur(0px)' }}
            exit={{ opacity: 0, y: placement === 'up' ? 4 : -4, scale: 0.98, filter: 'blur(3px)' }}
            transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
          >
            {list.map((locale, i) => {
              const isActive = locale.code === selected;
              return (
                <motion.li
                  key={locale.code}
                  role='option'
                  aria-selected={isActive}
                  initial={{ opacity: 0, y: 4 }}
                  animate={{ opacity: 1, y: 0 }}
                  transition={{ delay: i * 0.03, duration: 0.2 }}
                >
                  <button
                    type='button'
                    onClick={() => choose(locale.code)}
                    className={[
                      'flex w-full items-center justify-between rounded-xl px-3 py-2 text-left text-xs tracking-wide transition-colors',
                      isActive
                        ? 'bg-gray-900 text-white'
                        : 'text-gray-700 hover:bg-gray-100',
                    ].join(' ')}
                  >
                    <span className='font-medium'>{locale.label}</span>
                    <span
                      className={[
                        'font-mono text-[10px] uppercase tracking-wider',
                        isActive ? 'text-white/60' : 'text-gray-400',
                      ].join(' ')}
                    >
                      {locale.short ?? locale.code}
                    </span>
                  </button>
                </motion.li>
              );
            })}
          </motion.ul>
        ) : null}
      </AnimatePresence>
    </div>
  );
}

```

Works with your AI tools

ClaudeClaude*
CodexCodex*
LovableLovable
Base44Base44
Figma MakeFigma Make
ReplitReplit*
CursorCursor*
v0v0

* Compatible with most projects that use the default React/Next.js framework.

Built With

ReactTailwind CSSFramer Motion

Props

PropTypeDescription
localesLocale[]Available locales — code + label (+ optional short)
currentLocalestringControlled selected code
onChange(code: string) => voidSelection callback
variant'text' | 'compact'Full label or condensed trigger
placement'down' | 'up'Direction the menu opens
classNamestringStyles for the outer shell

More Navigation Components

New: Coverflow Gallery is live — explore the catalog. See what's new

Restrained product update strip with gradient sheen and a clean collapse dismiss.

Compact sun and moon switch that persists theme preference and toggles the document dark class.