Skip to content
Seamless UI

Tabs

Save

Switch between related panels with an underline that follows the active tab on a hairline, sliding on click and jumping on keyboard selection.

Category
Core
Version
0.2.0
Frameworks
Next.js, Vite + React
Styling
Tailwind CSS v4

Preview

Tabs preview
Three deploys this week, all healthy.

Installation

npx @seamlessui/cli add tabs

Pre-release@seamlessui/cli is not on npm yet. Install from a local build.

Review the plan first. A dry run lists every file and package without changing anything:

npx @seamlessui/cli add tabs --dry-run

Usage

tabs-example.tsx
import { Tabs, TabsList, TabsPanel, TabsTab } from "@/components/seamless/ui/tabs";

export default function TabsExample() {
  return (
    <Tabs defaultValue="overview" className="w-full max-w-md">
      <TabsList aria-label="Project">
        <TabsTab value="overview">Overview</TabsTab>
        <TabsTab value="activity">Activity</TabsTab>
        <TabsTab value="settings">Settings</TabsTab>
      </TabsList>
      <TabsPanel value="overview">Three deploys this week, all healthy.</TabsPanel>
      <TabsPanel value="activity">Ada merged “Add rate limits” two hours ago.</TabsPanel>
      <TabsPanel value="settings">Builds run on every push to main.</TabsPanel>
    </Tabs>
  );
}

API

PropTypeDefaultDescription
Tabs.defaultValueany0Value of the tab that is active initially, when uncontrolled.
Tabs.valueany—Value of the active tab, when controlled. Pair with onValueChange.
Tabs.onValueChange(value: any, details) => void—Called when the active tab changes.
Tabs.orientation"horizontal" | "vertical""horizontal"Lays the tabs out in a row or a column and sets which arrow keys move between them.
TabsList.activateOnFocusbooleanfalseActivates a tab as soon as arrow keys focus it, instead of on Enter or Space.
TabsList.loopFocusbooleantrueWraps arrow-key focus from the last tab to the first and back.
TabsTab.valueany—Identifies the tab. Must match a TabsPanel value.
TabsTab.disabledbooleanfalseSkips the tab during keyboard navigation and ignores clicks.
TabsPanel.valueany—Shown while the tab with the same value is active.
TabsPanel.keepMountedbooleanfalseKeeps the panel in the DOM, hidden, while another tab is active.

Accessibility

  • Base UI renders the tablist, tab, and tabpanel roles and links each tab to its panel with aria-controls and aria-labelledby.
  • Arrow keys move focus between tabs (Left/Right, or Up/Down when vertical); Home and End jump to the first and last tab.
  • Only the active tab is in the tab order, so Tab moves from the tab list straight into the panel.
  • The active tab is marked with aria-selected and an underline bar, not only a color change.
  • Give TabsList an aria-label that names the group.
  • The underline slides with a 350ms spring only for pointer selection; keyboard selection moves it without animating, and with reduced motion it never slides. Panels fade in over 150ms, removed with reduced motion.

Files

Installed into src/components/seamless, or components/seamless when the project has no src directory.

  • src/components/seamless/ui/tabs.tsxtabs
  • npm packages: @base-ui/react@^1.9.0React and Tailwind CSS v4 required

Source

The exact source the CLI installs, from /r/items/react/tabs/0.2.0.json.

ui/tabs.tsxShow
ui/tabs.tsx
"use client";

import { Tabs as BaseTabs } from "@base-ui/react/tabs";
import { type KeyboardEvent, type PointerEvent, useState } from "react";

function cx(...classes: Array<string | false | null | undefined>) {
  return classes.filter(Boolean).join(" ");
}

// A critically damped spring (bounce 0) settling in about 350ms, sampled for CSS linear().
const spring =
  "ease-[linear(0,0.044,0.144,0.263,0.384,0.495,0.593,0.675,0.744,0.8,0.844,0.88,0.908,0.929,0.946,0.959,0.969,0.977,0.982,0.987,1)]";

export interface TabsProps extends Omit<BaseTabs.Root.Props, "className"> {
  className?: string;
}

/**
 * Groups a tab list with its panels. Set `defaultValue` (or `value` + `onValueChange`) to a tab's `value`.
 * Tabs use manual activation: arrow keys move focus, Enter or Space selects.
 */
export function Tabs({ className, ...props }: TabsProps) {
  return (
    <BaseTabs.Root
      className={cx("flex flex-col gap-4 data-[orientation=vertical]:flex-row data-[orientation=vertical]:gap-6", className)}
      {...props}
    />
  );
}

export interface TabsListProps extends Omit<BaseTabs.List.Props, "className"> {
  className?: string;
}

/**
 * The row of tabs on a hairline. An underline follows the active tab: it slides with a spring
 * when a tab is clicked, and jumps without animating when the selection changes from the keyboard.
 */
export function TabsList({ className, children, onPointerDown, onKeyDown, ...props }: TabsListProps) {
  const [input, setInput] = useState<"pointer" | "keyboard">("pointer");

  return (
    <BaseTabs.List
      data-input={input}
      onPointerDown={(event: PointerEvent<HTMLDivElement>) => {
        setInput("pointer");
        onPointerDown?.(event as never);
      }}
      onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
        setInput("keyboard");
        onKeyDown?.(event as never);
      }}
      className={cx(
        "group/tabs relative z-0 flex gap-1 border-zinc-950/10 dark:border-white/10",
        "data-[orientation=horizontal]:border-b data-[orientation=horizontal]:pb-1.5",
        "data-[orientation=vertical]:flex-col data-[orientation=vertical]:border-l data-[orientation=vertical]:pl-1.5",
        className,
      )}
      {...props}
    >
      {children}
      <BaseTabs.Indicator
        className={cx(
          "absolute -z-10 rounded-full bg-zinc-950 dark:bg-white",
          "transition-none group-data-[input=pointer]/tabs:transition-[translate,width,height] group-data-[input=pointer]/tabs:duration-350",
          spring,
          "motion-reduce:transition-none group-data-[input=pointer]/tabs:motion-reduce:transition-none",
          // Horizontal: a 2px bar on the hairline under the active tab.
          "data-[orientation=horizontal]:-bottom-px data-[orientation=horizontal]:left-0 data-[orientation=horizontal]:h-0.5",
          "data-[orientation=horizontal]:w-(--active-tab-width) data-[orientation=horizontal]:translate-x-(--active-tab-left)",
          // Vertical: a 2px bar on the hairline beside the active tab.
          "data-[orientation=vertical]:top-0 data-[orientation=vertical]:-left-px data-[orientation=vertical]:w-0.5",
          "data-[orientation=vertical]:h-(--active-tab-height) data-[orientation=vertical]:translate-y-(--active-tab-top)",
        )}
      />
    </BaseTabs.List>
  );
}

export interface TabsTabProps extends Omit<BaseTabs.Tab.Props, "className"> {
  className?: string;
}

/** A single tab. Its `value` must match a `TabsPanel`'s `value`. */
export function TabsTab({ className, ...props }: TabsTabProps) {
  return (
    <BaseTabs.Tab
      className={cx(
        "relative inline-flex h-8 cursor-pointer items-center justify-center gap-2 rounded-md px-2.5 text-sm font-medium whitespace-nowrap select-none",
        "text-zinc-500 transition-[background-color,color] duration-150 ease-out motion-reduce:transition-none",
        "hover:bg-zinc-950/[0.04] hover:text-zinc-950 data-active:text-zinc-950",
        "dark:text-zinc-400 dark:hover:bg-white/[0.06] dark:hover:text-white dark:data-active:text-white",
        "focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-sky-500",
        "data-disabled:pointer-events-none data-disabled:opacity-45",
        "data-[orientation=vertical]:justify-start [&_svg]:size-4 [&_svg]:shrink-0",
        className,
      )}
      {...props}
    />
  );
}

export interface TabsPanelProps extends Omit<BaseTabs.Panel.Props, "className"> {
  className?: string;
}

/** Content shown while the tab with the same `value` is active. Fades in quickly when shown. */
export function TabsPanel({ className, ...props }: TabsPanelProps) {
  return (
    <BaseTabs.Panel
      className={cx(
        "rounded-lg text-sm leading-relaxed text-zinc-600 dark:text-zinc-400",
        "transition-opacity duration-150 ease-out starting:opacity-0 motion-reduce:transition-none",
        "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500",
        className,
      )}
      {...props}
    />
  );
}