Talisson Costa
← All components

Animated Tabs

Segmented tabs with a pill that slides between items using a shared layout animation.

  • layoutId
  • spring
  • a11y: tabs pattern
A shared layoutId lets the pill travel between tabs instead of blinking.

Install

npx shadcn@latest add https://tcosta.dev/r/animated-tabs.json

Or copy the files below into components/animated-tabs/. They use the shadcn/ui theme tokens and cn from @/lib/utils, and need motion.

Source

tabs.tsx
'use client';

import { useId, type ReactNode } from 'react';
import { TabsContext } from './tabs-context';

type TabsProps = {
  value: string;
  onValueChange: (value: string) => void;
  className?: string;
  children: ReactNode;
};

export function Tabs({ value, onValueChange, className, children }: TabsProps) {
  const id = useId();

  return (
    <TabsContext value={{ id, value, onValueChange }}>
      <div className={className}>{children}</div>
    </TabsContext>
  );
}
tabs-list.tsx
'use client';

import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { useTabs } from './tabs-context';
import { useTabsKeyboard } from './use-tabs-keyboard';

type TabsListProps = {
  label: string;
  className?: string;
  children: ReactNode;
};

export function TabsList({ label, className, children }: TabsListProps) {
  const { onValueChange } = useTabs();
  const onKeyDown = useTabsKeyboard(onValueChange);

  return (
    <div
      role="tablist"
      aria-label={label}
      onKeyDown={onKeyDown}
      className={cn('inline-flex items-center gap-1 rounded-full bg-muted p-1', className)}
    >
      {children}
    </div>
  );
}
tabs-trigger.tsx
'use client';

import { motion } from 'motion/react';
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { contentId, triggerId, useTabs } from './tabs-context';

type TabsTriggerProps = {
  value: string;
  className?: string;
  children: ReactNode;
};

export function TabsTrigger({ value, className, children }: TabsTriggerProps) {
  const tabs = useTabs();
  const isSelected = tabs.value === value;

  return (
    <button
      type="button"
      role="tab"
      id={triggerId(tabs.id, value)}
      data-value={value}
      aria-selected={isSelected}
      aria-controls={contentId(tabs.id, value)}
      tabIndex={isSelected ? 0 : -1}
      onClick={() => tabs.onValueChange(value)}
      className={cn(
        'relative rounded-full px-4 py-1.5 text-sm font-medium text-muted-foreground transition-colors duration-200 outline-none',
        'hover:text-foreground aria-selected:text-foreground',
        'focus-visible:ring-[3px] focus-visible:ring-ring/50',
        className,
      )}
    >
      {isSelected && (
        <motion.span
          layoutId={`${tabs.id}-indicator`}
          transition={{ type: 'spring', duration: 0.35, bounce: 0.2 }}
          className="absolute inset-0 rounded-full bg-background shadow-sm ring-1 ring-border"
        />
      )}
      <span className="relative">{children}</span>
    </button>
  );
}
tabs-content.tsx
'use client';

import { motion } from 'motion/react';
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { contentId, triggerId, useTabs } from './tabs-context';

type TabsContentProps = {
  value: string;
  className?: string;
  children: ReactNode;
};

export function TabsContent({ value, className, children }: TabsContentProps) {
  const tabs = useTabs();
  if (tabs.value !== value) return null;

  return (
    <motion.div
      role="tabpanel"
      id={contentId(tabs.id, value)}
      aria-labelledby={triggerId(tabs.id, value)}
      tabIndex={0}
      initial={{ opacity: 0, y: 4 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{ duration: 0.15, ease: 'easeOut' }}
      className={cn('outline-none', className)}
    >
      {children}
    </motion.div>
  );
}
tabs-context.ts
'use client';

import { createContext, useContext } from 'react';

type TabsContextValue = {
  id: string;
  value: string;
  onValueChange: (value: string) => void;
};

export const TabsContext = createContext<TabsContextValue | null>(null);

export function useTabs() {
  const context = useContext(TabsContext);
  if (!context) throw new Error('Tabs parts must be rendered inside <Tabs>.');
  return context;
}

export const triggerId = (id: string, value: string) => `${id}-tab-${value}`;
export const contentId = (id: string, value: string) => `${id}-panel-${value}`;
use-tabs-keyboard.ts
import type { KeyboardEvent } from 'react';

function nextIndex(key: string, current: number, count: number) {
  switch (key) {
    case 'ArrowRight':
      return (current + 1) % count;
    case 'ArrowLeft':
      return (current - 1 + count) % count;
    case 'Home':
      return 0;
    case 'End':
      return count - 1;
  }
}

/** WAI-ARIA tabs keyboard pattern: ←/→ move (wrapping), Home/End jump. */
export function useTabsKeyboard(onSelect: (value: string) => void) {
  return (event: KeyboardEvent<HTMLElement>) => {
    const tabs = [...event.currentTarget.querySelectorAll<HTMLElement>('[role="tab"]')];
    const next = nextIndex(event.key, tabs.indexOf(event.target as HTMLElement), tabs.length);
    if (next === undefined) return;

    event.preventDefault();
    tabs[next].focus();
    onSelect(tabs[next].dataset.value!);
  };
}
index.ts
export { Tabs } from './tabs';
export { TabsContent } from './tabs-content';
export { TabsList } from './tabs-list';
export { TabsTrigger } from './tabs-trigger';