← All components

Tabs

Panels behind a row of labels, with one rule or tile that slides to the chosen one.

1,284
Sessions this week
91%
Annual renewals

1,284 sessions, level with August.

The idea

The choice is drawn by one indicator that travels from the old tab to the new — an ink rule under the label, or a raised tile in a well — because a choice moving should look like one thing moving, not one mark vanishing while another appears. The list is one tab stop and the arrows choose as they go. Panels are stacked in one cell and crossfade, so the box holds the tallest and the page below never jumps; the hidden ones stay mounted for a crawler and are inert for everyone else.

Install

terminal1 line
npm i mihirmodi-ui motion

The tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.

Usage

Tabs — usage15 lines
import { Tabs } from "mihirmodi-ui";

<Tabs
  label="Project"
  value={tab}
  onChange={setTab}
  items={[
    { value: "overview", label: "Overview", content: <Overview /> },
    { value: "activity", label: "Activity", content: <Activity /> },
    { value: "billing", label: "Billing", content: <Billing />, disabled: true },
  ]}
/>

// Inside a panel, the tile-in-a-well version:
<Tabs variant="pill" label="Period" defaultValue="month" items={periods} />

Props

PropNotes
items{ value, label, content, disabled? }[]Every panel stays mounted, so its content is in the served HTML.
value / onChangestring / (v) => voidControlled. Or leave `value` out and give `defaultValue`.
variant"underline" | "pill"A 1.5px ink rule for a page section; a tile in a grey well inside a panel.
labelstringThe tab list's accessible name.
panelClassNamestringOn the box that holds the panels.

Source

Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.

packages/ui/src/components/navigation/Tabs.tsx202 lines
"use client";

import { useEffect, useId, useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { FLAT, SLIDE } from "../../lib/motion.js";

export type TabItem = {
  value: string;
  label: ReactNode;
  /** What the tab shows. Every panel stays mounted, so this is in the served HTML. */
  content: ReactNode;
  disabled?: boolean;
};

export type TabsProps = {
  items: TabItem[];
  /** Controlled. Leave it out and the tabs keep their own state, starting at `defaultValue`. */
  value?: string;
  defaultValue?: string;
  onChange?: (value: string) => void;
  /**
   * `underline` is a 1.5px ink rule under the label, for tabs that head a page
   * section. `pill` is a raised tile in a grey well, for tabs inside a panel.
   */
  variant?: "underline" | "pill";
  /** The tab list's accessible name. */
  label: string;
  className?: string;
  /** On the box that holds the panels — spacing above them, mostly. */
  panelClassName?: string;
};

/**
 * Tabs: a list of labels over a stack of panels, one shown at a time.
 *
 * The choice is drawn by ONE indicator that slides from the old tab to the new
 * — an ink rule under the label, or a raised tile in a well — because a choice
 * moving should look like one thing travelling, not one mark vanishing while
 * another appears somewhere else. The label's colour changes too, but only as
 * a second voice: the rule or the tile is what says which tab is chosen.
 *
 * It is the ARIA tabs pattern with automatic activation: the list is one tab
 * stop, the arrows move between tabs and choose as they go, Home and End jump
 * to the ends, and disabled tabs are stepped over. Panels are stacked in one
 * cell and crossfade, so the box holds the tallest of them and the page below
 * does not jump as you switch. The hidden ones stay mounted — their prose is
 * still in the HTML for a crawler — and are `inert`, so neither Tab nor a
 * screen reader wanders into a panel nobody can see.
 */
export function Tabs({
  items,
  value: valueProp,
  defaultValue,
  onChange,
  variant = "underline",
  label,
  className = "",
  panelClassName = "",
}: TabsProps) {
  const reduce = useReducedMotion();
  const id = useId();
  const [own, setOwn] = useState(defaultValue ?? items.find((t) => !t.disabled)?.value ?? "");
  const value = valueProp ?? own;
  const current = Math.max(0, items.findIndex((t) => t.value === value));
  const list = useRef<HTMLDivElement>(null);
  const tabs = useRef<(HTMLButtonElement | null)[]>([]);
  const [mark, setMark] = useState<{ x: number; w: number } | null>(null);

  const at = useRef(current);
  at.current = current;
  const measure = () => {
    const el = tabs.current[at.current];
    const next = el ? { x: el.offsetLeft, w: el.offsetWidth } : null;
    setMark((was) => (was && next && was.x === next.x && was.w === next.w ? was : next));
  };
  useLayoutEffect(measure, [current, items.length, variant]);
  // Labels change width when the web font lands, or when the list reflows.
  useEffect(() => {
    if (!list.current || typeof ResizeObserver === "undefined") return;
    const ro = new ResizeObserver(() => measure());
    ro.observe(list.current);
    tabs.current.forEach((t) => t && ro.observe(t));
    return () => ro.disconnect();
  }, [items.length]);

  const choose = (v: string) => {
    if (valueProp === undefined) setOwn(v);
    onChange?.(v);
  };

  const onKey = (e: KeyboardEvent, i: number) => {
    const n = items.length;
    let to = -1;
    const step = (dir: 1 | -1, from: number) => {
      for (let k = 1; k <= n; k++) {
        const j = (from + dir * k + n) % n;
        if (!items[j].disabled) return j;
      }
      return -1;
    };
    if (e.key === "ArrowRight") to = step(1, i);
    else if (e.key === "ArrowLeft") to = step(-1, i);
    else if (e.key === "Home") to = step(1, -1);
    else if (e.key === "End") to = step(-1, n);
    if (to < 0) return;
    e.preventDefault();
    tabs.current[to]?.focus();
    choose(items[to].value);
  };

  const pill = variant === "pill";
  const listClass = pill
    ? "relative inline-flex items-center rounded-[11px] bg-gray-alpha-100 p-[3px]"
    : "relative flex items-center gap-5 border-b-[0.5px] border-gray-alpha-400";
  const tabClass = pill ? "h-8 rounded-[8px] px-3" : "h-9";

  return (
    <LazyMotion features={domAnimation} strict>
      <div className={className}>
        <div ref={list} role="tablist" aria-label={label} aria-orientation="horizontal" className={listClass}>
          {mark &&
            (pill ? (
              <m.span
                aria-hidden
                className="absolute bottom-[3px] left-0 top-[3px] rounded-[8px] bg-highlight shadow-[0_0_0_0.5px_var(--color-gray-alpha-400),0_2px_2px_rgba(0,0,0,0.04)]"
                initial={false}
                animate={{ x: mark.x, width: mark.w }}
                transition={reduce ? FLAT : SLIDE}
              />
            ) : (
              <m.span
                aria-hidden
                // Sat over the list's hairline, so the chosen tab's rule replaces it rather than doubling it.
                className="absolute -bottom-[0.5px] left-0 h-[1.5px] rounded-full bg-ink"
                initial={false}
                animate={{ x: mark.x, width: mark.w }}
                transition={reduce ? FLAT : SLIDE}
              />
            ))}
          {items.map((t, i) => {
            const on = i === current;
            return (
              <button
                key={t.value}
                ref={(el) => {
                  tabs.current[i] = el;
                }}
                type="button"
                role="tab"
                id={`${id}-tab-${i}`}
                aria-selected={on}
                aria-controls={`${id}-panel-${i}`}
                disabled={t.disabled}
                tabIndex={on ? 0 : -1}
                onClick={() => choose(t.value)}
                onKeyDown={(e) => onKey(e, i)}
                className={`relative inline-flex items-center justify-center gap-1.5 whitespace-nowrap text-[13px] font-medium leading-none transition-colors duration-150 ease-out disabled:opacity-40 ${tabClass} ${
                  on ? "text-gray-1000" : "text-quiet hover:text-gray-1000"
                }`}
              >
                {/* Until the list is measured (on the server, or with no script), the chosen tab carries its own mark. */}
                {on && !mark && (
                  <span
                    aria-hidden
                    className={
                      pill
                        ? "absolute inset-0 rounded-[8px] bg-highlight shadow-[0_0_0_0.5px_var(--color-gray-alpha-400)]"
                        : "absolute inset-x-0 -bottom-[0.5px] h-[1.5px] rounded-full bg-ink"
                    }
                  />
                )}
                <span className="relative inline-flex items-center gap-1.5">{t.label}</span>
              </button>
            );
          })}
        </div>
        <div className={`grid ${pill ? "pt-4" : "pt-5"} ${panelClassName}`}>
          {items.map((t, i) => {
            const on = i === current;
            return (
              <div
                key={t.value}
                role="tabpanel"
                id={`${id}-panel-${i}`}
                aria-labelledby={`${id}-tab-${i}`}
                tabIndex={on ? 0 : -1}
                inert={!on}
                // Visibility goes with the fade: it flips at the end on the way
                // out and at once on the way in, so both are seen crossing.
                className={`[grid-area:1/1] transition-[opacity,visibility] duration-200 ease-out motion-reduce:transition-none ${
                  on ? "visible opacity-100" : "invisible opacity-0"
                }`}
              >
                {t.content}
              </div>
            );
          })}
        </div>
      </div>
    </LazyMotion>
  );
}
packages/ui/src/lib/motion.ts36 lines
/**
 * The motion presets the newer components share. The older ones (Dock,
 * DropMenu, HighlightList) carry their own copies of the same numbers, which is
 * deliberate: each of them documents why its curve is what it is, in place.
 */

/** One slide for anything that re-targets its position. */
export const SLIDE = { type: "spring", bounce: 0.2, duration: 0.4 } as const;

/** Something arriving that should land with a small settle. */
export const LIFT = { type: "spring", bounce: 0.28, duration: 0.5 } as const;

/** The disagreeing pair behind squash-and-stretch: see DropMenu. */
export const GROW = { type: "spring", stiffness: 620, damping: 19, mass: 0.85 } as const;
export const CROSS = { type: "spring", stiffness: 380, damping: 30 } as const;

/** Closing is quick and flat. A bounce on the way out reads as indecision. */
export const CLOSE = { duration: 0.13, ease: "easeIn" } as const;

/** Under reduced motion: a short plain fade, nothing that travels. */
export const FLAT = { duration: 0.15 } as const;

/** Expo-out, for anything that also drives a blur — it must not overshoot. */
export const EASE_OUT_EXPO = [0.16, 1, 0.3, 1] as const;

/** The house curve: overshoots a touch, for transforms only. */
export const EASE_GEIST = [0.175, 0.885, 0.32, 1.1] as const;

/** A block entering a list: fade and 4px. Opacity never springs. */
export function enter(reduce: boolean | null) {
  return {
    initial: reduce ? { opacity: 0 } : { opacity: 0, y: 4 },
    animate: { opacity: 1, y: 0 },
    transition: reduce ? FLAT : { duration: 0.2, ease: EASE_GEIST },
  };
}