{"slug":"tabs","name":"Tabs","description":"Pill, segment or underline tabs with a spring layoutId indicator.","category":"motion","source_url":"https://beui.dev/r/tabs/raw","detail_url":"https://beui.dev/r/tabs","raw_url":"https://beui.dev/r/tabs/raw","page_url":"https://beui.dev/components/motion/tabs","markdown_url":"https://beui.dev/components/motion/tabs.md","published_at":"2026-05-17","updated_at":"2026-09-18","dependencies":["clsx","lucide-react","motion","react","tailwind-merge"],"internal":["@/components/motion/tabs","@/lib/ease","@/lib/utils"],"files":[{"path":"components/motion/tabs.tsx","type":"component","content":"\"use client\";\n// beui.dev/components/motion/tabs\n\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport { cancelFrame, frame, motion, MotionConfig, useReducedMotion, type Transition } from \"motion/react\";\nimport {\n  createContext,\n  useCallback,\n  useContext,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useMemo,\n  useState,\n  type ReactNode,\n} from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\ntype Variant = \"pill\" | \"underline\" | \"segment\";\n\ntype Ctx = {\n  value: string;\n  setValue: (v: string) => void;\n  layoutId: string;\n  variant: Variant;\n};\n\nconst TabsCtx = createContext<Ctx | null>(null);\n\nfunction useTabs() {\n  const ctx = useContext(TabsCtx);\n  if (!ctx) throw new Error(\"Tabs.* must be used inside <Tabs>\");\n  return ctx;\n}\n\n// Settle without overshoot: a scrollable tab list would turn even a small\n// overshoot into a transient scrollbar and layout shift.\n// Scale stiffness and damping together for a quicker glide with the same feel.\nconst transition: Transition = {\n  type: \"spring\",\n  stiffness: 245,\n  damping: 36,\n  mass: 1.2,\n};\n\nexport function Tabs({\n  defaultValue,\n  value,\n  onValueChange,\n  variant = \"pill\",\n  children,\n  className,\n}: {\n  defaultValue?: string;\n  value?: string;\n  onValueChange?: (v: string) => void;\n  variant?: Variant;\n  children: ReactNode;\n  className?: string;\n}) {\n  const [internal, setInternal] = useState(defaultValue ?? \"\");\n  const layoutId = useId();\n  const reduce = useReducedMotion();\n  const controlled = value !== undefined;\n  const current = controlled ? value : internal;\n  const setValue = useCallback(\n    (v: string) => {\n      if (!controlled) setInternal(v);\n      onValueChange?.(v);\n    },\n    [controlled, onValueChange],\n  );\n  const contextValue = useMemo(\n    () => ({ value: current, setValue, layoutId, variant }),\n    [current, layoutId, setValue, variant],\n  );\n  return (\n    <MotionConfig transition={reduce ? { duration: 0 } : transition}>\n      <TabsCtx.Provider value={contextValue}>\n        {/* layoutRoot: the indicator's layoutId measures in page coordinates, so\n            inside fixed/scrolled containers it would replay scroll offsets as\n            movement. The pill only ever travels within the list, so scoping\n            projection to the Tabs wrapper is always correct. */}\n        <motion.div layoutRoot className={className}>\n          {children}\n        </motion.div>\n      </TabsCtx.Provider>\n    </MotionConfig>\n  );\n}\n\nconst listClasses: Record<Variant, string> = {\n  pill: \"inline-flex items-center gap-1 rounded-full bg-card p-1\",\n  underline: \"inline-flex items-center gap-1 border-b border-border\",\n  segment: \"inline-flex items-center gap-0 rounded-lg bg-card p-0.5\",\n};\n\nexport function TabsList({\n  children,\n  className,\n  wrapperClassName,\n}: {\n  children: ReactNode;\n  className?: string;\n  wrapperClassName?: string;\n}) {\n  const { variant, value } = useTabs();\n  const reduce = useReducedMotion();\n  const rootRef = useRef<HTMLDivElement>(null);\n  const viewportRef = useRef<HTMLDivElement>(null);\n  const listRef = useRef<HTMLDivElement>(null);\n  const viewportId = useId();\n  const [edges, setEdges] = useState({ overflow: false, left: false, right: false });\n\n  const measure = useCallback(() => {\n    const root = rootRef.current;\n    const viewport = viewportRef.current;\n    if (!root || !viewport) return;\n    // Overlay controls do not reduce the viewport or change its scroll range.\n    const overflow = viewport.scrollWidth > root.clientWidth + 1;\n    const max = Math.max(0, viewport.scrollWidth - viewport.clientWidth);\n    const rtl = getComputedStyle(viewport).direction === \"rtl\";\n    // Modern browsers expose negative scrollLeft in RTL. Clamp rubber-banding.\n    const fromLeft = Math.max(0, Math.min(max, rtl ? max + viewport.scrollLeft : viewport.scrollLeft));\n    const next = { overflow, left: fromLeft > 1, right: fromLeft < max - 1 };\n    setEdges((previous) => previous.overflow === next.overflow && previous.left === next.left && previous.right === next.right ? previous : next);\n  }, []);\n\n  const reveal = useCallback((tab: HTMLElement | null) => {\n    const viewport = viewportRef.current;\n    if (!viewport || !tab) return;\n    const frame = viewport.getBoundingClientRect();\n    const item = tab.getBoundingClientRect();\n    const max = Math.max(0, viewport.scrollWidth - viewport.clientWidth);\n    const rtl = getComputedStyle(viewport).direction === \"rtl\";\n    const fromLeft = Math.max(0, Math.min(max, rtl ? max + viewport.scrollLeft : viewport.scrollLeft));\n    // Keep the selected/focused label clear of the arrows over the faded edges.\n    const left = frame.left + (fromLeft > 1 ? 36 : 0);\n    const right = frame.right - (fromLeft < max - 1 ? 36 : 0);\n    const delta = item.left < left ? item.left - left : item.right > right ? item.right - right : 0;\n    // Scroll only this viewport; scrollIntoView can also move the whole page.\n    if (delta) viewport.scrollBy({ left: delta, behavior: reduce ? \"instant\" : \"smooth\" });\n  }, [reduce]);\n\n  useLayoutEffect(() => {\n    const root = rootRef.current;\n    const viewport = viewportRef.current;\n    const list = listRef.current;\n    if (!root || !viewport || !list) return;\n    const update = () => {\n      measure();\n      reveal(list.querySelector<HTMLElement>('[role=\"tab\"][aria-selected=\"true\"]'));\n    };\n    const observer = new ResizeObserver(update);\n    observer.observe(root);\n    observer.observe(viewport);\n    observer.observe(list);\n    viewport.addEventListener(\"scroll\", measure, { passive: true });\n    update();\n    return () => {\n      observer.disconnect();\n      viewport.removeEventListener(\"scroll\", measure);\n    };\n  }, [measure, reveal]);\n\n  useLayoutEffect(() => {\n    // Children may change without a resize; controlled selection must also reveal.\n    void children;\n    void value;\n    void edges.overflow;\n    measure();\n    reveal(listRef.current?.querySelector<HTMLElement>('[role=\"tab\"][aria-selected=\"true\"]') ?? null);\n  }, [children, value, edges.overflow, measure, reveal]);\n\n  useLayoutEffect(() => {\n    if (variant === \"underline\") return;\n    const list = listRef.current;\n    if (!list) return;\n    void children;\n    const labels = Array.from(list.querySelectorAll<HTMLElement>(\"[data-tabs-label]\"));\n    const indicator = list.querySelector<HTMLElement>(\"[data-tabs-indicator]\");\n    const target = list.querySelector<HTMLElement>('[role=\"tab\"][aria-selected=\"true\"]');\n    if (!indicator || !target || target.dataset.tabsValue !== value) {\n      for (const label of labels) label.style.clipPath = \"inset(0 100% 0 0)\";\n      return;\n    }\n    let frames = 0;\n    let stillFrames = 0;\n    let previous: { left: number; right: number } | undefined;\n    const syncClips = () => {\n      const pill = (reduce ? target : indicator).getBoundingClientRect();\n      // Read ALL geometry before writing ANY masks. A loop per tab interleaved\n      // reads and writes, forcing the browser to flush styles repeatedly.\n      const clips = labels.map((label) => {\n        const bounds = label.getBoundingClientRect();\n        const left = Math.max(0, Math.min(bounds.width, pill.left - bounds.left));\n        const right = Math.max(0, Math.min(bounds.width, bounds.right - pill.right));\n        return left + right >= bounds.width ? \"inset(0 100% 0 0)\" : `inset(0 ${right}px 0 ${left}px)`;\n      });\n      labels.forEach((label, index) => {\n        if (label.style.clipPath !== clips[index]) label.style.clipPath = clips[index];\n      });\n      frames += 1;\n      stillFrames = previous && Math.abs(pill.left - previous.left) < 0.01 && Math.abs(pill.right - previous.right) < 0.01 ? stillFrames + 1 : 0;\n      previous = { left: pill.left, right: pill.right };\n      if (reduce || (frames > 2 && stillFrames >= 2)) cancelFrame(syncClips);\n    };\n    // One shared pass after Motion paints the projected pill keeps every label\n    // in sync, including labels crossed during a long or interrupted glide.\n    frame.postRender(syncClips, true);\n    return () => cancelFrame(syncClips);\n  }, [value, children, variant, reduce]);\n\n  const scroll = (direction: number) => {\n    const viewport = viewportRef.current;\n    if (viewport) viewport.scrollBy({ left: direction * viewport.clientWidth * 0.8, behavior: reduce ? \"instant\" : \"smooth\" });\n  };\n  const controlClass = \"absolute inset-y-0 z-20 inline-flex w-9 items-center justify-center text-foreground transition-opacity hover:opacity-70 focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-0\";\n  const surfaceClass = variant === \"pill\" ? \"rounded-full bg-card\" : variant === \"segment\" ? \"rounded-lg bg-card\" : \"\";\n\n  return (\n    <div ref={rootRef} className={cn(\"relative isolate flex w-full max-w-full min-w-0 items-center\", edges.overflow && surfaceClass, wrapperClassName)}>\n      {edges.overflow && (\n        <button type=\"button\" aria-label=\"Scroll tabs left\" aria-controls={viewportId} disabled={!edges.left} onClick={() => scroll(-1)} className={cn(controlClass, \"left-0 rounded-l-full\")}>\n          <ChevronLeft size={20} aria-hidden=\"true\" />\n        </button>\n      )}\n      <motion.div\n        ref={viewportRef}\n        id={viewportId}\n        layoutScroll\n        className={cn(\"w-full min-w-0 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\", edges.overflow && \"[border-radius:inherit]\")}\n        style={edges.overflow ? {\n          maskImage: `linear-gradient(to right, ${edges.left ? \"transparent, black 40px\" : \"black, black 0px\"}, ${edges.right ? \"black calc(100% - 40px), transparent\" : \"black 100%\"})`,\n        } : undefined}\n        onFocusCapture={(event) => {\n          if (event.target instanceof HTMLElement && event.target.getAttribute(\"role\") === \"tab\") reveal(event.target);\n        }}\n      >\n        <div ref={listRef} role=\"tablist\" className={cn(listClasses[variant], \"w-max\", className)}>\n          {children}\n        </div>\n      </motion.div>\n      {edges.overflow && edges.left && (\n        <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-y-0 left-0 z-10 w-10 rounded-l-[inherit] backdrop-blur-[2px] [mask-image:linear-gradient(to_right,black,transparent)]\" />\n      )}\n      {edges.overflow && edges.right && (\n        <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-y-0 right-0 z-10 w-10 rounded-r-[inherit] backdrop-blur-[2px] [mask-image:linear-gradient(to_left,black,transparent)]\" />\n      )}\n      {edges.overflow && (\n        <button type=\"button\" aria-label=\"Scroll tabs right\" aria-controls={viewportId} disabled={!edges.right} onClick={() => scroll(1)} className={cn(controlClass, \"right-0 rounded-r-full\")}>\n          <ChevronRight size={20} aria-hidden=\"true\" />\n        </button>\n      )}\n    </div>\n  );\n}\n\nexport function TabsTrigger({\n  value,\n  children,\n  className,\n  indicatorClassName,\n}: {\n  value: string;\n  children: ReactNode;\n  className?: string;\n  indicatorClassName?: string;\n}) {\n  const { value: current, setValue, layoutId, variant } = useTabs();\n  const active = current === value;\n  // React owns the initial mask only; TabsList synchronizes subsequent masks.\n  const [initialClip] = useState(() => active ? \"inset(0)\" : \"inset(0 100% 0 0)\");\n\n  if (variant === \"underline\") {\n    return (\n      <button\n        type=\"button\"\n        role=\"tab\"\n        aria-selected={active}\n        onClick={() => setValue(value)}\n        className={cn(\n          \"relative isolate px-3 pb-2.5 pt-1 -mb-px text-sm font-medium transition-colors min-h-[44px] inline-flex items-center whitespace-nowrap shrink-0\",\n          active ? \"text-foreground\" : \"text-muted-foreground hover:text-foreground\",\n          className,\n        )}\n      >\n        {children}\n        {active ? (\n        <motion.span\n          layoutId={layoutId}\n          layout\n          className={cn(\n            \"absolute bottom-0 left-0 right-0 h-px bg-primary\",\n            indicatorClassName,\n          )}\n        />\n        ) : null}\n      </button>\n    );\n  }\n\n  const radius = variant === \"pill\" ? \"rounded-full\" : \"rounded-md\";\n\n  return (\n    <div className=\"relative shrink-0\">\n      {active ? (\n        <motion.span\n          data-tabs-indicator=\"\"\n          layoutId={layoutId}\n          layout\n          style={{ borderRadius: variant === \"pill\" ? 9999 : 8 }}\n          className={cn(\n            \"absolute inset-0 bg-primary\",\n            radius,\n            indicatorClassName,\n          )}\n        />\n      ) : null}\n      <button\n        type=\"button\"\n        role=\"tab\"\n        aria-selected={active}\n        data-tabs-value={value}\n        onClick={() => setValue(value)}\n        className={cn(\n          \"relative z-10 inline-flex items-center justify-center whitespace-nowrap bg-transparent px-3.5 py-1.5 text-sm font-medium outline-none\",\n          \"text-muted-foreground hover:text-foreground\",\n          radius,\n          className,\n        )}\n      >\n        {children}\n        <span\n          data-tabs-label=\"\"\n          aria-hidden=\"true\"\n          inert\n          className=\"pointer-events-none absolute inset-0 inline-flex items-center justify-center text-primary-foreground [gap:inherit] [padding:inherit]\"\n          style={{ clipPath: initialClip }}\n        >\n          {children}\n        </span>\n      </button>\n    </div>\n  );\n}\n\nexport function TabsContent({ value, children, className }: { value: string; children: ReactNode; className?: string }) {\n  const { value: current } = useTabs();\n  const reduce = useReducedMotion();\n  const active = current === value;\n  // Inactive panels stay mounted but hidden, so their content (e.g. source\n  // code) is present in the server-rendered HTML for crawlers and assistive\n  // tech, instead of being dropped from the DOM.\n  if (!active) {\n    return (\n      <div hidden className={className}>\n        {children}\n      </div>\n    );\n  }\n  return (\n    <motion.div\n      key={value}\n      initial={{ opacity: 0, y: reduce ? 0 : 4 }}\n      animate={{ opacity: 1, y: 0 }}\n      transition={{ duration: 0.18, ease: EASE_OUT }}\n      className={cn(\"mt-4\", className)}\n    >\n      {children}\n    </motion.div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"util","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"util","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"components/previews/motion/tabs.preview.tsx","type":"preview","content":"\"use client\";\n\nimport { Tabs, TabsContent, TabsList, TabsTrigger } from \"@/components/motion/tabs\";\n\nexport function TabsPreview() {\n  return (\n    <div className=\"flex w-full max-w-md flex-col gap-8\">\n      <Section title=\"Pill\">\n        <Tabs defaultValue=\"overview\" variant=\"pill\">\n          <TabsList>\n            <TabsTrigger value=\"overview\">Overview</TabsTrigger>\n            <TabsTrigger value=\"activity\">Activity</TabsTrigger>\n            <TabsTrigger value=\"settings\">Settings</TabsTrigger>\n          </TabsList>\n          <TabsContent value=\"overview\" className=\"text-sm text-muted-foreground\">High-level summary.</TabsContent>\n          <TabsContent value=\"activity\" className=\"text-sm text-muted-foreground\">Recent events.</TabsContent>\n          <TabsContent value=\"settings\" className=\"text-sm text-muted-foreground\">Preferences.</TabsContent>\n        </Tabs>\n      </Section>\n      <Section title=\"Overflow\">\n        <Tabs defaultValue=\"Overview\" className=\"w-full max-w-xs\">\n          <TabsList>\n            {[\"Overview\", \"Activity\", \"Analytics\", \"Members\", \"Billing\", \"Settings\"].map((label) => (\n              <TabsTrigger key={label} value={label}>{label}</TabsTrigger>\n            ))}\n          </TabsList>\n        </Tabs>\n      </Section>\n      <Section title=\"Segment\">\n        <Tabs defaultValue=\"day\" variant=\"segment\">\n          <TabsList>\n            <TabsTrigger value=\"day\">Day</TabsTrigger>\n            <TabsTrigger value=\"week\">Week</TabsTrigger>\n            <TabsTrigger value=\"month\">Month</TabsTrigger>\n          </TabsList>\n        </Tabs>\n      </Section>\n      <Section title=\"Underline\">\n        <Tabs defaultValue=\"all\" variant=\"underline\">\n          <TabsList>\n            <TabsTrigger value=\"all\">All</TabsTrigger>\n            <TabsTrigger value=\"open\">Open</TabsTrigger>\n            <TabsTrigger value=\"closed\">Closed</TabsTrigger>\n          </TabsList>\n        </Tabs>\n      </Section>\n    </div>\n  );\n}\n\nfunction Section({ title, children }: { title: string; children: React.ReactNode }) {\n  return (\n    <div className=\"flex flex-col gap-2\">\n      <span className=\"text-[10px] font-semibold uppercase tracking-wider text-muted-foreground\">{title}</span>\n      {children}\n    </div>\n  );\n}\n"}]}