Tabs
A line or pill tab list with a shared indicator that glides between tabs (layoutId), and optional panels that crossfade out of a blur. All panels share one grid cell, so the block is as tall as its tallest panel and never resizes on a switch.
Code
1. Install
npm i framer-motion
2. Files
"use client";
// Tabs: a line or pill tab list with a shared indicator that glides between tabs (layoutId), and optional panels that
// crossfade out of a blur. All panels share one grid cell, so the block is as tall as its tallest panel and never
// resizes on a switch. Pill tabs with an icon show their label only while selected. Arrow keys, Home and End walk the list.
import { createContext, useContext, useId, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { AnimatePresence, MotionConfig, motion } from "framer-motion";
export type TabsCue = "choose";
export type TabItem = {
/** Unique id of the tab. */
value: string;
/** Text on the tab. Optional on a pill tab that only has an icon. */
label?: ReactNode;
/** Icon before the label, about 18px. */
icon?: ReactNode;
/** Content shown while this tab is selected. Leave every panel out for a tab list on its own. */
panel?: ReactNode;
disabled?: boolean;
};
export type TabsProps = {
/** The tabs, in order. */
tabs?: TabItem[];
/** Selected tab (controlled). */
value?: string;
/** Initially selected tab when uncontrolled. Defaults to the first tab. */
defaultValue?: string;
/** Called with the new tab's value. */
onChange?: (value: string) => void;
/** "line" underlines the selected tab; "pill" puts it on a raised pill. */
variant?: "line" | "pill";
/** Accessible name of the tab list. */
label?: string;
/** Sound hook: "choose" (pitched by `at`) when the selection changes. */
onSound?: (cue: TabsCue, opts?: { at?: number }) => void;
className?: string;
style?: CSSProperties;
};
export const DEFAULT_TABS: TabItem[] = [
{ value: "overview", label: "Overview", panel: "Revenue is up 8.2% this week. Churn stayed under 3%." },
{ value: "orders", label: "Orders", panel: "42 orders. 29 shipped, 13 packing. Best day was Tuesday with +$1,120 across prints and posters." },
{ value: "invoices", label: "Invoices", panel: "Next invoice run starts Friday." },
];
const EASE = [0.22, 1, 0.36, 1] as const;
const EXIT = [0.4, 0, 1, 1] as const;
const SNAP = { type: "spring", stiffness: 380, damping: 34, mass: 0.8 } as const;
const SHOW = { opacity: 1, scale: 1, filter: "blur(0px)", visibility: "visible" as const, transitionEnd: { filter: "none" }, transition: { duration: 0.2, delay: 0.08, ease: EASE } };
const HIDE = { opacity: 0, scale: 0.98, filter: "blur(4px)", transition: { duration: 0.14, ease: EXIT }, transitionEnd: { visibility: "hidden" as const } };
type Ctx = { id: string; variant: "line" | "pill" };
const C = createContext<Ctx>({ id: "", variant: "line" });
export function Tabs({ tabs = DEFAULT_TABS, value, defaultValue, onChange, variant = "line", label = "Account", onSound, className, style }: TabsProps) {
const id = useId();
const list = useRef<HTMLDivElement>(null);
const [inner, setInner] = useState(defaultValue);
const want = value ?? inner;
const cur = tabs.some((t) => t.value === want) ? want : tabs[0]?.value; // a missing id falls back to the first tab
const set = (v: string) => {
if (v === cur) return;
onSound?.("choose", { at: tabs.findIndex((t) => t.value === v) / Math.max(1, tabs.length - 1) });
if (value === undefined) setInner(v);
onChange?.(v);
};
const walk = (e: React.KeyboardEvent) => {
const els = [...(list.current?.querySelectorAll<HTMLButtonElement>('[role="tab"]:not(:disabled)') ?? [])];
const i = els.indexOf(document.activeElement as HTMLButtonElement);
if (i < 0) return;
const d = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : e.key === "Home" ? -i : e.key === "End" ? els.length - 1 - i : 0;
if (!d) return;
e.preventDefault();
const t = els[(i + d + els.length) % els.length];
t.focus();
t.click();
};
return (
<MotionConfig reducedMotion="user">
<C.Provider value={{ id, variant }}>
<div className={className ? `tbs-root ${className}` : "tbs-root"} data-variant={variant} style={style}>
<div ref={list} role="tablist" aria-label={label} className="tbs-list" onKeyDown={walk}>
{tabs.map((t) => (
<Tab key={t.value} item={t} on={t.value === cur} onPick={() => set(t.value)} />
))}
</div>
{tabs.some((t) => t.panel !== undefined) && (
<div className="tbs-panels">
{tabs.map((t) => (
<motion.div key={t.value} role="tabpanel" id={`${id}-p-${t.value}`} aria-labelledby={`${id}-t-${t.value}`} className="tbs-panel" inert={t.value !== cur} initial={false} animate={t.value === cur ? SHOW : HIDE}>
{t.panel}
</motion.div>
))}
</div>
)}
</div>
</C.Provider>
</MotionConfig>
);
}
function Tab({ item, on, onPick }: { item: TabItem; on: boolean; onPick: () => void }) {
const { id, variant } = useContext(C);
const { value, icon, label, disabled } = item;
return (
<button type="button" role="tab" id={`${id}-t-${value}`} aria-selected={on} aria-label={typeof label === "string" ? label : undefined} aria-controls={`${id}-p-${value}`} tabIndex={on ? 0 : -1} disabled={disabled} className="tbs-tab" data-on={on || undefined} onClick={onPick}>
{on && <motion.span className="tbs-ind" layoutId={`${id}-ind`} transition={SNAP} />}
{icon && <span className="tbs-ico">{icon}</span>}
{(label || variant === "line") && (
<span className="tbs-label">
{variant === "pill" && icon ? (
<AnimatePresence initial={false}>
{on && (
<motion.span key="l" className="tbs-label-in" initial={{ width: 0, opacity: 0, marginLeft: 0 }} animate={{ width: "auto", opacity: 1, marginLeft: 6 }} exit={{ width: 0, opacity: 0, marginLeft: 0 }} transition={{ width: SNAP, opacity: { duration: 0.16, delay: on ? 0.08 : 0 } }}>
{label}
</motion.span>
)}
</AnimatePresence>
) : (
label
)}
</span>
)}
</button>
);
}
/* Tabs. Prefix tbs-. */
.tbs-root,
.tbs-root * {
box-sizing: border-box;
}
:where(.tbs-root) button {
font: inherit;
color: inherit;
background: none;
border: 0;
padding: 0;
margin: 0;
}
.tbs-root {
--tbs-ease: cubic-bezier(0.22, 1, 0.36, 1);
--tbs-pill-bg: var(--ts-raised);
display: flex;
flex-direction: column;
gap: 14px;
font-family: var(--ts-font, system-ui, sans-serif);
}
[data-theme="dark"] .tbs-root {
--tbs-pill-bg: rgba(var(--ts-fg-rgb), 0.1);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .tbs-root {
--tbs-pill-bg: rgba(var(--ts-fg-rgb), 0.1);
}
}
.tbs-list {
display: flex;
gap: 4px;
position: relative;
}
.tbs-root[data-variant="line"] .tbs-list {
gap: 18px;
box-shadow: inset 0 -1px 0 var(--ts-border);
}
.tbs-root[data-variant="pill"] .tbs-list {
padding: 4px;
gap: 2px;
border-radius: 999px;
background: rgba(var(--ts-fg-rgb), 0.06);
align-self: flex-start;
}
.tbs-tab {
position: relative;
display: inline-flex;
align-items: center;
color: var(--ts-fg-muted);
font-size: 14px;
font-weight: 500;
letter-spacing: -0.01em;
cursor: pointer;
white-space: nowrap;
-webkit-tap-highlight-color: transparent;
transition: color 0.16s var(--tbs-ease), scale 0.22s var(--tbs-ease);
}
.tbs-tab:hover:not([data-on]) {
color: var(--ts-fg-secondary);
}
.tbs-tab[data-on] {
color: var(--ts-fg);
}
.tbs-tab:disabled {
opacity: 0.4;
cursor: default;
}
.tbs-tab:focus-visible {
outline: 0;
box-shadow: 0 0 0 2px var(--ts-raised), 0 0 0 4px var(--ts-accent);
border-radius: 999px;
}
.tbs-root[data-variant="line"] .tbs-tab {
height: 36px;
padding: 0 2px;
}
.tbs-root[data-variant="line"] .tbs-ind {
position: absolute;
left: 0;
right: 0;
bottom: -1px;
height: 2px;
border-radius: 1px;
background: var(--ts-fg);
}
.tbs-root[data-variant="pill"] .tbs-tab {
height: 36px;
min-width: 36px;
padding: 0 11px;
border-radius: 999px;
}
.tbs-root[data-variant="pill"] .tbs-tab:active {
scale: 0.96;
}
.tbs-root[data-variant="pill"] .tbs-ind {
position: absolute;
inset: 0;
border-radius: 999px;
background: var(--tbs-pill-bg);
box-shadow: var(--ts-edge);
}
.tbs-ico,
.tbs-label {
position: relative;
display: inline-flex;
align-items: center;
}
.tbs-ico svg {
width: 18px;
height: 18px;
}
.tbs-label-in {
display: inline-block;
overflow: hidden;
white-space: nowrap;
}
.tbs-panels {
position: relative;
display: grid;
}
.tbs-panels > .tbs-panel {
grid-area: 1 / 1;
}
.tbs-panel {
font-size: 14px;
color: var(--ts-fg-secondary);
line-height: 1.55;
}
/*
* Teseria tokens — the only colours, fonts and corner radii a block reads.
*
* Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
* the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
* any ancestor, or the OS setting unless [data-theme="light"] is set.
*
* Surfaces page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
* Text fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
* Lines border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
* dark; composes with other shadows)
* Signals accent · focus · success · warning · danger
* Corners controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
* (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
*/
:root {
--ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ts-r-panel: 24px;
--ts-r-pop: 18px;
--ts-r-inner: 12px;
--ts-page: #ffffff;
--ts-raised: #ffffff;
--ts-sunken: #f3f2f0;
--ts-tint-1: #f5f4f2;
--ts-tint-2: #ecebe8;
--ts-tint-3: #e3e1dd;
--ts-fg: #161514;
--ts-fg-rgb: 22, 21, 20;
--ts-fg-secondary: #3a3836;
--ts-fg-tertiary: #585552;
--ts-fg-muted: #6d6a66;
--ts-fg-faint: #89857f;
--ts-fg-inverse: #ffffff;
--ts-border: rgba(22, 21, 20, 0.1);
--ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);
--ts-accent: #3446eb;
--ts-focus: #3446eb;
--ts-success: #13804a;
--ts-warning: #a9650b;
--ts-danger: #df474e;
}
:root[data-theme="dark"] {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
}
3. Usage
import { Tabs } from "./Tabs";
import "./Tabs.css";
export default function Demo() {
return <Tabs />;
}