Navigation

Animated Tabs

Radix tabs with a measured pill or underline indicator that slides between triggers and retargets mid-flight.

Install

npx shadcn@latest add @paragon/animated-tabs

animated-tabs.tsx

"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}
    />
  );
}