Overlay
SwitchLang
Language switcher with per-locale links
Preview
size
current
variant
icon
Code
import css from "./SwitchLang.module.css";
import { useState, type MouseEvent } from "react";
type Size = "sm" | "md" | "lg";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface SwitchLangOption {
value: string;
label: string;
href: string;
}
interface SwitchLangProps {
// Exactly two: with more languages the pattern is a dropdown, not a switch.
options: [SwitchLangOption, SwitchLangOption];
current: string;
size: Size | ResponsiveSize;
variant: "inline" | "track";
icon?: React.ReactNode;
}
function getSizeClasses(size: Size | ResponsiveSize): string {
if (typeof size === "string") {
return css[size];
}
const classes = [css[`${size.base}_base`]];
if (size.mobile) classes.push(css[`${size.mobile}_mobile`]);
if (size.tablet) classes.push(css[`${size.tablet}_tablet`]);
if (size.desktop) classes.push(css[`${size.desktop}_desktop`]);
if (size.wide) classes.push(css[`${size.wide}_wide`]);
return classes.join(" ");
}
export default function SwitchLang({
options,
current,
size,
variant,
icon,
}: SwitchLangProps) {
const [first, second] = options;
const [interacted, setInteracted] = useState(false);
const [picked, setPicked] = useState<string | null>(null);
const [lastCurrent, setLastCurrent] = useState(current);
if (current !== lastCurrent) {
setLastCurrent(current);
setPicked(null);
}
const shown = picked ?? current;
const pick = (ev: MouseEvent, value: string) => {
if (ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
setInteracted(true);
setPicked(value);
};
return (
<div
className={`${css.switchlang} ${variant === "track" ? css.track : ""} ${interacted ? css.animated : ""} ${getSizeClasses(size)}`}
>
{icon && (
<span className={css.switchlang_icon} aria-hidden="true">
{icon}
</span>
)}
<span className={css.switchlang_options}>
<a
className={`${css.switchlang_option} ${first.value === shown ? css.active : ""}`}
href={first.href}
lang={first.value}
hrefLang={first.value}
aria-current={first.value === current ? "true" : undefined}
onClick={(ev) => pick(ev, first.value)}
>
{first.label}
</a>
<span className={css.switchlang_separator} aria-hidden="true">
/
</span>
<a
className={`${css.switchlang_option} ${second.value === shown ? css.active : ""}`}
href={second.href}
lang={second.value}
hrefLang={second.value}
aria-current={second.value === current ? "true" : undefined}
onClick={(ev) => pick(ev, second.value)}
>
{second.label}
</a>
</span>
</div>
);
}
.switchlang {
display: inline-flex;
align-items: center;
/* No gap: the air inside each option's box is what separates the text from the slash. */
gap: 0;
font-family: var(--font-heading);
font-weight: var(--weight-500);
letter-spacing: 0.5px;
&.sm {
font-size: var(--text-14);
--switchlang-box: var(--size-24);
--switchlang-air: 4px;
}
&.md {
font-size: var(--text-16);
--switchlang-box: var(--size-32);
--switchlang-air: 6px;
}
&.lg {
font-size: var(--text-18);
--switchlang-box: var(--size-40);
--switchlang-air: 8px;
}
&.sm_base {
font-size: var(--text-14);
--switchlang-box: var(--size-24);
--switchlang-air: 4px;
}
&.md_base {
font-size: var(--text-16);
--switchlang-box: var(--size-32);
--switchlang-air: 6px;
}
&.lg_base {
font-size: var(--text-18);
--switchlang-box: var(--size-40);
--switchlang-air: 8px;
}
@media (min-width: 480px) {
&.sm_mobile {
font-size: var(--text-14);
--switchlang-box: var(--size-24);
--switchlang-air: 4px;
}
&.md_mobile {
font-size: var(--text-16);
--switchlang-box: var(--size-32);
--switchlang-air: 6px;
}
&.lg_mobile {
font-size: var(--text-18);
--switchlang-box: var(--size-40);
--switchlang-air: 8px;
}
}
@media (min-width: 768px) {
&.sm_tablet {
font-size: var(--text-14);
--switchlang-box: var(--size-24);
--switchlang-air: 4px;
}
&.md_tablet {
font-size: var(--text-16);
--switchlang-box: var(--size-32);
--switchlang-air: 6px;
}
&.lg_tablet {
font-size: var(--text-18);
--switchlang-box: var(--size-40);
--switchlang-air: 8px;
}
}
@media (min-width: 1024px) {
&.sm_desktop {
font-size: var(--text-14);
--switchlang-box: var(--size-24);
--switchlang-air: 4px;
}
&.md_desktop {
font-size: var(--text-16);
--switchlang-box: var(--size-32);
--switchlang-air: 6px;
}
&.lg_desktop {
font-size: var(--text-18);
--switchlang-box: var(--size-40);
--switchlang-air: 8px;
}
}
@media (min-width: 1440px) {
&.sm_wide {
font-size: var(--text-14);
--switchlang-box: var(--size-24);
--switchlang-air: 4px;
}
&.md_wide {
font-size: var(--text-16);
--switchlang-box: var(--size-32);
--switchlang-air: 6px;
}
&.lg_wide {
font-size: var(--text-18);
--switchlang-box: var(--size-40);
--switchlang-air: 8px;
}
}
.switchlang_option {
/* The box is the click target and never drops under 24; it paints nothing. */
display: inline-flex;
align-items: center;
justify-content: center;
height: var(--switchlang-box);
padding-inline: var(--switchlang-air);
color: var(--color-text-tertiary);
text-decoration: none;
border-radius: var(--radius-4);
transition: color 0.2s ease;
@media (hover: hover) {
&:hover {
color: var(--color-text);
}
}
&.active {
color: var(--color-accent);
}
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 0;
}
}
.switchlang_options {
display: contents;
}
.switchlang_separator {
color: var(--color-text-tertiary);
opacity: 0.5;
}
&.track {
display: inline-flex;
align-items: center;
gap: var(--space-8);
/* The pill form owns its own scale, decoupled from the inline one. */
&.sm {
font-size: var(--text-14);
--switchlang-pill-height: var(--size-32);
}
&.md {
font-size: var(--text-16);
--switchlang-pill-height: var(--size-40);
}
&.lg {
font-size: var(--text-18);
--switchlang-pill-height: var(--size-44);
}
&.sm_base {
font-size: var(--text-14);
--switchlang-pill-height: var(--size-32);
}
&.md_base {
font-size: var(--text-16);
--switchlang-pill-height: var(--size-40);
}
&.lg_base {
font-size: var(--text-18);
--switchlang-pill-height: var(--size-44);
}
@media (min-width: 480px) {
&.sm_mobile {
font-size: var(--text-14);
--switchlang-pill-height: var(--size-32);
}
&.md_mobile {
font-size: var(--text-16);
--switchlang-pill-height: var(--size-40);
}
&.lg_mobile {
font-size: var(--text-18);
--switchlang-pill-height: var(--size-44);
}
}
@media (min-width: 768px) {
&.sm_tablet {
font-size: var(--text-14);
--switchlang-pill-height: var(--size-32);
}
&.md_tablet {
font-size: var(--text-16);
--switchlang-pill-height: var(--size-40);
}
&.lg_tablet {
font-size: var(--text-18);
--switchlang-pill-height: var(--size-44);
}
}
@media (min-width: 1024px) {
&.sm_desktop {
font-size: var(--text-14);
--switchlang-pill-height: var(--size-32);
}
&.md_desktop {
font-size: var(--text-16);
--switchlang-pill-height: var(--size-40);
}
&.lg_desktop {
font-size: var(--text-18);
--switchlang-pill-height: var(--size-44);
}
}
@media (min-width: 1440px) {
&.sm_wide {
font-size: var(--text-14);
--switchlang-pill-height: var(--size-32);
}
&.md_wide {
font-size: var(--text-16);
--switchlang-pill-height: var(--size-40);
}
&.lg_wide {
font-size: var(--text-18);
--switchlang-pill-height: var(--size-44);
}
}
.switchlang_icon {
flex-shrink: 0;
display: inline-flex;
align-items: center;
color: var(--color-text-secondary);
svg {
width: 1.4em;
height: 1.4em;
}
}
.switchlang_options {
--switchlang-pill-air: 3px;
position: relative;
isolation: isolate;
display: inline-flex;
align-items: stretch;
min-height: var(--switchlang-pill-height);
padding: var(--switchlang-pill-air);
border: var(--border-surface) solid var(--color-border-card);
border-radius: var(--radius-pill);
-webkit-tap-highlight-color: transparent;
&::before {
content: "";
position: absolute;
z-index: -1;
inset: var(--switchlang-pill-air) 50% var(--switchlang-pill-air) var(--switchlang-pill-air);
border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
transition: scale 0.15s ease;
}
@media (prefers-reduced-motion: reduce) {
&::before {
transition: none;
}
}
&:has(.switchlang_option:last-child.active)::before {
translate: 100% 0;
}
&:has(.switchlang_option:active)::before {
scale: 0.95;
}
}
&.animated .switchlang_options::before {
transition:
translate 0.3s cubic-bezier(0.1, 0.9, 0.2, 1),
scale 0.15s ease;
}
@media (prefers-reduced-motion: reduce) {
&.animated .switchlang_options::before {
transition: none;
}
}
.switchlang_separator {
display: none;
}
.switchlang_option {
flex: 1;
min-width: 0;
height: auto;
padding-inline: var(--space-16);
border-radius: var(--radius-pill);
&.active {
font-weight: var(--weight-600);
}
}
}
}
<script setup lang="ts">
import { computed, ref, watch } from "vue";
type Size = "sm" | "md" | "lg";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface SwitchLangOption {
value: string;
label: string;
href: string;
}
interface SwitchLangProps {
// Exactly two: with more languages the pattern is a dropdown, not a switch.
options: [SwitchLangOption, SwitchLangOption];
current: string;
size: Size | ResponsiveSize;
variant: "inline" | "track";
}
const { options, current, size } = defineProps<SwitchLangProps>();
const first = computed(() => options[0]);
const second = computed(() => options[1]);
const interacted = ref(false);
const picked = ref<string | null>(null);
watch(
() => current,
() => {
picked.value = null;
},
);
const shown = computed(() => picked.value ?? current);
function pick(ev: MouseEvent, value: string) {
if (ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
interacted.value = true;
picked.value = value;
}
const sizeClasses = computed(() => {
if (typeof size === "string") {
return size;
}
const classes = [`${size.base}_base`];
if (size.mobile) classes.push(`${size.mobile}_mobile`);
if (size.tablet) classes.push(`${size.tablet}_tablet`);
if (size.desktop) classes.push(`${size.desktop}_desktop`);
if (size.wide) classes.push(`${size.wide}_wide`);
return classes.join(" ");
});
</script>
<template>
<div
class="switchlang"
:class="[{ track: variant === 'track', animated: interacted }, sizeClasses]"
>
<span v-if="$slots.icon" class="switchlang_icon" aria-hidden="true">
<slot name="icon"></slot>
</span>
<span class="switchlang_options">
<a
class="switchlang_option"
:class="{ active: first.value === shown }"
:href="first.href"
:lang="first.value"
:hreflang="first.value"
:aria-current="first.value === current ? 'true' : undefined"
@click="pick($event, first.value)"
>
{{ first.label }}
</a>
<span class="switchlang_separator" aria-hidden="true">/</span>
<a
class="switchlang_option"
:class="{ active: second.value === shown }"
:href="second.href"
:lang="second.value"
:hreflang="second.value"
:aria-current="second.value === current ? 'true' : undefined"
@click="pick($event, second.value)"
>
{{ second.label }}
</a>
</span>
</div>
</template>
<style scoped>
.switchlang {
display: inline-flex;
align-items: center;
/* No gap: the air inside each option's box is what separates the text from the slash. */
gap: 0;
font-family: var(--font-heading);
font-weight: var(--weight-500);
letter-spacing: 0.5px;
&.sm {
font-size: var(--text-14);
--switchlang-box: var(--size-24);
--switchlang-air: 4px;
}
&.md {
font-size: var(--text-16);
--switchlang-box: var(--size-32);
--switchlang-air: 6px;
}
&.lg {
font-size: var(--text-18);
--switchlang-box: var(--size-40);
--switchlang-air: 8px;
}
&.sm_base {
font-size: var(--text-14);
--switchlang-box: var(--size-24);
--switchlang-air: 4px;
}
&.md_base {
font-size: var(--text-16);
--switchlang-box: var(--size-32);
--switchlang-air: 6px;
}
&.lg_base {
font-size: var(--text-18);
--switchlang-box: var(--size-40);
--switchlang-air: 8px;
}
@media (min-width: 480px) {
&.sm_mobile {
font-size: var(--text-14);
--switchlang-box: var(--size-24);
--switchlang-air: 4px;
}
&.md_mobile {
font-size: var(--text-16);
--switchlang-box: var(--size-32);
--switchlang-air: 6px;
}
&.lg_mobile {
font-size: var(--text-18);
--switchlang-box: var(--size-40);
--switchlang-air: 8px;
}
}
@media (min-width: 768px) {
&.sm_tablet {
font-size: var(--text-14);
--switchlang-box: var(--size-24);
--switchlang-air: 4px;
}
&.md_tablet {
font-size: var(--text-16);
--switchlang-box: var(--size-32);
--switchlang-air: 6px;
}
&.lg_tablet {
font-size: var(--text-18);
--switchlang-box: var(--size-40);
--switchlang-air: 8px;
}
}
@media (min-width: 1024px) {
&.sm_desktop {
font-size: var(--text-14);
--switchlang-box: var(--size-24);
--switchlang-air: 4px;
}
&.md_desktop {
font-size: var(--text-16);
--switchlang-box: var(--size-32);
--switchlang-air: 6px;
}
&.lg_desktop {
font-size: var(--text-18);
--switchlang-box: var(--size-40);
--switchlang-air: 8px;
}
}
@media (min-width: 1440px) {
&.sm_wide {
font-size: var(--text-14);
--switchlang-box: var(--size-24);
--switchlang-air: 4px;
}
&.md_wide {
font-size: var(--text-16);
--switchlang-box: var(--size-32);
--switchlang-air: 6px;
}
&.lg_wide {
font-size: var(--text-18);
--switchlang-box: var(--size-40);
--switchlang-air: 8px;
}
}
.switchlang_option {
/* The box is the click target and never drops under 24; it paints nothing. */
display: inline-flex;
align-items: center;
justify-content: center;
height: var(--switchlang-box);
padding-inline: var(--switchlang-air);
color: var(--color-text-tertiary);
text-decoration: none;
border-radius: var(--radius-4);
transition: color 0.2s ease;
@media (hover: hover) {
&:hover {
color: var(--color-text);
}
}
&.active {
color: var(--color-accent);
}
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 0;
}
}
.switchlang_options {
display: contents;
}
.switchlang_separator {
color: var(--color-text-tertiary);
opacity: 0.5;
}
&.track {
display: inline-flex;
align-items: center;
gap: var(--space-8);
/* The pill form owns its own scale, decoupled from the inline one. */
&.sm {
font-size: var(--text-14);
--switchlang-pill-height: var(--size-32);
}
&.md {
font-size: var(--text-16);
--switchlang-pill-height: var(--size-40);
}
&.lg {
font-size: var(--text-18);
--switchlang-pill-height: var(--size-44);
}
&.sm_base {
font-size: var(--text-14);
--switchlang-pill-height: var(--size-32);
}
&.md_base {
font-size: var(--text-16);
--switchlang-pill-height: var(--size-40);
}
&.lg_base {
font-size: var(--text-18);
--switchlang-pill-height: var(--size-44);
}
@media (min-width: 480px) {
&.sm_mobile {
font-size: var(--text-14);
--switchlang-pill-height: var(--size-32);
}
&.md_mobile {
font-size: var(--text-16);
--switchlang-pill-height: var(--size-40);
}
&.lg_mobile {
font-size: var(--text-18);
--switchlang-pill-height: var(--size-44);
}
}
@media (min-width: 768px) {
&.sm_tablet {
font-size: var(--text-14);
--switchlang-pill-height: var(--size-32);
}
&.md_tablet {
font-size: var(--text-16);
--switchlang-pill-height: var(--size-40);
}
&.lg_tablet {
font-size: var(--text-18);
--switchlang-pill-height: var(--size-44);
}
}
@media (min-width: 1024px) {
&.sm_desktop {
font-size: var(--text-14);
--switchlang-pill-height: var(--size-32);
}
&.md_desktop {
font-size: var(--text-16);
--switchlang-pill-height: var(--size-40);
}
&.lg_desktop {
font-size: var(--text-18);
--switchlang-pill-height: var(--size-44);
}
}
@media (min-width: 1440px) {
&.sm_wide {
font-size: var(--text-14);
--switchlang-pill-height: var(--size-32);
}
&.md_wide {
font-size: var(--text-16);
--switchlang-pill-height: var(--size-40);
}
&.lg_wide {
font-size: var(--text-18);
--switchlang-pill-height: var(--size-44);
}
}
.switchlang_icon {
flex-shrink: 0;
display: inline-flex;
align-items: center;
color: var(--color-text-secondary);
svg {
width: 1.4em;
height: 1.4em;
}
}
.switchlang_options {
--switchlang-pill-air: 3px;
position: relative;
isolation: isolate;
display: inline-flex;
align-items: stretch;
min-height: var(--switchlang-pill-height);
padding: var(--switchlang-pill-air);
border: var(--border-surface) solid var(--color-border-card);
border-radius: var(--radius-pill);
-webkit-tap-highlight-color: transparent;
&::before {
content: "";
position: absolute;
z-index: -1;
inset: var(--switchlang-pill-air) 50% var(--switchlang-pill-air) var(--switchlang-pill-air);
border-radius: var(--radius-pill);
background: color-mix(in srgb, var(--color-accent) 15%, transparent);
transition: scale 0.15s ease;
}
@media (prefers-reduced-motion: reduce) {
&::before {
transition: none;
}
}
&:has(.switchlang_option:last-child.active)::before {
translate: 100% 0;
}
&:has(.switchlang_option:active)::before {
scale: 0.95;
}
}
&.animated .switchlang_options::before {
transition:
translate 0.3s cubic-bezier(0.1, 0.9, 0.2, 1),
scale 0.15s ease;
}
@media (prefers-reduced-motion: reduce) {
&.animated .switchlang_options::before {
transition: none;
}
}
.switchlang_separator {
display: none;
}
.switchlang_option {
flex: 1;
min-width: 0;
height: auto;
padding-inline: var(--space-16);
border-radius: var(--radius-pill);
&.active {
font-weight: var(--weight-600);
}
}
}
}
</style>
Props
| Prop | Type | Required | Description |
|---|---|---|---|
| options | [SwitchLangOption, SwitchLangOption] | required | Exactly two languages ({ value, label, href }); with more, the right pattern is a dropdown. |
| current | string | required | Active language (marks its link with aria-current). |
| size | "sm" | "md" | "lg" | ResponsiveSize | required | Text size and the click box of each option (inline) or the track (track). Fixed or responsive. |
| variant | "inline" | "track" | required | Shape: text with a separator (header) or a pill that slides between the options of a track (drawer). |
| icon | React.ReactNode | — | Decorative icon before the options. |