## Tabs — Navigation One indicator shared across tabs. Docs: https://www.interior.dev/docs/tabs Reference: https://www.interior.dev/reference/tabs License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/tabs.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx "use client"; import { useState } from "react"; import { Tabs } from "@/components/interior/tabs"; const sections = [ { value: "readme", label: "Readme" }, { value: "issues", label: "Issues" }, { value: "insights", label: "Insights", disabled: true }, ]; export function RepoHeader() { const [section, setSection] = useState("readme"); return ( value === "readme" ? : } /> ); } ``` ### Props - `items` (`TabItem[]`) Each tab is { value, label, disabled? }. Labels are strings so the row can reserve their selected width. - `value` (`string`) Controlled selection. Omit it and the component keeps its own. - `defaultValue` (`string`) — default: `first enabled item` Uncontrolled starting tab. - `onValueChange` (`(value: string) => void`) Fires on click, on Enter or Space, and on arrow keys when activation is automatic. - `activation` (`"automatic" | "manual"`) — default: `"automatic"` Manual separates focus from selection: arrows move focus, Enter or Space commits. Use it when a panel is expensive. - `variant` (`"underline" | "segmented"`) — default: `"underline"` Two dressings of the same single indicator: a 2px bar on the hairline, or a lifted block inside a track. - `renderPanel` (`(value: string) => React.ReactNode`) Called only for the selected tab. Omit it to render the tab row alone and wire panels yourself. - `label` (`string`) — default: `"Tabs"` aria-label on the tablist. Name the thing being sectioned, not the widget. - `panelClassName` (`string`) — default: `""` Applied to the panel. Reserve a min height here when panel contents differ in length. - `className` (`string`) — default: `""` Appended last on the root so callers win. ### Behavior notes - The indicator is a single element carried between tabs by a shared layout id, so a fast switch cannot leave two bars crossfading past each other or restart a bar from zero width, and interrupting the move resumes the spring from where the bar currently is. - The selected label changes weight without changing geometry: an invisible copy at the target weight holds the cell width, so the row never reflows on selection and the indicator never lands on stale measurements. - Arrow keys walk the row, Home and End jump to the ends, and disabled tabs are stepped over rather than focused; only the selected tab sits in the page tab order, so Tab leaves the row instead of visiting every item in it. - Only the selected panel is mounted, wired with role tabpanel and aria-labelledby, so a screen reader is handed one section of content rather than all of them stacked. - Under prefers-reduced-motion the indicator's travel and the panel's 3px slide collapse to zero duration; the bar still ends up under the right label and the panel still appears. - Selection is optional state: pass value and onValueChange to own it, or defaultValue to let the component own it, and take useTabs alone when you want the roving focus, ids and ARIA wiring under your own chrome. ### Source (`components/interior/tabs.tsx`) ```tsx "use client"; import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react"; import type { KeyboardEvent, ReactNode } from "react"; import { motion, useReducedMotion } from "motion/react"; const INDICATOR = { type: "spring", stiffness: 620, damping: 42, mass: 0.35 } as const; const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; const PANEL = { type: "spring", stiffness: 460, damping: 38, mass: 0.8 } as const; export type TabItem = { value: string; label: string; disabled?: boolean; }; export type TabsActivation = "automatic" | "manual"; export type UseTabsOptions = { items: TabItem[]; value?: string; defaultValue?: string; onValueChange?: (value: string) => void; activation?: TabsActivation; }; export function useTabs({ items, value: controlled, defaultValue, onValueChange, activation = "automatic", }: UseTabsOptions) { const base = useId(); const nodes = useRef(new Map()); const direction = useRef(1); const [internal, setInternal] = useState( () => defaultValue ?? items.find((i) => !i.disabled)?.value ?? items[0]?.value ?? "", ); const value = controlled ?? internal; const emit = useRef(onValueChange); emit.current = onValueChange; const select = useCallback( (next: string) => { if (next === value) return; const from = items.findIndex((i) => i.value === value); const to = items.findIndex((i) => i.value === next); direction.current = to < from ? -1 : 1; if (controlled === undefined) setInternal(next); emit.current?.(next); }, [controlled, items, value], ); const focusAt = useCallback( (i: number) => { const item = items[i]; if (!item) return; nodes.current.get(item.value)?.focus(); }, [items], ); const nextEnabled = useCallback( (from: number, dir: number) => { const n = items.length; let i = from < 0 ? 0 : from; for (let k = 0; k < n; k += 1) { i = (i + dir + n) % n; if (!items[i].disabled) return i; } return from; }, [items], ); const endStop = useCallback( (dir: number) => { const n = items.length; if (dir > 0) { for (let i = 0; i < n; i += 1) if (!items[i].disabled) return i; } else { for (let i = n - 1; i >= 0; i -= 1) if (!items[i].disabled) return i; } return 0; }, [items], ); const getTabProps = useCallback( (item: TabItem, index: number) => ({ id: `${base}-tab-${item.value}`, role: "tab" as const, type: "button" as const, "aria-selected": item.value === value, "aria-controls": `${base}-panel-${item.value}`, "aria-disabled": item.disabled ? (true as const) : undefined, tabIndex: item.value === value ? 0 : -1, ref: (node: HTMLButtonElement | null) => { if (node) nodes.current.set(item.value, node); else nodes.current.delete(item.value); }, onClick: () => { if (!item.disabled) select(item.value); }, onKeyDown: (e: KeyboardEvent) => { if (e.key === "ArrowRight" || e.key === "ArrowLeft") { e.preventDefault(); const to = nextEnabled(index, e.key === "ArrowRight" ? 1 : -1); focusAt(to); if (activation === "automatic") select(items[to].value); return; } if (e.key === "Home" || e.key === "End") { e.preventDefault(); const to = endStop(e.key === "Home" ? 1 : -1); focusAt(to); if (activation === "automatic") select(items[to].value); return; } if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (!item.disabled) select(item.value); } }, }), [activation, base, endStop, focusAt, items, nextEnabled, select, value], ); const getPanelProps = useCallback( (panelValue: string) => ({ id: `${base}-panel-${panelValue}`, role: "tabpanel" as const, "aria-labelledby": `${base}-tab-${panelValue}`, tabIndex: 0, }), [base], ); const tabListProps = { role: "tablist" as const, "aria-orientation": "horizontal" as const, }; return { value, select, direction: direction.current, tabListProps, getTabProps, getPanelProps, }; } export type UseTabsReturn = ReturnType; export type TabsProps = { items: TabItem[]; value?: string; defaultValue?: string; onValueChange?: (value: string) => void; activation?: TabsActivation; renderPanel?: (value: string) => ReactNode; label?: string; panelClassName?: string; className?: string; }; export function Tabs({ items, value, defaultValue, onValueChange, activation = "automatic", renderPanel, label = "Tabs", panelClassName = "", className = "", }: TabsProps) { const tabs = useTabs({ items, value, defaultValue, onValueChange, activation }); const reduced = useReducedMotion(); const rowRef = useRef(null); const tabRefs = useRef<(HTMLButtonElement | null)[]>([]); const [plateau, setPlateau] = useState({ x: 0, width: 0, ready: false }); const selectedIndex = items.findIndex((item) => item.value === tabs.value); useIsoLayoutEffect(() => { const node = tabRefs.current[selectedIndex]; if (!node) return; const read = () => { setPlateau((prev) => prev.x === node.offsetLeft && prev.width === node.offsetWidth && prev.ready ? prev : { x: node.offsetLeft, width: node.offsetWidth, ready: true }, ); }; read(); const row = rowRef.current; if (!row) return; const observer = new ResizeObserver(read); observer.observe(row); return () => observer.disconnect(); }, [selectedIndex, items]); return (
{items.map((item, index) => { const selected = item.value === tabs.value; return ( ); })}
{renderPanel ? ( {renderPanel(tabs.value)} ) : null}
); } ```