Skip to content
Uxelith
Sign in
Freev1.0.1Navigation

Animated Tabs

Tabs with an indicator that springs 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. Pill or underline.

pnpm dlx shadcn add …/r/animated-tabs.json
Licence
MIT
Packages
3
Updated
9 Oct 2026
Live

Installation

  1. Run this in a project set up with shadcn/ui:

    $ pnpm dlx shadcn@latest add https://uxelith.com/r/animated-tabs.json
  2. It adds these packages for you:

    clsxtailwind-mergemotion
  3. The source lands in your components folder as animated-tabs.tsx. It's yours: change anything.

Usage

example.tsx
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.

animated-tabs.tsx
"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

PropTypeDefaultDescription
tabs{ value, label, icon?, content, disabled? }[]—The tabs and their panels.
labelstring—Accessible name of the tab list.
value / defaultValuestringfirst enabledControlled 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.
fullWidthbooleanfalseStretch tabs across the full width.
listClassName / panelClassNamestring—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

  1. 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.

More in Navigation

All 6 in Navigation
  • NavigationFree

    Accordion

    Questions that spring open to their answers — reversible mid-way — with a hover highlight that glides between titles and a plus that folds into a minus (or a chevron). Single or multiple open, two looks.

  • NavigationPro

    Command Palette

    A ⌘K command menu with ranked fuzzy search and match highlighting. Give it a trigger and the search button grows into the palette and shrinks back on close. Inline or in a dialog.

  • NavigationFree

    Dock

    A macOS-style dock: icons swell up to 2× under the pointer or keyboard focus, labels spring in, and apps hop when launched.