Teseria
All elements

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

Terminal
npm i framer-motion

2. Files

Tabs.tsx128 lines
"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.css129 lines
/* 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;
}
tokens.cssshared · add once
/*
 * 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

Demo.tsx
import { Tabs } from "./Tabs";
import "./Tabs.css";

export default function Demo() {
  return <Tabs />;
}