# Animated Tabs

> Tabs with a sliding active indicator and arrow-key navigation.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/animated-tabs
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/animated-tabs.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

No extra dependencies.

## Usage

```tsx title="components/animated-tabs-demo.tsx"
import { AnimatedTabs } from "@/components/animated-tabs"

const tabs = [
  { id: "overview", label: "Overview" },
  { id: "analytics", label: "Analytics" },
  { id: "reports", label: "Reports" },
  { id: "settings", label: "Settings" },
]

export default function AnimatedTabsDemo() {
  return (
    <div className="flex flex-col items-center gap-10">
      <AnimatedTabs tabs={tabs} />
      <AnimatedTabs tabs={tabs} variant="underline" defaultValue="analytics" />
    </div>
  )
}
```

## Source

```tsx title="components/animated-tabs.tsx"
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

export type AnimatedTab = { id: string; label: React.ReactNode }

export interface AnimatedTabsProps extends Omit<React.ComponentProps<"div">, "onChange"> {
  tabs: AnimatedTab[]
  value?: string
  defaultValue?: string
  onChange?: (id: string) => void
  /** "pill" fills the active tab, "underline" draws a bar below it */
  variant?: "pill" | "underline"
}

export function AnimatedTabs({
  tabs,
  value,
  defaultValue,
  onChange,
  variant = "pill",
  className,
  ...props
}: AnimatedTabsProps) {
  const [internal, setInternal] = React.useState(defaultValue ?? tabs[0]?.id)
  const active = value ?? internal
  const refs = React.useRef(new Map<string, HTMLButtonElement>())
  const [indicator, setIndicator] = React.useState<{ left: number; width: number } | null>(null)

  React.useLayoutEffect(() => {
    const measure = () => {
      const el = active ? refs.current.get(active) : undefined
      if (el) setIndicator({ left: el.offsetLeft, width: el.offsetWidth })
    }
    measure()
    window.addEventListener("resize", measure)
    return () => window.removeEventListener("resize", measure)
  }, [active, tabs])

  const select = (id: string) => {
    setInternal(id)
    onChange?.(id)
  }

  const onKeyDown = (event: React.KeyboardEvent) => {
    const index = tabs.findIndex((t) => t.id === active)
    const delta = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0
    if (!delta) return
    event.preventDefault()
    const next = tabs[(index + delta + tabs.length) % tabs.length]
    select(next.id)
    refs.current.get(next.id)?.focus()
  }

  return (
    <div
      role="tablist"
      onKeyDown={onKeyDown}
      className={cn(
        "relative inline-flex items-center",
        variant === "pill" ? "rounded-full border bg-muted p-1" : "gap-1 border-b",
        className
      )}
      {...props}
    >
      {indicator && (
        <span
          aria-hidden
          className={cn(
            "absolute transition-[left,width] duration-300 ease-[cubic-bezier(.2,.8,.2,1)]",
            variant === "pill"
              ? "inset-y-1 rounded-full bg-background shadow-sm ring-1 ring-foreground/5"
              : "-bottom-px h-0.5 rounded-full bg-foreground"
          )}
          style={{ left: indicator.left, width: indicator.width }}
        />
      )}
      {tabs.map((tab) => (
        <button
          key={tab.id}
          ref={(el) => {
            if (el) refs.current.set(tab.id, el)
            else refs.current.delete(tab.id)
          }}
          role="tab"
          aria-selected={tab.id === active}
          tabIndex={tab.id === active ? 0 : -1}
          onClick={() => select(tab.id)}
          className={cn(
            "relative z-10 px-4 text-sm font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring",
            variant === "pill" ? "h-8 rounded-full" : "h-10 rounded-md",
            tab.id === active ? "text-foreground" : "text-muted-foreground hover:text-foreground"
          )}
        >
          {tab.label}
        </button>
      ))}
    </div>
  )
}
```
