Installation
Run this in a project set up with shadcn/ui:
$ pnpm dlx shadcn@latest add https://uxelith.com/r/animated-tabs.jsonIt adds these packages for you:
clsxtailwind-mergemotionThe source lands in your components folder as
animated-tabs.tsx. It's yours: change anything.
Usage
import { AnimatedTabs } from "@/components/animated-tabs"
export function Billing() {
return (
<AnimatedTabs
label="Billing period"
tabs={[
{ value: "monthly", label: "Monthly", content: <p>Billed every month.</p> },
{ value: "yearly", label: "Yearly", content: <p>Two months free.</p> },
]}
/>
)
}Source
One file, 272 lines, nothing hidden. Read it before you install it.
"use client";
import { AnimatePresence, LayoutGroup, motion, useIsPresent, useReducedMotion } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface AnimatedTab {
value: string;
label: React.ReactNode;
/** Optional icon before the label. */
icon?: React.ReactNode;
content: React.ReactNode;
disabled?: boolean;
}
export interface AnimatedTabsProps extends Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> {
tabs: AnimatedTab[];
/** Controlled selected tab. */
value?: string;
/** Initially selected tab when uncontrolled. Defaults to the first enabled tab. */
defaultValue?: string;
onValueChange?: (value: string) => void;
/** `pill` slides a filled pill; `underline` slides a line under the active tab. */
variant?: "pill" | "underline";
/** Accessible name for the tab list, e.g. "Billing period". */
label: string;
/** Stretch tabs to fill the full width. */
fullWidth?: boolean;
listClassName?: string;
panelClassName?: string;
}
// Motion presets (ADR-010).
const SPRING_SNAPPY = { type: "spring", visualDuration: 0.2, bounce: 0 } as const;
const SPRING_SMOOTH = { type: "spring", visualDuration: 0.35, bounce: 0.15 } as const;
const slide = {
enter: (direction: number) => ({ opacity: 0, x: direction * 24, filter: "blur(4px)" }),
center: { opacity: 1, x: 0, filter: "blur(0px)" },
exit: (direction: number) => ({ opacity: 0, x: direction * -24, filter: "blur(4px)" }),
};
function Panel({
id,
labelledBy,
direction,
reduceMotion,
className,
children,
ref,
}: {
id: string;
labelledBy: string;
direction: number;
reduceMotion: boolean;
className?: string;
children: React.ReactNode;
ref?: React.Ref<HTMLDivElement>;
}) {
// A panel on its way out is only a picture: no role, no focus, no reading.
const present = useIsPresent();
return (
<motion.div
ref={ref}
id={present ? id : undefined}
role={present ? "tabpanel" : undefined}
aria-labelledby={present ? labelledBy : undefined}
aria-hidden={present ? undefined : true}
inert={!present}
tabIndex={present ? 0 : -1}
custom={direction}
variants={slide}
initial="enter"
animate="center"
exit="exit"
transition={reduceMotion ? { duration: 0 } : SPRING_SMOOTH}
className={cn("rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring", className)}
>
{children}
</motion.div>
);
}
/**
* Tabs with an indicator that glides to the selected tab, a hover highlight
* that follows the pointer, and panels that slide in from the side you're
* heading to while the height eases to fit.
*
* Follows the WAI-ARIA tabs pattern with automatic activation: ←/→ move and
* select, Home/End jump to the ends, disabled tabs are skipped, and only the
* selected tab is in the Tab order. With `prefers-reduced-motion` the
* indicator and panels change instantly.
*/
export function AnimatedTabs({
tabs,
value: valueProp,
defaultValue,
onValueChange,
variant = "pill",
label,
fullWidth = false,
className,
listClassName,
panelClassName,
...props
}: AnimatedTabsProps) {
const id = React.useId();
const reduceMotion = useReducedMotion();
const firstEnabled = tabs.find((t) => !t.disabled)?.value;
const [uncontrolled, setUncontrolled] = React.useState(defaultValue ?? firstEnabled);
const requested = valueProp ?? uncontrolled;
// If the selected tab is removed or disabled, fall back to the first enabled one.
const selected = tabs.some((t) => t.value === requested && !t.disabled) ? requested : firstEnabled;
const refs = React.useRef(new Map<string, HTMLButtonElement>());
const [hovered, setHovered] = React.useState<string | null>(null);
// Which way the panels slide: toward the newly selected tab.
const index = tabs.findIndex((t) => t.value === selected);
const [previous, setPrevious] = React.useState({ index, direction: 0 });
if (previous.index !== index) setPrevious({ index, direction: Math.sign(index - previous.index) });
const direction = previous.direction;
// Panels differ in height; measure the current one so the frame can ease to it.
const [height, setHeight] = React.useState<number | "auto">("auto");
// Clip only while the height eases, so shadows and glows in a panel aren't cut off at rest.
const [resizing, setResizing] = React.useState(false);
const measureRef = React.useRef<HTMLDivElement>(null);
React.useLayoutEffect(() => {
const el = measureRef.current;
if (!el) return;
const observer = new ResizeObserver(() => setHeight(el.offsetHeight));
observer.observe(el);
return () => observer.disconnect();
}, []);
const select = (next: string) => {
if (next === selected) return;
if (valueProp === undefined) setUncontrolled(next);
onValueChange?.(next);
};
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
const enabled = tabs.filter((t) => !t.disabled);
const index = enabled.findIndex((t) => t.value === selected);
let target: AnimatedTab | undefined;
if (event.key === "ArrowRight") target = enabled[(index + 1) % enabled.length];
else if (event.key === "ArrowLeft") target = enabled[(index - 1 + enabled.length) % enabled.length];
else if (event.key === "Home") target = enabled[0];
else if (event.key === "End") target = enabled.at(-1);
if (!target) return;
event.preventDefault();
select(target.value);
refs.current.get(target.value)?.focus();
};
const spring = reduceMotion ? { duration: 0 } : SPRING_SMOOTH;
const active = tabs.find((t) => t.value === selected);
return (
<div className={cn("w-full", className)} {...props}>
<LayoutGroup id={id}>
<div
role="tablist"
aria-label={label}
aria-orientation="horizontal"
onKeyDown={onKeyDown}
onPointerLeave={() => setHovered(null)}
className={cn(
"relative flex",
variant === "pill"
? "w-fit gap-1 rounded-xl border border-border bg-muted/60 p-1"
: "w-full gap-6 border-b border-border",
fullWidth && "w-full",
listClassName,
)}
>
{tabs.map((tab) => {
const isSelected = tab.value === selected;
return (
<button
key={tab.value}
ref={(node) => {
if (node) refs.current.set(tab.value, node);
else refs.current.delete(tab.value);
}}
id={`${id}-tab-${tab.value}`}
role="tab"
type="button"
aria-selected={isSelected}
// Only the selected panel is rendered, so only its tab points at one.
aria-controls={isSelected ? `${id}-panel-${tab.value}` : undefined}
tabIndex={isSelected ? 0 : -1}
disabled={tab.disabled}
onClick={() => select(tab.value)}
onPointerEnter={(event) => event.pointerType === "mouse" && setHovered(tab.value)}
className={cn(
"relative inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium outline-none transition-colors duration-200 motion-reduce:transition-none",
"disabled:pointer-events-none disabled:opacity-40",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
variant === "pill" ? "h-9 rounded-lg px-4" : "h-11 rounded-sm px-0.5",
fullWidth && "flex-1",
isSelected ? "text-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
{isSelected && (
<motion.span
layoutId="indicator"
transition={spring}
aria-hidden="true"
className={cn(
"absolute",
variant === "pill"
? "inset-0 rounded-lg border border-border bg-background shadow-[0_1px_2px_rgb(0_0_0/0.08),0_4px_12px_-4px_rgb(0_0_0/0.12)] dark:border-input dark:bg-[color-mix(in_oklab,var(--foreground)_9%,var(--muted))] dark:shadow-[inset_0_1px_0_rgb(255_255_255/0.06),0_4px_12px_-4px_rgb(0_0_0/0.5)]"
: "inset-x-0 -bottom-px h-0.5 rounded-full bg-[var(--tabs-indicator,var(--primary))]",
)}
/>
)}
{hovered === tab.value && !isSelected && !reduceMotion && (
<motion.span
layoutId="hover"
aria-hidden="true"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={SPRING_SNAPPY}
className={cn(
"absolute bg-foreground/[0.06]",
variant === "pill" ? "inset-0 rounded-lg" : "-inset-x-2 inset-y-1.5 rounded-md",
)}
/>
)}
<span className="relative z-10 inline-flex items-center gap-2 [&_svg]:size-4 [&_svg]:shrink-0">
{tab.icon}
{tab.label}
</span>
</button>
);
})}
</div>
</LayoutGroup>
<motion.div
initial={false}
animate={{ height }}
transition={reduceMotion ? { duration: 0 } : SPRING_SMOOTH}
onAnimationStart={() => setResizing(true)}
onAnimationComplete={() => setResizing(false)}
// Room for the panel's focus ring inside the clipped frame.
className={cn("-mx-1 mt-4 px-1", resizing && "overflow-hidden")}
>
{/* flow-root keeps a panel's own margin inside what's measured. */}
<div ref={measureRef} className="relative flow-root py-1">
<AnimatePresence initial={false} custom={direction} mode="popLayout">
{active && (
<Panel
key={active.value}
id={`${id}-panel-${active.value}`}
labelledBy={`${id}-tab-${active.value}`}
direction={direction}
reduceMotion={!!reduceMotion}
className={panelClassName}
>
{active.content}
</Panel>
)}
</AnimatePresence>
</div>
</motion.div>
</div>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| tabs | { value, label, icon?, content, disabled? }[] | — | The tabs and their panels. |
| label | string | — | Accessible name of the tab list. |
| value / defaultValue | string | first enabled | Controlled or initial selected tab. |
| onValueChange | (value: string) => void | — | Called when the selection changes. |
| variant | "pill" | "underline" | "pill" | Sliding pill, or a sliding line under the tab. |
| fullWidth | boolean | false | Stretch tabs across the full width. |
| listClassName / panelClassName | string | — | Style the tab list or the panel. |
Accessibility
- WAI-ARIA tabs pattern: tablist, tab and tabpanel roles wired with aria-selected, aria-controls and aria-labelledby.
- ← and → move and select, Home and End jump to the ends, disabled tabs are skipped.
- Only the selected tab is in the Tab order; the panel is focusable so long content can be scrolled from the keyboard.
- The outgoing panel is inert and hidden from assistive tech while it slides away.
- Under prefers-reduced-motion the indicator and panels change instantly.
Changelog
- v1.0.1Latest
Rebuilt: hover highlight that follows the pointer, panels that slide in from the side you're heading to, height that eases to fit, optional icons. Also: if the selected tab is removed or disabled, the first enabled tab is selected.