Skip to content
Component

Language switcher

A link to the same page in the other language, narrowing to the globe alone.

  • #arc
  • #component
  • #language
  • #locale
  • #i18n
  • #header

Install

One command installs the files and packages this item needs.

Terminalbash
npx shadcn@latest add https://dsui.vercel.app/r/language-switcher.json

language-switcher.module.csscss
.link { display: inline-flex; min-height: var(--control-height-sm); align-items: center; gap: var(--space-2); border-radius: var(--radius-pill); padding: 0 var(--space-3); color: var(--text-secondary); font-size: var(--label-md); font-weight: var(--label-weight); line-height: var(--leading-body); text-decoration: none; white-space: nowrap; transition: color var(--duration-fast) var(--ease-standard), background var(--duration-fast) var(--ease-standard); }
.link svg { flex: none; }
@media (hover: hover) and (pointer: fine) {
  .link:hover { background: var(--surface-muted); color: var(--foreground); }
}
/* Touch has no hover, so the press carries the whole answer. */
.link:active { background: var(--surface-sunken); color: var(--foreground); }
/* Clipped rather than removed: the name is still the link's accessible name when only the globe shows. */
@media (max-width: 1023px) {
  .compactLabel { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
}
@media (prefers-reduced-motion: reduce) { .link { transition: none; } }
language-switcher.tsxtsx
"use client";

import type { AnchorHTMLAttributes, ReactNode } from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import { LanguageSkillIcon } from "@hugeicons/core-free-icons";
import { playUiSound } from "../sound/play";
import styles from "./language-switcher.module.css";

export interface LanguageSwitcherProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
  /** The language the control moves to: its BCP 47 code and the name to show. */
  to: { code: string; label: string };
  /** Hides the name below the lg breakpoint. It stays the link's accessible name. */
  compact?: boolean;
  /** Replaces the globe. Pass a decorative icon of the same size. */
  icon?: ReactNode;
}

/**
 * A link to the same page in the other language. It is an anchor, not a button, because the two languages are two
 * documents: the switch is a navigation, so it opens in a new tab with the usual modifiers and copies as a URL.
 */
export function LanguageSwitcher({ className, to, compact = false, icon, onClick, ...props }: LanguageSwitcherProps) {
  return (
    <a
      {...props}
      hrefLang={to.code}
      lang={to.code}
      className={[styles.link, className].filter(Boolean).join(" ")}
      onClick={event => { playUiSound("tick"); onClick?.(event); }}
    >
      {icon ?? <HugeiconsIcon icon={LanguageSkillIcon} size={17} strokeWidth={1.75} aria-hidden="true" />}
      <span className={compact ? styles.compactLabel : undefined}>{to.label}</span>
    </a>
  );
}

LanguageSwitcher.displayName = "LanguageSwitcher";

export default LanguageSwitcher;