UINavigationmotion/react

Tabs

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.

Display nameAda Lovelace
Username@ada

Customize

7 props

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add tabs

Writes 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.

example.tsx
1import { Tabs } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <Tabs />6  );7}

Props

PropTypeDefault
items

Tabs to render with their panel content.

{ value: string; label: string; content?: React.ReactNode; disabled?: boolean }[]account settings demo
value

Controlled selected tab value.

stringundefined
defaultValue

Initial selected tab when uncontrolled.

stringfirst enabled tab
onValueChange

Called when the selected tab changes.

(value: string) => voidundefined
variant

Indicator style: a sliding underline or a sliding filled pill.

'underline' | 'pill''underline'
size

Tab height and text size preset.

'sm' | 'md' | 'lg''md'
accentColor

Color of the indicator.

string'#6366f1'
activationMode

Whether arrow-key focus selects the tab immediately or Enter/Space is required.

'automatic' | 'manual''automatic'
transition

How panels animate when the selection changes.

'slide' | 'fade' | 'none''slide'
fullWidth

Stretch tabs to share the full width.

booleanfalse
ariaLabel

Accessible label of the tab list.

string'Account settings'
className

Class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

components/ui/tabs.tsx
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}