Navigation
Animated Tabs
A controlled tab strip whose active indicator glides between tabs — underline and pill variants via motion layout animation.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/animated-tabs.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "AnimatedTabs" component using the
motion package (motion/react) for the sliding indicator.
Contract
- Tab strip only — no panels. The consumer owns the content area and wires it
to the controlled value.
- Props: tabs: { id: string; label: string }[]; controlled value: string plus
onChange: (id: string) => void; variant?: "underline" | "pill" (default
"underline"); className. forwardRef to the root div and spread remaining
div props (Omit "onChange" from the native attributes to avoid the clash).
Behavior
- The active tab renders an indicator <motion.span> with a layoutId; when the
selection moves, motion's shared layout animation (FLIP) glides the
indicator from the old tab's box to the new one — no manual DOM measuring.
- Scope the layoutId with React.useId() so multiple instances on one page
never animate into each other. Set initial={false} so mount doesn't animate.
- Transition: spring (stiffness 500, damping 40). Read motion's
useReducedMotion(); when true, swap the transition for { duration: 0 } so
switching is instant but fully functional.
- Accessibility: container role="tablist"; each tab role="tab",
aria-selected, type="button". Roving tabindex: only the active tab has
tabIndex={0} (fall back to the first tab when value matches nothing).
ArrowRight / ArrowLeft move cyclically and select (selection follows
focus, calling onChange and focusing the target); Home / End jump to the
first / last tab. Chain the consumer's onKeyDown first and bail if it
called preventDefault.
- Overflow: labels never wrap (whitespace-nowrap, shrink-0); the strip is
max-w-full overflow-x-auto so long tab sets scroll horizontally instead of
breaking layout. focus() during keyboard navigation scrolls offscreen tabs
into view for free.
Rendering & styling
- Semantic tokens only. underline: container gap-1 border-b; indicator is a
2px bar (h-0.5 bg-primary) pinned inset-x-0 bottom-0 inside the active
tab. pill: container rounded-lg bg-muted p-1; indicator is an inset-0
rounded-md bg-background shadow-sm layer under the label.
- Tab text: active text-foreground, inactive text-muted-foreground with
hover:text-foreground and transition-colors. Label sits in a relative z-10
span above the indicator; the indicator is aria-hidden and
pointer-events-none.
- Focus ring: focus-visible:ring-2 ring-ring with ring-inset (an outset ring
would clip inside the scrollable container). Merge consumer className on
the root via cn().
Customization levers
- Indicator feel: the spring's stiffness/damping pair is the only motion
knob — 500/40 is snappy; lower stiffness (~300) reads softer, lower
damping adds overshoot.
- Tokens: indicator color (bg-primary underline / bg-background pill),
container bg-muted, and text tokens all remap freely; the layout animation
is style-agnostic.
- Density: px/py on the tab buttons and container p-1/gap-1 set the scale;
keep the underline bar at h-0.5 or h-[3px] — thicker starts reading as a
border.
- Router-driven tabs: derive value from usePathname() (or a search param)
and make onChange call router.push — the strip stays controlled and gains
URL state for free.
- Add a variant: one container class branch + one indicator class branch;
the a11y, keyboard and layout-animation logic is variant-independent.Concepts
- Shared layout animation — the indicator only ever exists inside the active tab; motion's
layoutIdFLIP animation interpolates between its last and next measured box, so the glide needs zero manual DOM measurement or resize handling. - Instance-scoped layoutId —
layoutIdis a global namespace in motion; prefixing it withuseId()keeps two tab strips on one page from animating into each other. - Roving tabindex — one Tab stop for the whole strip: only the active tab is focusable, arrows move within the group. This is the WAI-ARIA tabs pattern and keeps keyboard traversal fast.
- Selection follows focus — Arrow/Home/End both move focus and select, so keyboard users see the indicator glide with each keypress instead of needing a second activation step.
- Strip, not tabs-with-panels — the component deliberately stops at the tablist; the consumer maps
valueto panels, router segments or filters, which is what makes it drop into any layout. - Reduced-motion honesty — under
prefers-reduced-motionthe spring is swapped for a zero-duration transition: the indicator still moves (function intact), it just stops animating.