Radix tabs with a measured pill or underline indicator that slides between triggers and retargets mid-flight.
npx shadcn@latest add @paragon/animated-tabs"use client";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
type AnimatedTabsVariant = "pill" | "underline";
interface AnimatedTabsContextValue {
value: string | undefined;
variant: AnimatedTabsVariant;
isStatic: boolean;
}
const AnimatedTabsContext =
React.createContext<AnimatedTabsContextValue | null>(null);
function useAnimatedTabs() {
const context = React.useContext(AnimatedTabsContext);
if (!context) {
throw new Error("AnimatedTabs parts must be used within <AnimatedTabs>");
}
return context;
}
export interface AnimatedTabsProps
extends React.ComponentProps<typeof TabsPrimitive.Root> {
/** "pill" slides a raised pill behind the active tab; "underline" slides a bar beneath it. */
variant?: AnimatedTabsVariant;
/** Disables the sliding indicator motion; selection snaps instantly. */
static?: boolean;
}
/**
* Tabs built on Radix with a single measured indicator that slides between
* triggers. The indicator reads `offsetLeft`/`offsetWidth` off the trigger
* carrying `aria-selected="true"` and transitions transform + width, so it
* retargets mid-flight when the user clicks quickly. The first paint is
* suppressed — the indicator appears in place, never animating in from zero.
*/
export function AnimatedTabs({
variant = "pill",
value: valueProp,
defaultValue,
onValueChange,
static: isStatic = false,
className,
...props
}: AnimatedTabsProps) {
const [internalValue, setInternalValue] = React.useState(defaultValue);
const value = valueProp ?? internalValue;
const contextValue = React.useMemo(
() => ({ value, variant, isStatic }),
[value, variant, isStatic],
);
return (
<AnimatedTabsContext.Provider value={contextValue}>
<TabsPrimitive.Root
data-slot="animated-tabs"
value={value}
onValueChange={(next) => {
setInternalValue(next);
onValueChange?.(next);
}}
className={cn("flex flex-col gap-4", className)}
{...props}
/>
</AnimatedTabsContext.Provider>
);
}
export function AnimatedTabsList({
className,
children,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
const { value, variant, isStatic } = useAnimatedTabs();
const listRef = React.useRef<HTMLDivElement>(null);
const indicatorRef = React.useRef<HTMLSpanElement>(null);
const hasPositioned = React.useRef(false);
const position = React.useCallback((animate: boolean) => {
const list = listRef.current;
const indicator = indicatorRef.current;
if (!list || !indicator) return;
const active = list.querySelector<HTMLElement>(
'[role="tab"][aria-selected="true"]',
);
if (!active) {
indicator.style.opacity = "0";
return;
}
if (!animate) indicator.style.transitionProperty = "none";
indicator.style.opacity = "1";
indicator.style.width = `${active.offsetWidth}px`;
indicator.style.transform = `translateX(${active.offsetLeft}px)`;
if (!animate) {
// Flush the jump before re-enabling transitions so it never animates.
void indicator.offsetWidth;
indicator.style.transitionProperty = "";
}
}, []);
React.useLayoutEffect(() => {
position(hasPositioned.current && !isStatic);
hasPositioned.current = true;
}, [position, value, isStatic]);
// Layout shifts (container resize, font swap) snap without animating.
React.useEffect(() => {
const list = listRef.current;
if (!list) return;
const observer = new ResizeObserver(() => position(false));
observer.observe(list);
for (const tab of list.querySelectorAll('[role="tab"]')) {
observer.observe(tab);
}
return () => observer.disconnect();
}, [position]);
return (
<TabsPrimitive.List
ref={listRef}
data-slot="animated-tabs-list"
className={cn(
"relative w-fit",
variant === "pill" && "inline-flex items-center gap-0.5 rounded-lg bg-muted p-1",
variant === "underline" && "inline-flex items-center gap-1 border-b",
className,
)}
{...props}
>
<span
ref={indicatorRef}
aria-hidden
className={cn(
"pointer-events-none absolute left-0 opacity-0 [transition-property:transform,width] duration-[250ms] ease-out motion-reduce:transition-none",
variant === "pill" &&
"inset-y-1 rounded-md bg-background shadow-border dark:bg-foreground/10",
variant === "underline" && "-bottom-px h-0.5 rounded-full bg-foreground",
)}
/>
{children}
</TabsPrimitive.List>
);
}
export function AnimatedTabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
const { variant } = useAnimatedTabs();
return (
<TabsPrimitive.Trigger
data-slot="animated-tabs-trigger"
className={cn(
"relative z-10 inline-flex items-center justify-center gap-1.5 text-sm font-medium whitespace-nowrap text-muted-foreground transition-[color] duration-150 ease-out select-none after:absolute after:inset-x-0 hover:text-foreground disabled:pointer-events-none disabled:opacity-50 aria-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
variant === "pill" && "h-7 rounded-md px-3 after:-inset-y-1.5",
variant === "underline" && "h-9 rounded-md px-3 after:-inset-y-0.5",
className,
)}
{...props}
/>
);
}
export function AnimatedTabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="animated-tabs-content"
className={className}
{...props}
/>
);
}