Skip to content
    Style: Wireframe

    Components · Navigation

    Tabs

    Panels behind a row of tabs, or page links that look like tabs. Arrow keys, Home and End.

    38% of the 2,073 sites in The UI Index have a navigation (790 sites).

    Used in UI Pro: Feature tabs

    Three pages changed since the last deploy.

    This week: 23 deploys.

    Both previews render the same markup; the HTML tab below is that markup without a framework.

    Install

    React
    npx shadcn@latest add https://www.themes.dev/ui/r/tabs.json
    Astro
    npx shadcn@latest add https://www.themes.dev/ui/r/astro/tabs.json

    Or copy the files below into your project. They need clsx, tailwind-merge, and the themes.dev UI theme. The HTML needs only the compiled CSS and the script.

    tabs.tsx
    import { useState, type KeyboardEvent, type ReactNode } from 'react';
    import { cn } from '@/lib/utils';
    import { countClasses, panelId, tabId, tabListVariants, tabPanelClasses, tabVariants, type TabItem, type TabsVariants } from './tabs.variants';
    
    export type { TabItem };
    
    export interface TabsProps extends TabsVariants {
      tabs: TabItem[];
      /** Id of the selected tab (the current page in nav mode). Default: the first enabled tab. */
      selected?: string;
      /** The accessible name of the tab list. */
      label: string;
      /** Unique on the page; prefixes the element ids. */
      id: string;
      /** Panel content per tab id (the Astro version's named slots). */
      panels?: Record<string, ReactNode>;
      onTabsChange?: (detail: { id: string }) => void;
      className?: string;
    }
    
    /** WAI-ARIA tabs with automatic activation: click, Left/Right (wrapping, disabled skipped), Home/End. */
    export function Tabs({ tabs, selected, label, id, variant, panels = {}, onTabsChange, className }: TabsProps) {
      const initial = selected ?? tabs.find((t) => !t.disabled)?.id;
      const [current, setCurrent] = useState(initial);
      const [prev, setPrev] = useState(selected);
      if (selected !== prev) {
        setPrev(selected);
        setCurrent(initial);
      }
      const isNav = tabs.length > 0 && tabs.every((t) => t.href);
    
      if (isNav)
        return (
          <nav aria-label={label} className={cn('w-full', className)}>
            <ul className={cn(tabListVariants({ variant }), 'm-0 list-none p-0')}>
              {tabs.map((t) => (
                <li key={t.id} className="shrink-0">
                  <a href={t.disabled ? undefined : t.href} aria-disabled={t.disabled ? 'true' : undefined} role={t.disabled ? 'link' : undefined} aria-current={t.id === current ? 'page' : undefined} className={tabVariants({ variant })}>
                    {t.label}
                    {t.count !== undefined && <span className={countClasses}>{t.count}</span>}
                  </a>
                </li>
              ))}
            </ul>
          </nav>
        );
    
      const select = (t: TabItem, focus: boolean) => {
        setCurrent(t.id);
        const el = document.getElementById(tabId(id, t.id));
        if (focus) el?.focus();
        el?.closest('[data-tabs]')?.dispatchEvent(new CustomEvent('tabs-change', { bubbles: true, detail: { id: tabId(id, t.id) } }));
        onTabsChange?.({ id: tabId(id, t.id) });
      };
      const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>, t: TabItem) => {
        const enabled = tabs.filter((x) => !x.disabled);
        const i = enabled.indexOf(t);
        const next =
          e.key === 'ArrowRight' ? enabled[(i + 1) % enabled.length]
          : e.key === 'ArrowLeft' ? enabled[(i - 1 + enabled.length) % enabled.length]
          : e.key === 'Home' ? enabled[0]
          : e.key === 'End' ? enabled.at(-1)
          : undefined;
        if (!next) return;
        e.preventDefault();
        select(next, true);
      };
    
      return (
        <div data-tabs="" className={cn('w-full', className)}>
          <div role="tablist" aria-label={label} className={tabListVariants({ variant })}>
            {tabs.map((t) => (
              <button key={t.id} type="button" role="tab" id={tabId(id, t.id)} aria-controls={panelId(id, t.id)} aria-selected={t.id === current ? 'true' : 'false'} tabIndex={t.id === current ? 0 : -1} disabled={t.disabled} className={tabVariants({ variant })} onClick={() => select(t, false)} onKeyDown={(e) => onKeyDown(e, t)}>
                {t.label}
                {t.count !== undefined && <span className={countClasses}>{t.count}</span>}
              </button>
            ))}
          </div>
          {tabs.map((t) => (
            <div key={t.id} role="tabpanel" id={panelId(id, t.id)} aria-labelledby={tabId(id, t.id)} hidden={t.id !== current} tabIndex={0} className={tabPanelClasses}>
              {panels[t.id]}
            </div>
          ))}
        </div>
      );
    }
    tabs.variants.ts
    import { cva, type VariantProps } from 'class-variance-authority';
    
    /** The row of tabs. line: a hairline under the row, the selected tab underlined in ink. pill: a muted track, the
     *  selected tab a raised chip. */
    export const tabListVariants = cva('flex max-w-full items-center overflow-x-auto [scrollbar-width:none]', {
      variants: {
        variant: {
          // contain: inline-size keeps the row from widening an auto-sized parent (a grid track would grow to fit every
          // tab); it fills its space and scrolls instead. The edges shade where more tabs are hidden (scrolling shadows:
          // the covers scroll with the tabs, the shades stay; no script). --tabs-surface: the ground under the row; the shade turns light in dark mode.
          line: 'w-full gap-1 border-b [contain:inline-size] [background:linear-gradient(to_right,var(--tabs-surface,var(--background))_50%,transparent)_left/2rem_100%_no-repeat_local,linear-gradient(to_left,var(--tabs-surface,var(--background))_50%,transparent)_right/2rem_100%_no-repeat_local,radial-gradient(farthest-side_at_0_50%,var(--tabs-shade,rgb(0_0_0/0.22)),transparent)_left/0.75rem_100%_no-repeat_scroll,radial-gradient(farthest-side_at_100%_50%,var(--tabs-shade,rgb(0_0_0/0.22)),transparent)_right/0.75rem_100%_no-repeat_scroll]',
          pill: 'inline-flex w-fit gap-1 rounded-lg bg-muted p-1',
        },
      },
      defaultVariants: { variant: 'line' },
    });
    
    /** One tab. Selected comes from aria-selected (or aria-current in nav mode), so the state is the DOM's, not a class. */
    export const tabVariants = cva(
      'relative inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 text-sm font-medium whitespace-nowrap text-muted-foreground transition-[color,background-color,box-shadow] select-none hover:text-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0',
      {
        variants: {
          variant: {
            // The underline grows from the centre when a tab is selected (no movement with reduced motion).
            // The ring sits inside the tab: the row scrolls (overflow-x), which would clip a ring drawn outside it.
            line: 'h-10 px-3 outline-offset-[-6px] after:absolute after:inset-x-2 after:-bottom-px after:h-0.5 after:scale-x-0 after:rounded-full after:bg-foreground after:transition-transform after:duration-200 hover:bg-accent/60 active:bg-pressed aria-selected:text-foreground aria-selected:after:scale-x-100 aria-[current=page]:text-foreground aria-[current=page]:after:scale-x-100 rounded-md',
            pill: 'h-8 rounded-md px-3 hover:bg-input/45 hover:text-foreground active:scale-[0.97] active:bg-pressed aria-selected:hover:shadow-[0_1px_3px_rgb(0_0_0/0.14),0_0_0_1px_var(--input)] aria-selected:active:bg-background/70 aria-selected:bg-background aria-selected:text-foreground aria-selected:shadow-[0_1px_2px_rgb(0_0_0/0.08),0_0_0_1px_var(--border)] aria-[current=page]:bg-background aria-[current=page]:text-foreground',
          },
        },
        defaultVariants: { variant: 'line' },
      },
    );
    
    /** A panel. It takes focus (tabindex 0) so its content is one Tab stop away from the list. */
    /** A panel. It takes focus (tabindex 0); its content is inset 12px from the ring, with no shift in layout. */
    export const tabPanelClasses = 'mt-4 -mx-3 rounded-md px-3';
    
    export type TabsVariants = VariantProps<typeof tabListVariants>;
    
    export interface TabItem {
      /** Unique within the tabs; the panel's slot name (Astro) or key in `panels` (React). */
      id: string;
      label: string;
      /** A count after the label, muted. */
      count?: number;
      disabled?: boolean;
      /** Makes the tab a page link. When every tab has an href the tabs render as a <nav> with aria-current. */
      href?: string;
    }
    
    export const tabId = (id: string, tab: string) => `${id}-tab-${tab}`;
    export const panelId = (id: string, tab: string) => `${id}-panel-${tab}`;
    export const countClasses = 'font-normal text-muted-foreground tabular-nums';