Animated Tabs for React

Accessible tabs with icon support, sliding indicator, and optional content panels. Keyboard navigable.

Component

Profile content goes here.

Installation

 

Usage

Import

Add the Tabs import.

import { Tabs } from "@/components/tabs";

Use

Pass tabs array with id, label, icon.

<Tabs
  tabs={[{ id: "tab1", label: "Tab 1", icon: User }]}
/>;

Guidelines

  • Each tab requires id, label, and icon (LucideIcon from lucide-react).
  • showContent=true renders tab.content in panels below the tab bar.
  • Use onTabChange to react to tab selection (e.g. scroll, fetch data).
  • Keyboard: ArrowLeft and ArrowRight navigate between tabs.

Props

All props are optional unless marked required. Use these to customize every aspect of the component.

PropTypeDefaultDescription
tabsTab[]-Array of tab objects with id, label, icon, and optional content.
defaultSelectedstring-Initially selected tab id.
selectedstring-Controlled selected tab id.
onTabChange(id: string) => void-Callback when tab changes.
classNamestring""Additional CSS classes for the container.
showContentbooleanfalseWhether to render tab content panels.
contentClassNamestring""Classes for the content container.

Accessibility

  • Uses correct ARIA roles: a tablist with aria-label Tabs, each button has role tab with aria-selected, and panels have role tabpanel with aria-labelledby linking back to their tab.
  • Implements roving tabindex so only the selected tab is in the tab order (tabIndex 0) while the rest are tabIndex -1, matching the tabs interaction pattern.
  • ArrowLeft and ArrowRight move between tabs with wraparound, call preventDefault, and programmatically move focus to the newly selected tab.
  • Focus is clearly visible via focus-visible ring utilities (ring-2, ring-ring, ring-offset-2) rather than relying only on the active indicator.
  • When showContent is set, each tab adds aria-controls pointing to its panel id so assistive tech can associate the tab with its content.
  • Honours reduced-motion: both the tab buttons and the sliding indicator apply motion-reduce:transition-none to disable transitions for users who prefer reduced motion.

Performance

  • The active-tab indicator animates only the transform property (translateX) which is GPU-composited and avoids layout or paint, keeping the slide cheap.
  • Tab buttons use transition-colors for hover and selection state changes, a lightweight paint-only transition.
  • Animations are gated by motion-reduce:transition-none, so reduced-motion users skip the transform and color transitions entirely.
  • No requestAnimationFrame, IntersectionObserver, or will-change is used; the only timer is a single one-shot setTimeout(0) to move focus after navigation, so there are no recurring listeners or timers to clean up.
  • Inactive panels stay mounted but hidden via the hidden class rather than being unmounted, trading a little extra DOM for instant tab switches.

Examples

With content

Three tabs with content panels.

Profile content goes here.
import { Tabs } from "@/components/tabs";
import { FileText, Settings, User } from "lucide-react";

<Tabs
  tabs={[
    {
      id: "profile",
      label: "Profile",
      icon: User,
      content: <p>Profile content</p>,
    },
    {
      id: "docs",
      label: "Docs",
      icon: FileText,
      content: <p>Docs content</p>,
    },
    {
      id: "settings",
      label: "Settings",
      icon: Settings,
      content: <p>Settings content</p>,
    },
  ]}
  defaultSelected="profile"
  showContent={true}
/>;

Navigation only

Tabs without content, use onTabChange for navigation.

Profile content goes here.
import { Tabs } from "@/components/tabs";
import { Code2, Layers } from "lucide-react";

<Tabs
  tabs={[
    { id: "code", label: "Code", icon: Code2 },
    { id: "preview", label: "Preview", icon: Layers },
  ]}
  defaultSelected="code"
  onTabChange={(id) => console.log(id)}
/>;

Last updated on Jun 19

Made with ❤️ by Pulkit &

© 2026 Pulkit. All rights reserved

Last updated: