Feature tabs

One panel, many features: the line slides between tabs, fills as autoplay runs, and the panel eases to each.

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/feature-tabs.json

Overview

Feature tabs are where a landing page shows the product, and most snap from one screenshot to the next. Here the line under the chosen tab slides across with a little give; with autoplay it fills like a progress bar and hands over to the next tab when full, pausing whenever you point at it. The panel eases to the height of what it’s showing while the new content slides in from the side you went, through a light blur.

States

switch
The line slides and resizes to the tab (520ms, slight overshoot); the label colour follows.
panel
Height eases over 460ms; old content slides and blurs out one way, new content in from the other.
autoplay
The line fills over the interval and moves to the next tab when full. Pointer or focus inside pauses it; clicking a tab restarts it.

Usage

import { FeatureTabs } from "@/components/feature-tabs";

<FeatureTabs
  autoplay={4200}
  tabs={[
    { id: "plan", label: "Plan", content: <PlanShot /> },
    { id: "ship", label: "Ship", content: <ShipShot /> },
  ]}
/>

In a features section

Put whatever shows the feature best in each tab: a screenshot, a video, a small live mock.

import Image from "next/image";
import { FeatureTabs } from "@/components/feature-tabs";

const features = [
  { id: "plan", label: "Plan", title: "Turn a rough idea into a plan", shot: "/shots/plan.png" },
  { id: "write", label: "Write", title: "Write it once, in your voice", shot: "/shots/write.png" },
  { id: "ship", label: "Ship", title: "Ship with a log you can read", shot: "/shots/ship.png" },
];

export function Features() {
  return (
    <section className="mx-auto max-w-4xl">
      <FeatureTabs
        autoplay={5000}
        tabs={features.map((f) => ({
          id: f.id,
          label: f.label,
          content: (
            <div className="grid items-center gap-8 md:grid-cols-2">
              <h3 className="text-2xl font-medium tracking-tight">{f.title}</h3>
              <Image src={f.shot} alt="" width={640} height={420} className="rounded-2xl border" />
            </div>
          ),
        }))}
      />
    </section>
  );
}

Props

PropTypeDescription
tabs{ id; label; content }[]Each tab and what its panel shows.
value / defaultValue / onValueChangestring / string / (id) => voidThe chosen tab, controlled or not.
autoplaynumberMove on every this many ms, with the line as the timer. Off with reduced motion.

Accessibility and motion

  • A real tablist with a labelled tabpanel; arrow keys, Home and End move between tabs and only the chosen tab is in the tab order.
  • Autoplay pauses while the pointer or focus is anywhere inside, so it never moves under someone reading.
  • The progress is a CSS animation, so nothing re-renders while it runs.
  • With reduced motion there is no autoplay and content changes in place.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style). Icons from lucide-react.

Source

›Show the full source of feature-tabs.tsx (240 lines)
"use client";

import * as React from "react";

/* ─────────────────────────────────────────────────────────
 * FEATURE TABS: one panel, many features
 *
 *   tabs      the line under the chosen tab slides over with a
 *             little give
 *   autoplay  that line fills like a progress bar; when it's
 *             full the next tab takes over. Pointing at it pauses
 *   panel     its height eases to the new content while the
 *             content slides in from the side you went, through
 *             a light blur
 *
 * A real tablist: arrow keys, Home and End move between tabs.
 * ───────────────────────────────────────────────────────── */

export interface FeatureTab {
  id: string;
  label: string;
  content: React.ReactNode;
}

export interface FeatureTabsProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
  tabs: FeatureTab[];
  value?: string;
  defaultValue?: string;
  onValueChange?: (id: string) => void;
  /** Move to the next tab every this many ms. Pointing at the tabs pauses it. */
  autoplay?: number;
}

type Leaving = { id: string; dir: number; key: number };

const EASE = "cubic-bezier(0.16,1,0.3,1)";
const THROW = "cubic-bezier(0.34,1.36,0.64,1)";
const MORPH = 460;
const FOCUS = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40";

const reducedQuery = "(prefers-reduced-motion: reduce)";
const subscribeReduced = (onChange: () => void) => {
  const query = window.matchMedia(reducedQuery);
  query.addEventListener("change", onChange);
  return () => query.removeEventListener("change", onChange);
};
const useReducedMotion = () =>
  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);

function Pane({ children, dir, leaving, reduced, paneRef }: { children: React.ReactNode; dir: number; leaving: boolean; reduced: boolean; paneRef?: React.Ref<HTMLDivElement> }) {
  const ref = React.useRef<HTMLDivElement>(null);
  React.useLayoutEffect(() => {
    const el = ref.current;
    if (!el || reduced || dir === 0) return;
    const shift = 32 * dir;
    const animation = leaving
      ? el.animate(
          [
            { opacity: 1, filter: "blur(0px)", transform: "none" },
            { opacity: 0, filter: "blur(6px)", transform: `translateX(${-shift}px)` },
          ],
          { duration: MORPH * 0.6, easing: EASE, fill: "forwards" }
        )
      : el.animate(
          [
            { opacity: 0, filter: "blur(6px)", transform: `translateX(${shift}px)` },
            { opacity: 1, filter: "blur(0px)", transform: "none" },
          ],
          { duration: MORPH, easing: EASE, delay: 60, fill: "backwards" }
        );
    return () => animation.cancel();
  }, [dir, leaving, reduced]);
  return (
    <div
      ref={(el) => {
        ref.current = el;
        if (typeof paneRef === "function") paneRef(el);
        else if (paneRef) (paneRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
      }}
      inert={leaving}
      aria-hidden={leaving || undefined}
      className="absolute inset-x-0 top-0"
    >
      {children}
    </div>
  );
}

export function FeatureTabs({ tabs, value, defaultValue, onValueChange, autoplay, className = "", ...props }: FeatureTabsProps) {
  const reduced = useReducedMotion();
  const id = React.useId();
  const [own, setOwn] = React.useState(defaultValue ?? tabs[0]?.id);
  const active = value ?? own;
  const index = Math.max(0, tabs.findIndex((t) => t.id === active));

  const [leaving, setLeaving] = React.useState<Leaving[]>([]);
  const [dir, setDir] = React.useState(0);
  const [cycle, setCycle] = React.useState(0);
  const [paused, setPaused] = React.useState(false);
  const [line, setLine] = React.useState({ left: 0, width: 0, ready: false });
  const [height, setHeight] = React.useState<number | null>(null);
  const tabRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
  const paneRef = React.useRef<HTMLDivElement | null>(null);
  const keyCounter = React.useRef(0);

  const select = (next: number, focus = false) => {
    const tab = tabs[(next + tabs.length) % tabs.length];
    if (!tab) return;
    setCycle((c) => c + 1);
    if (tab.id === active) return;
    const nextIndex = tabs.indexOf(tab);
    setDir(Math.sign(nextIndex - index) || 1);
    setLeaving((l) => [...l, { id: active, dir: Math.sign(nextIndex - index) || 1, key: ++keyCounter.current }]);
    if (value === undefined) setOwn(tab.id);
    onValueChange?.(tab.id);
    if (focus) tabRefs.current[nextIndex]?.focus();
  };

  React.useEffect(() => {
    if (!leaving.length) return;
    const timer = window.setTimeout(() => setLeaving([]), MORPH);
    return () => window.clearTimeout(timer);
  }, [leaving]);

  // The line sits under the chosen tab.
  React.useLayoutEffect(() => {
    const measure = () => {
      const tab = tabRefs.current[index];
      if (!tab) return;
      const left = tab.offsetLeft;
      const width = tab.offsetWidth;
      setLine((l) => (l.left === left && l.width === width ? l : { left, width, ready: l.width > 0 }));
    };
    measure();
    const observer = new ResizeObserver(measure);
    tabRefs.current.forEach((t) => t && observer.observe(t));
    return () => observer.disconnect();
  }, [index, tabs]);

  // The panel takes the height of what it's showing.
  React.useLayoutEffect(() => {
    const pane = paneRef.current;
    if (!pane) return;
    const measure = () => setHeight((h) => (h === pane.offsetHeight ? h : pane.offsetHeight));
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(pane);
    return () => observer.disconnect();
  }, [active]);

  const onKeyDown = (e: React.KeyboardEvent) => {
    const moves: Record<string, number> = { ArrowRight: index + 1, ArrowLeft: index - 1, Home: 0, End: tabs.length - 1 };
    if (!(e.key in moves)) return;
    e.preventDefault();
    select(moves[e.key], true);
  };

  const playing = Boolean(autoplay) && !reduced && !paused;
  const current = tabs[index];

  return (
    <div
      className={`w-full ${className}`}
      onPointerEnter={() => setPaused(true)}
      onPointerLeave={() => setPaused(false)}
      onFocus={() => setPaused(true)}
      onBlur={(e) => !e.currentTarget.contains(e.relatedTarget as Node) && setPaused(false)}
      {...props}
    >
      <div role="tablist" aria-label="Features" onKeyDown={onKeyDown} className="relative flex gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
        {tabs.map((tab, i) => {
          const selected = i === index;
          return (
            <button
              key={tab.id}
              ref={(el) => {
                tabRefs.current[i] = el;
              }}
              type="button"
              role="tab"
              id={`${id}-tab-${tab.id}`}
              aria-selected={selected}
              aria-controls={`${id}-panel`}
              tabIndex={selected ? 0 : -1}
              onClick={() => select(i)}
              className={`relative shrink-0 whitespace-nowrap px-3 pb-3 pt-1 text-[13.5px] transition-colors duration-300 ${FOCUS} ${
                selected ? "text-foreground" : "text-muted-foreground hover:text-foreground"
              }`}
            >
              {tab.label}
            </button>
          );
        })}
        {/* The hairline under the row, then the line: it slides to the chosen tab and, with autoplay, fills as its time runs. */}
        <span aria-hidden="true" className="absolute inset-x-0 bottom-0 h-px bg-border" />
        <span
          aria-hidden="true"
          className="absolute bottom-0 left-0 h-px overflow-hidden"
          style={{
            width: line.width,
            transform: `translateX(${line.left}px)`,
            transition: line.ready && !reduced ? `transform 520ms ${THROW}, width 420ms ${EASE}` : "none",
          }}
        >
          <span className="absolute inset-0 bg-border" />
          <span
            key={`${active}-${cycle}`}
            className="absolute inset-0 origin-left bg-foreground"
            onAnimationEnd={() => select(index + 1)}
            style={
              autoplay && !reduced
                ? { animation: `ui-progress ${autoplay}ms linear forwards`, animationPlayState: playing ? "running" : "paused" }
                : undefined
            }
          />
        </span>
      </div>

      <div
        id={`${id}-panel`}
        role="tabpanel"
        aria-labelledby={`${id}-tab-${active}`}
        className="relative mt-5 overflow-hidden"
        style={{ height: height ?? undefined, transition: reduced || height === null ? "none" : `height ${MORPH}ms ${EASE}` }}
      >
        {leaving.map((l) => (
          <Pane key={l.key} dir={l.dir} leaving reduced={reduced}>
            {tabs.find((t) => t.id === l.id)?.content}
          </Pane>
        ))}
        {current && (
          <Pane key={current.id} dir={leaving.length ? dir : 0} leaving={false} reduced={reduced} paneRef={paneRef}>
            {current.content}
          </Pane>
        )}
      </div>
    </div>
  );
}

Registry item: /r/feature-tabs.json