A tab list whose active indicator springs between tabs as a sliding underline or pill, with panels that slide or fade in the direction of travel. Supports arrow-key, Home and End navigation and both controlled and uncontrolled use.
Preview
Account
Manage your public profile and how others see you.
Customize
7 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add tabsWrites components/ui/tabs.tsx and components/styles/tabs.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { Tabs } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <Tabs />6 );7}Props
Accessibility
Implements the WAI-ARIA Tabs pattern: role=tablist with aria-label, role=tab buttons with aria-selected and aria-controls, and role=tabpanel regions labelled by their tab. Roving tabindex keeps a single Tab stop; Left/Right arrows move between tabs (wrapping), Home/End jump to the ends, disabled tabs are skipped. activationMode 'automatic' selects on focus, 'manual' selects with Enter/Space. The panel is focusable so Tab moves straight into its content. Under prefers-reduced-motion the indicator jumps and panels only cross-fade.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import { AnimatePresence, motion, useReducedMotion } from "motion/react";5import styles from "../styles/tabs.module.css";6 7export interface TabItem {8 /** Unique value of the tab */9 value: string;10 /** Visible label */11 label: string;12 /** Panel content */13 content?: React.ReactNode;14 /** Disable this tab */15 disabled?: boolean;16}17 18export interface TabsProps {19 /** Tabs to render; defaults to an account settings demo */20 items?: TabItem[];21 /** Controlled selected value */22 value?: string;23 /** Initial selected value when uncontrolled (defaults to the first tab) */24 defaultValue?: string;25 /** Called when the selected tab changes */26 onValueChange?: (value: string) => void;27 /** Indicator style: a sliding underline or a sliding pill */28 variant?: "underline" | "pill";29 /** Size preset */30 size?: "sm" | "md" | "lg";31 /** Accent color of the indicator and focus highlights */32 accentColor?: string;33 /** "automatic" selects on arrow focus, "manual" requires Enter/Space */34 activationMode?: "automatic" | "manual";35 /** Panel transition */36 transition?: "slide" | "fade" | "none";37 /** Stretch tabs to fill the full width */38 fullWidth?: boolean;39 /** Accessible label for the tab list */40 ariaLabel?: string;41 /** Optional className merged onto the root */42 className?: string;43 /** Optional inline styles merged onto the root */44 style?: React.CSSProperties;45}46 47const useIsoLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;48 49function Row({ k, v }: { k: string; v: string }) {50 return (51 <div className={styles.row}>52 <span className={styles.rowKey}>{k}</span>53 <span className={styles.rowVal}>{v}</span>54 </div>55 );56}57 58const DEFAULT_ITEMS: TabItem[] = [59 {60 value: "account",61 label: "Account",62 content: (63 <>64 <h3 className={styles.panelTitle}>Account</h3>65 <p className={styles.panelText}>Manage your public profile and how others see you.</p>66 <Row k="Display name" v="Ada Lovelace" />67 <Row k="Username" v="@ada" />68 </>69 ),70 },71 {72 value: "security",73 label: "Security",74 content: (75 <>76 <h3 className={styles.panelTitle}>Security</h3>77 <p className={styles.panelText}>Keep your account safe with a strong password and two-factor auth.</p>78 <Row k="Password" v="Updated 3 weeks ago" />79 <Row k="Two-factor" v="Enabled" />80 </>81 ),82 },83 {84 value: "notifications",85 label: "Notifications",86 content: (87 <>88 <h3 className={styles.panelTitle}>Notifications</h3>89 <p className={styles.panelText}>Choose what we email you about and how often.</p>90 <Row k="Product updates" v="Weekly digest" />91 <Row k="Mentions" v="Instantly" />92 </>93 ),94 },95 {96 value: "billing",97 label: "Billing",98 content: (99 <>100 <h3 className={styles.panelTitle}>Billing</h3>101 <p className={styles.panelText}>You are on the Pro plan, renewing on Nov 1.</p>102 <Row k="Plan" v="Pro, $12 / month" />103 <Row k="Payment" v="Visa ending 4242" />104 </>105 ),106 },107];108 109export function Tabs({110 items = DEFAULT_ITEMS,111 value: valueProp,112 defaultValue,113 onValueChange,114 variant = "underline",115 size = "md",116 accentColor = "#6366f1",117 activationMode = "automatic",118 transition = "slide",119 fullWidth = false,120 ariaLabel = "Account settings",121 className = "",122 style,123}: TabsProps) {124 const reduce = useReducedMotion();125 const firstEnabled = items.find((t) => !t.disabled)?.value ?? "";126 const [inner, setInner] = React.useState(defaultValue ?? firstEnabled);127 const controlled = valueProp !== undefined;128 const value = controlled ? valueProp : inner;129 const baseId = React.useId();130 const listRef = React.useRef<HTMLDivElement>(null);131 const tabRefs = React.useRef<Array<HTMLButtonElement | null>>([]);132 const [rect, setRect] = React.useState<{ x: number; w: number } | null>(null);133 const activeIndex = Math.max(0, items.findIndex((t) => t.value === value));134 const prevIndex = React.useRef(activeIndex);135 const dir = activeIndex >= prevIndex.current ? 1 : -1;136 137 React.useEffect(() => {138 prevIndex.current = activeIndex;139 }, [activeIndex]);140 141 const select = (v: string) => {142 if (v === value) return;143 if (!controlled) setInner(v);144 onValueChange?.(v);145 };146 147 const measure = React.useCallback(() => {148 const el = tabRefs.current[activeIndex];149 if (el) setRect({ x: el.offsetLeft, w: el.offsetWidth });150 }, [activeIndex]);151 152 useIsoLayoutEffect(() => {153 measure();154 }, [measure, variant, size, fullWidth]);155 156 React.useEffect(() => {157 const list = listRef.current;158 if (!list || typeof ResizeObserver === "undefined") return;159 const ro = new ResizeObserver(() => measure());160 ro.observe(list);161 return () => ro.disconnect();162 }, [measure]);163 164 const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {165 const enabled = items.map((t, i) => (t.disabled ? -1 : i)).filter((i) => i >= 0);166 const current = tabRefs.current.findIndex((el) => el === document.activeElement);167 if (current < 0 || enabled.length === 0) return;168 const pos = enabled.indexOf(current);169 let next = -1;170 if (e.key === "ArrowRight") next = enabled[(pos + 1) % enabled.length];171 else if (e.key === "ArrowLeft") next = enabled[(pos - 1 + enabled.length) % enabled.length];172 else if (e.key === "Home") next = enabled[0];173 else if (e.key === "End") next = enabled[enabled.length - 1];174 if (next < 0) return;175 e.preventDefault();176 tabRefs.current[next]?.focus();177 if (activationMode === "automatic") select(items[next].value);178 };179 180 const active = items[activeIndex];181 const offset = reduce || transition !== "slide" ? 0 : 20;182 const dur = transition === "none" ? 0 : reduce ? 0.1 : 0.22;183 184 return (185 <div186 className={`${styles.root} ${className}`.trim()}187 data-variant={variant}188 data-size={size}189 data-full={fullWidth ? "" : undefined}190 style={{ "--mf-tabs-accent": accentColor, ...style } as React.CSSProperties}191 >192 <div ref={listRef} role="tablist" aria-label={ariaLabel} aria-orientation="horizontal" className={styles.list} onKeyDown={onKeyDown}>193 {rect && (194 <motion.span195 aria-hidden="true"196 className={styles.indicator}197 initial={false}198 animate={{ x: rect.x, width: rect.w }}199 transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 36 }}200 />201 )}202 {items.map((t, i) => {203 const selected = i === activeIndex;204 return (205 <button206 key={t.value}207 ref={(el) => {208 tabRefs.current[i] = el;209 }}210 type="button"211 role="tab"212 id={`${baseId}-tab-${i}`}213 aria-selected={selected}214 aria-controls={`${baseId}-panel-${i}`}215 tabIndex={selected ? 0 : -1}216 disabled={t.disabled}217 data-selected={selected ? "" : undefined}218 className={styles.tab}219 onClick={() => select(t.value)}220 >221 {t.label}222 </button>223 );224 })}225 </div>226 <div className={styles.panels}>227 <AnimatePresence mode="wait" initial={false} custom={dir}>228 {active && (229 <motion.div230 key={active.value}231 role="tabpanel"232 id={`${baseId}-panel-${activeIndex}`}233 aria-labelledby={`${baseId}-tab-${activeIndex}`}234 tabIndex={0}235 className={styles.panel}236 initial={{ opacity: 0, x: dir * offset }}237 animate={{ opacity: 1, x: 0 }}238 exit={{ opacity: 0, x: -dir * offset }}239 transition={{ duration: dur, ease: [0.16, 1, 0.3, 1] }}240 >241 {active.content}242 </motion.div>243 )}244 </AnimatePresence>245 </div>246 </div>247 );248}