Overview
Tabs with some spring in them. The underline stretches as it travels: its leading edge darts ahead and the trailing edge catches up. A soft wash follows the pointer across tabs, tabs can carry an icon and a badge, and the panel slides in from the direction you moved while its height eases to the new content. A pill variant slides a raised background instead of a line. Arrow keys, Home and End move between tabs.
Installation
Install dependencies:
$ pnpm add motionAdd the utility function for class merging:
lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs));}
Copy the component code into your project:
tsx
"use client"; import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";import type { KeyboardEvent, ReactNode } from "react";import { AnimatePresence, LayoutGroup, animate, motion, useMotionValue, useReducedMotion, useTransform } from "motion/react";import { cn } from "@/lib/utils"; export type SpringTab = { value: string; label: string; content: ReactNode; icon?: ReactNode; /** Small count or tag after the label. */ badge?: ReactNode;}; export type SpringTabsProps = { tabs: SpringTab[]; value?: string; defaultValue?: string; onValueChange?: (value: string) => void; /** `underline` draws a stretchy line; `pill` slides a soft background. */ variant?: "underline" | "pill"; "aria-label"?: string; className?: string;}; const EASE_OUT = [0.23, 1, 0.32, 1] as const;const LEAD = { type: "spring" as const, stiffness: 520, damping: 38 };const TRAIL = { type: "spring" as const, stiffness: 220, damping: 28 }; export function SpringTabs({ tabs, value, defaultValue, onValueChange, variant = "underline", "aria-label": ariaLabel = "Content sections", className,}: SpringTabsProps) { const reduce = !!useReducedMotion(); const baseId = useId(); const controlled = value !== undefined; const [internal, setInternal] = useState(defaultValue ?? tabs[0]?.value ?? ""); const active = controlled ? value : internal; const activeIndex = Math.max(0, tabs.findIndex((t) => t.value === active)); const activeTab = tabs[activeIndex]; const [direction, setDirection] = useState(1); const [hovered, setHovered] = useState<string | null>(null); const listRef = useRef<HTMLDivElement>(null); const refs = useRef(new Map<string, HTMLButtonElement>()); // Indicator edges move on separate springs: the leading edge is quick and // the trailing edge catches up, so the line stretches as it travels. const left = useMotionValue(0); const right = useMotionValue(0); const width = useTransform(() => Math.max(0, right.get() - left.get())); const placed = useRef(false); const place = useCallback( (instant: boolean) => { const el = activeTab ? refs.current.get(activeTab.value) : null; if (!el) return; const inset = variant === "underline" ? 12 : 0; const l = el.offsetLeft + inset; const r = el.offsetLeft + el.offsetWidth - inset; if (instant || reduce || !placed.current) { left.jump(l); right.jump(r); placed.current = true; return; } const goingRight = l > left.get(); animate(left, l, goingRight ? TRAIL : LEAD); animate(right, r, goingRight ? LEAD : TRAIL); }, [activeTab, left, right, reduce, variant], ); useLayoutEffect(() => place(false), [place]); useEffect(() => { const el = listRef.current; if (!el) return; const ro = new ResizeObserver(() => place(true)); ro.observe(el); return () => ro.disconnect(); }, [place]); // Panel height follows the content. const innerRef = useRef<HTMLDivElement>(null); const [height, setHeight] = useState<number | "auto">("auto"); useEffect(() => { const el = innerRef.current; if (!el) return; const ro = new ResizeObserver(([entry]) => setHeight(entry.contentRect.height)); ro.observe(el); return () => ro.disconnect(); }, []); const activate = (next: string) => { const nextIndex = tabs.findIndex((t) => t.value === next); if (nextIndex === activeIndex) return; setDirection(nextIndex > activeIndex ? 1 : -1); if (!controlled) setInternal(next); onValueChange?.(next); }; if (!activeTab) return null; const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => { const map: Record<string, number> = { ArrowRight: (activeIndex + 1) % tabs.length, ArrowLeft: (activeIndex - 1 + tabs.length) % tabs.length, Home: 0, End: tabs.length - 1, }; if (!(event.key in map)) return; event.preventDefault(); const tab = tabs[map[event.key]]; activate(tab.value); refs.current.get(tab.value)?.focus(); }; const pill = variant === "pill"; return ( <div className={cn("w-full", className)}> <LayoutGroup id={baseId}> <div ref={listRef} role="tablist" aria-label={ariaLabel} onKeyDown={onKeyDown} onPointerLeave={() => setHovered(null)} className={cn( "relative isolate flex w-full gap-1 overflow-x-auto [scrollbar-width:none]", pill ? "w-fit max-w-full rounded-full bg-(--color-paper-2) p-1 shadow-[inset_0_0_0_1px_var(--color-rule)]" : "border-b border-(--color-rule)", )} > {/* Indicator */} <motion.span aria-hidden className={cn( "pointer-events-none absolute -z-10", pill ? "top-1 bottom-1 rounded-full bg-(--color-paper) shadow-[0_0_0_0.5px_var(--color-rule),0_1px_2px_rgba(0,0,0,0.08),0_2px_8px_-2px_rgba(0,0,0,0.1)]" : "bottom-0 h-[2px] rounded-full bg-(--color-ink)", )} style={{ left, width }} /> {tabs.map((tab) => { const selected = tab.value === activeTab.value; return ( <button key={tab.value} ref={(element) => { if (element) refs.current.set(tab.value, element); else refs.current.delete(tab.value); }} id={`${baseId}-${tab.value}`} type="button" role="tab" aria-selected={selected} aria-controls={`${baseId}-panel`} tabIndex={selected ? 0 : -1} onClick={() => activate(tab.value)} onPointerEnter={() => setHovered(tab.value)} className={cn( "relative inline-flex shrink-0 items-center gap-1.5 text-[13.5px] font-medium whitespace-nowrap outline-none", "transition-colors duration-200", "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-inset", pill ? "h-8 rounded-full px-3.5" : "h-11 rounded-t-[10px] px-4", selected ? "text-(--color-ink)" : "text-(--color-neutral) hover:text-(--color-ink-2)", )} > {hovered === tab.value && !selected ? ( <motion.span layoutId="hover" aria-hidden transition={reduce ? { duration: 0 } : { type: "spring", bounce: 0.15, duration: 0.4 }} className={cn("absolute -z-10 bg-(--color-ink)/[0.045]", pill ? "inset-0 rounded-full" : "inset-x-1 inset-y-1.5 rounded-[8px]")} /> ) : null} {tab.icon ? ( <span aria-hidden className="flex size-4 [&_svg]:size-full [&_svg]:stroke-[1.75]"> {tab.icon} </span> ) : null} {tab.label} {tab.badge !== undefined ? ( <span className={cn( "inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full px-1.5 text-[11px] tabular-nums transition-colors duration-200", selected ? "bg-(--color-ink) text-(--color-paper)" : "bg-(--color-paper-2) text-(--color-neutral)", pill && !selected && "bg-(--color-paper)", )} > {tab.badge} </span> ) : null} </button> ); })} </div> </LayoutGroup> <motion.div id={`${baseId}-panel`} role="tabpanel" aria-labelledby={`${baseId}-${activeTab.value}`} tabIndex={0} initial={false} animate={{ height }} transition={reduce ? { duration: 0 } : { type: "spring", bounce: 0, duration: 0.4 }} className="relative overflow-hidden rounded-[12px] outline-none focus-visible:ring-2 focus-visible:ring-(--color-focus)" > <div ref={innerRef} className="relative"> <AnimatePresence initial={false} mode="popLayout" custom={direction}> <motion.div key={activeTab.value} custom={direction} variants={{ enter: (d: number) => (reduce ? { opacity: 0 } : { opacity: 0, x: d * 24, filter: "blur(6px)" }), center: { opacity: 1, x: 0, filter: "blur(0px)" }, exit: (d: number) => (reduce ? { opacity: 0 } : { opacity: 0, x: d * -24, filter: "blur(6px)" }), }} initial="enter" animate="center" exit="exit" transition={{ duration: 0.32, ease: EASE_OUT }} className="py-5" > {activeTab.content} </motion.div> </AnimatePresence> </div> </motion.div> </div> );}
Adjust import paths according to your folder structure.
Basic Usage
tsx
import { SpringTabs } from "@/components/ui/spring-tabs";import { MessageSquare } from "lucide-react"; <SpringTabs defaultValue="overview" tabs={[ { value: "overview", label: "Overview", content: <Overview /> }, { value: "comments", label: "Comments", icon: <MessageSquare />, badge: 3, content: <Comments /> }, ]}/> <SpringTabs tabs={tabs} variant="pill" />
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| tabs | SpringTab[] | Yes | — | Tabs with value, label, content, and optional icon and badge. |
| value / defaultValue | string | No | — | Controlled or initial tab. |
| onValueChange | (value: string) => void | No | — | Fires when the tab changes. |
| variant | "underline" | "pill" | No | "underline" | Stretchy underline, or a sliding raised pill. |
| aria-label | string | No | "Content sections" | Accessible name of the tab list. |
| className | string | No | — | Classes for the wrapper. |
Best Practices
- Use tabs to switch between views of the same thing; for choices or settings, use a Segmented Control.
- Keep labels to one or two words; badges work best for counts.
- The tab row scrolls sideways when it doesn't fit, so it holds up on narrow screens.
- Reduced motion places the indicator instantly and swaps panels with a fade.