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.jsonOverview
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
| Prop | Type | Description |
|---|---|---|
| tabs | { id; label; content }[] | Each tab and what its panel shows. |
| value / defaultValue / onValueChange | string / string / (id) => void | The chosen tab, controlled or not. |
| autoplay | number | Move 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