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 motion

Add 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:

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

PropTypeRequiredDefaultDescription
tabsSpringTab[]Yes—Tabs with value, label, content, and optional icon and badge.
value / defaultValuestringNo—Controlled or initial tab.
onValueChange(value: string) => voidNo—Fires when the tab changes.
variant"underline" | "pill"No"underline"Stretchy underline, or a sliding raised pill.
aria-labelstringNo"Content sections"Accessible name of the tab list.
classNamestringNo—Classes for the wrapper.

Best Practices

  1. Use tabs to switch between views of the same thing; for choices or settings, use a Segmented Control.
  2. Keep labels to one or two words; badges work best for counts.
  3. The tab row scrolls sideways when it doesn't fit, so it holds up on narrow screens.
  4. Reduced motion places the indicator instantly and swaps panels with a fade.