Skip to content
UI Kit
Accent color

Tabs for React

Underline, pills, segmented and vertical tabs with icons and counts, arrow keys and a sliding indicator.

One of 6 components in Navigation elements

3 variants

Underline, pills or vertical

Tabs switch between views of the same subject without leaving the page. This component follows the tabs pattern of the WAI, arrow keys move between tabs, Home and End jump to the ends, and only the active tab sits in the tab order, so the keyboard reaches the panel in one step.

Underline is the quiet default for page sections and settings. Pills and segmented fit filters and switches between two to four views, segmented works well for Monthly and Yearly. Vertical puts the tabs in a column for settings with many sections. Every style carries icons and counts, and the indicator slides to the new tab instead of jumping.

Use tabs for content of equal weight, the first tab gets most of the views. Keep labels to one or two words. When the content is a sequence, use a stepper, and when people need to compare, show both views at once.

Variants

Underline

The default for pages and panels. The row scrolls sideways on phones, the active tab stays in view.

Pills and segmented

Pills for filters above content, segmented for two to four equal views.

Vertical

A side list for settings from tablet up, a scrolling row on phones. Up and down arrows move.

Details

What Tabs brings along, read from the registry and the source.

Variants
3
Version
1.0.0 · since 1.0.0
File
components/ui/tabs.tsx
Brings along
utilskiticon
npm packages
None
Languages
Texts come through props
Studio
Adjust in code

Props

The TabsProps interface from components/ui/tabs.tsx, with the comments from the code.

  • itemsrequired
    TabItem[]
  • valueoptional
    string
  • defaultValueoptional
    string
  • onValueChangeoptional
    (value: string) => void
  • variantoptional
    'underline' | 'pills' | 'segmented' | 'vertical'

    underline: a line under the active tab. pills: a filled pill. segmented: one track with a sliding thumb. vertical: a side list.

  • labelrequired
    string

    Accessible name of the tab list

  • fullWidthoptional
    boolean

    Stretch tabs over the full width

  • panelClassNameoptional
    string

Accessibility

Read from the source of the component.

  • Tabs handles the keys Home, End and Space itself.
  • Sets aria-controls, aria-label, aria-labelledby, aria-orientation and aria-selected, so screen readers announce purpose and state.
  • Uses the roles tab, tablist and tabpanel.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 287 lines
components/ui/tabs.tsx
'use client';

import { useCallback, useId, useLayoutEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useInView } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';

export interface TabItem {
  value: string;
  label: React.ReactNode;
  icon?: IconName;
  /** A small count after the label */
  count?: number;
  disabled?: boolean;
  content: React.ReactNode;
}

export interface TabsProps {
  items: TabItem[];
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  /** underline: a line under the active tab. pills: a filled pill. segmented: one track with a sliding thumb. vertical: a side list. */
  variant?: 'underline' | 'pills' | 'segmented' | 'vertical';
  /** Accessible name of the tab list */
  label: string;
  /** Stretch tabs over the full width */
  fullWidth?: boolean;
  className?: string;
  panelClassName?: string;
}

/* Tabs rise one after another when the list scrolls into view, the panel follows */
const STYLES =
  '.kitfx-tabs-in [role=tab]{animation:kitfx-tabs-in .6s var(--ease-kit) both;animation-delay:calc(var(--i,0) * 55ms)}' +
  '.kitfx-tabs-in [role=tabpanel]{animation:kitfx-tabs-in .7s var(--ease-kit) .18s both}' +
  '@keyframes kitfx-tabs-in{from{opacity:0;transform:translateY(10px)}}' +
  '@media (prefers-reduced-motion:reduce){.kitfx-tabs-in [role=tab],.kitfx-tabs-in [role=tabpanel]{animation:none}}';

const SPRING = 'cubic-bezier(0.34, 1.25, 0.64, 1)';
const EASE = 'cubic-bezier(0.2, 0.7, 0.2, 1)';

type Mark = { kind: 'line' | 'box'; x: number; y: number; w: number; h: number; animate: boolean };
const transformOf = (m: Mark) => (m.kind === 'line' ? `translateX(${m.x}px)` : `translate(${m.x}px, ${m.y}px)`);

/**
 * Tabs with full keyboard support (arrows, Home, End), automatic activation and a sliding indicator.
 * The indicator glides with a light spring, the new panel slides in from the side of the tab.
 * Rendered on the server with the first panel visible, so content is there without JavaScript.
 *   <Tabs label="Project" items={[{ value: 'overview', label: 'Overview', content: … }]} />
 */
export function Tabs({ items, value: valueProp, defaultValue, onValueChange, variant = 'underline', label, fullWidth, className, panelClassName }: TabsProps) {
  const [inner, setInner] = useState(defaultValue ?? items.find((i) => !i.disabled)?.value);
  const value = valueProp ?? inner;
  const id = useId();
  const listRef = useRef<HTMLDivElement>(null);
  const markRef = useRef<HTMLSpanElement>(null);
  const panelsRef = useRef<HTMLDivElement>(null);
  const [mark, setMark] = useState<Mark | null>(null);
  const prevMark = useRef<Mark | null>(null);
  const prevValue = useRef(value);
  const first = useRef(true);
  const vertical = variant === 'vertical';
  const [rootRef, inView] = useInView<HTMLDivElement>();
  const hidden = useRef(false);
  if (!inView) hidden.current = true;
  const entering = inView && hidden.current;
  // Scroll rows fade only on the side where more tabs wait. A vertical list is a row on phones, so it starts faded at the end.
  const [fade, setFade] = useState({ start: false, end: vertical });
  const updateFade = useCallback(() => {
    const l = listRef.current;
    if (!l) return;
    const rest = l.scrollWidth - l.clientWidth - l.scrollLeft;
    setFade((f) => (f.start === l.scrollLeft > 1 && f.end === rest > 1 ? f : { start: l.scrollLeft > 1, end: rest > 1 }));
  }, []);

  const select = (v: string) => {
    setInner(v);
    onValueChange?.(v);
  };

  const measure = useCallback(
    (animate: boolean) => {
      const el = listRef.current?.querySelector<HTMLElement>(`[data-value="${CSS.escape(value ?? '')}"]`);
      if (!el) return;
      const x = el.offsetLeft;
      const y = el.offsetTop;
      const w = el.offsetWidth;
      const h = el.offsetHeight;
      const wide = !vertical || window.matchMedia('(min-width: 48rem)').matches;
      const next: Mark =
        variant === 'underline'
          ? { kind: 'line', x: x + 8, y: 0, w: Math.max(0, w - 16), h: 2, animate }
          : vertical && !wide
            ? { kind: 'line', x, y: 0, w, h: 2, animate }
            : { kind: 'box', x, y, w, h, animate };
      setMark((m) => (m && m.kind === next.kind && m.x === next.x && m.y === next.y && m.w === next.w && m.h === next.h ? m : next));
      // Keep the active tab in view in a scrolling row
      const list = listRef.current;
      if (list && !(vertical && wide) && list.scrollWidth > list.clientWidth) {
        const left = x - 16;
        const right = x + w + 16 - list.clientWidth;
        if (list.scrollLeft > left) list.scrollTo({ left, behavior: animate ? 'smooth' : 'auto' });
        else if (list.scrollLeft < right) list.scrollTo({ left: right, behavior: animate ? 'smooth' : 'auto' });
      }
    },
    [value, vertical, variant],
  );
  useLayoutEffect(() => {
    measure(!first.current);
    updateFade();
    first.current = false;
    const ro = new ResizeObserver(() => {
      measure(false);
      updateFade();
    });
    if (listRef.current) ro.observe(listRef.current);
    return () => ro.disconnect();
  }, [measure, updateFade]);

  // The indicator moves from where it was, as a transform only (FLIP)
  useLayoutEffect(() => {
    const el = markRef.current;
    const prev = prevMark.current;
    prevMark.current = mark;
    if (!el || !mark || !prev || !mark.animate || prev.kind !== mark.kind || prefersReducedMotion()) return;
    if (prev.x === mark.x && prev.y === mark.y && prev.w === mark.w && prev.h === mark.h) return;
    const sx = mark.w ? prev.w / mark.w : 1;
    const sy = mark.h ? prev.h / mark.h : 1;
    const from = mark.kind === 'line' ? `translateX(${prev.x}px) scaleX(${sx})` : `translate(${prev.x}px, ${prev.y}px) scale(${sx}, ${sy})`;
    el.animate([{ transform: from }, { transform: transformOf(mark) }], { duration: 420, easing: SPRING });
  }, [mark]);

  // The new panel slides in from the side of the tab that was chosen
  useLayoutEffect(() => {
    const before = prevValue.current;
    prevValue.current = value;
    if (before === value || prefersReducedMotion()) return;
    const panel = panelsRef.current?.querySelector<HTMLElement>(`[data-panel="${CSS.escape(value ?? '')}"]`);
    if (!panel) return;
    const dir = Math.sign(items.findIndex((i) => i.value === value) - items.findIndex((i) => i.value === before)) || 1;
    const from = vertical ? `translateY(${dir * 10}px)` : `translateX(${dir * 16}px)`;
    panel.animate([{ opacity: 0, transform: from, filter: 'blur(3px)' }, { opacity: 1, transform: 'none', filter: 'blur(0)' }], { duration: 420, easing: EASE });
  }, [value, items, vertical]);

  const onKeyDown = (e: React.KeyboardEvent) => {
    const enabled = items.filter((i) => !i.disabled);
    const i = enabled.findIndex((x) => x.value === value);
    const next: Record<string, number> = vertical ? { ArrowDown: 1, ArrowUp: -1 } : { ArrowRight: 1, ArrowLeft: -1 };
    let target: TabItem | undefined;
    if (e.key in next) target = enabled[(i + next[e.key] + enabled.length) % enabled.length];
    else if (e.key === 'Home') target = enabled[0];
    else if (e.key === 'End') target = enabled[enabled.length - 1];
    if (!target) return;
    e.preventDefault();
    select(target.value);
    listRef.current?.querySelector<HTMLElement>(`[data-value="${CSS.escape(target.value)}"]`)?.focus();
  };

  const ready = mark !== null;
  const scrolls = variant === 'underline' || variant === 'pills' || vertical;
  const mask = scrolls && (fade.start || fade.end) ? `linear-gradient(to right, ${fade.start ? 'transparent, #000 48px' : '#000, #000'}, ${fade.end ? '#000 calc(100% - 48px), transparent' : '#000, #000'})` : undefined;
  const list = (
    <div
      ref={listRef}
      role="tablist"
      aria-label={label}
      aria-orientation={vertical ? 'vertical' : 'horizontal'}
      onKeyDown={onKeyDown}
      onScroll={scrolls ? updateFade : undefined}
      style={mask ? ({ '--kit-tabs-mask': mask } as React.CSSProperties) : undefined}
      className={cn(
        'relative isolate',
        // The base line sits on the wrapper, so the fade never thins it out
        variant === 'underline' && 'kit-no-scrollbar -mb-px flex gap-1 overflow-x-auto [mask-image:var(--kit-tabs-mask,none)]',
        variant === 'pills' && 'kit-no-scrollbar -my-1 flex gap-1 overflow-x-auto py-1 [mask-image:var(--kit-tabs-mask,none)]',
        variant === 'segmented' && 'inline-flex max-w-full rounded-lg bg-muted p-1 shadow-[inset_0_0_0_1px_var(--border)]',
        variant === 'segmented' && fullWidth && 'flex w-full',
        vertical && 'flex flex-col gap-0.5 max-md:kit-no-scrollbar max-md:-mb-px max-md:flex-row max-md:overflow-x-auto max-md:[mask-image:var(--kit-tabs-mask,none)] md:w-56 md:shrink-0',
      )}
    >
      {mark && (
        <span
          ref={markRef}
          aria-hidden
          className={cn(
            'pointer-events-none absolute left-0 -z-10 origin-top-left transition-opacity duration-500 ease-[var(--ease-kit)]',
            !inView && 'opacity-0',
            entering && 'delay-300',
            mark.kind === 'line' && 'bottom-0 h-0.5 rounded-full bg-foreground',
            mark.kind === 'box' && 'top-0',
            mark.kind === 'box' && variant === 'pills' && 'rounded-full bg-primary',
            mark.kind === 'box' && variant === 'segmented' && 'rounded-md bg-background shadow-[0_0_0_1px_var(--border),var(--shadow-sm)] dark:bg-[color-mix(in_oklab,var(--foreground)_10%,var(--background))]',
            mark.kind === 'box' && vertical && 'rounded-lg bg-accent',
          )}
          style={mark.kind === 'line' ? { width: mark.w, transform: transformOf(mark) } : { width: mark.w, height: mark.h, transform: transformOf(mark) }}
        />
      )}
      {items.map((it, i) => {
        const on = it.value === value;
        return (
          <button
            key={it.value}
            type="button"
            role="tab"
            id={`${id}-tab-${it.value}`}
            aria-selected={on}
            aria-controls={`${id}-panel-${it.value}`}
            tabIndex={on ? 0 : -1}
            disabled={it.disabled}
            data-value={it.value}
            onClick={() => select(it.value)}
            style={{ '--i': i } as React.CSSProperties}
            className={cn(
              'group/tab relative inline-flex shrink-0 select-none items-center gap-2 whitespace-nowrap text-sm font-medium outline-none transition-colors duration-200 disabled:pointer-events-none disabled:opacity-40',
              'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
              !inView && 'opacity-0',
              variant === 'underline' && cn('h-11 rounded-md px-3', on ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'),
              // Pills and segments are 36 px tall, the hit area reaches 44
              (variant === 'pills' || variant === 'segmented') && 'after:absolute after:inset-x-0 after:-inset-y-1 after:content-[""]',
              variant === 'pills' && cn('h-9 rounded-full px-4', on ? 'text-primary-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground', on && !ready && 'bg-primary'),
              variant === 'segmented' && cn('h-9 justify-center rounded-md px-4', fullWidth && 'flex-1', on ? 'text-foreground' : 'text-muted-foreground hover:text-foreground', on && !ready && 'bg-background shadow-[0_0_0_1px_var(--border)]'),
              vertical && cn('h-11 rounded-lg px-3 md:h-10 md:w-full md:justify-start', on ? 'text-foreground' : 'text-muted-foreground hover:text-foreground md:hover:bg-[color-mix(in_oklab,var(--accent)_60%,transparent)]', on && !ready && 'bg-accent'),
              variant === 'underline' && on && !ready && 'after:absolute after:inset-x-2 after:bottom-0 after:h-0.5 after:rounded-full after:bg-foreground',
            )}
          >
            {it.icon && <Icon name={it.icon} size={16} className={cn('transition-[opacity,scale] duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] motion-reduce:transition-none', on ? 'scale-110' : 'opacity-80 group-hover/tab:scale-105')} />}
            {it.label}
            {it.count !== undefined && (
              <span
                className={cn(
                  'grid h-5 min-w-5 place-items-center rounded-full px-1.5 text-[11px] font-semibold tabular-nums transition-colors duration-200',
                  on && variant === 'pills' ? 'bg-[color-mix(in_oklab,var(--primary-foreground)_22%,transparent)] text-primary-foreground' : 'bg-muted text-muted-foreground',
                  on && variant !== 'pills' && 'bg-[color-mix(in_oklab,var(--primary)_14%,var(--muted))] text-foreground',
                )}
              >
                {it.count}
              </span>
            )}
            {vertical && <Icon name="chevron-right" size={15} className={cn('ms-auto text-subtle-foreground transition-[opacity,translate] duration-300 ease-[var(--ease-kit)] max-md:hidden motion-reduce:transition-none', on ? 'translate-x-0' : '-translate-x-1 opacity-0 group-hover/tab:translate-x-0 group-hover/tab:opacity-100')} />}
          </button>
        );
      })}
    </div>
  );

  const panels = items.map((it) => (
    <div
      key={it.value}
      role="tabpanel"
      id={`${id}-panel-${it.value}`}
      aria-labelledby={`${id}-tab-${it.value}`}
      hidden={it.value !== value}
      tabIndex={0}
      data-panel={it.value}
      className={cn('outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-4 focus-visible:ring-offset-background rounded-md', !inView && 'opacity-0', panelClassName)}
    >
      {it.content}
    </div>
  ));

  const style = (
    <style href="kitfx-tabs" precedence="default">
      {STYLES}
    </style>
  );

  if (vertical) {
    return (
      <div ref={rootRef} className={cn('flex w-full flex-col gap-6 md:flex-row md:gap-10', entering && 'kitfx-tabs-in', className)}>
        {style}
        <div className="min-w-0 max-md:border-b max-md:border-border md:shrink-0">{list}</div>
        <div ref={panelsRef} className="min-w-0 flex-1">
          {panels}
        </div>
      </div>
    );
  }
  return (
    <div ref={rootRef} className={cn('w-full', entering && 'kitfx-tabs-in', className)}>
      {style}
      {variant === 'underline' ? <div className="border-b border-border">{list}</div> : list}
      <div ref={panelsRef} className="mt-6">
        {panels}
      </div>
    </div>
  );
}

Frequent questions

How do I install Tabs?

With aiw add @ai-website/tabs. The command puts components/ui/tabs.tsx into your project, the code is yours from then on. It brings along icon. It needs no further npm packages.

Which variants does Tabs have?

3 variants, Underline, Pills and segmented and Vertical. You pick one with the variant prop, for example variant="vertical".

Can an AI assistant build with Tabs?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Tabs with aiw_components and install it with aiw_add @ai-website/tabs. Name the variant in your prompt, for example variant="pills", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard