Componentnavigation1.48 KB gzipZero dependencies

Tabs

Segmented panel switch with roving keyboard focus.

Preview

Live Preview
Preview panel content.

Installation

Tokens guide →

Add to your project via CLI (zero dependencies, code you own):

npx bigbullui add tabs

Or install the full npm package:

npm install bigbullui
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";

<Tabs defaultValue="one">
  <TabsList>
    <TabsTrigger value="one">One</TabsTrigger>
    <TabsTrigger value="two">Two</TabsTrigger>
  </TabsList>
  <TabsContent value="one">First.</TabsContent>
  <TabsContent value="two">Second.</TabsContent>
</Tabs>

Usage guidance

Use Tabs when a ticket stub surface needs Segmented panel switch with roving keyboard focus. Keep props minimal and prefer composition with sibling stubs.

Avoid Tabs for long form flows or dense data grids where a dedicated layout block fits better.

Source code

tabs.tsx

Zero dependencies (only React + utils). Copy and paste directly into your project:

"use client";

import * as React from "react";
import { cn } from "./lib/utils";

type TabsContextValue = { value: string; setValue: (v: string) => void; baseId: string; animated: boolean };
const TabsCtx = React.createContext<TabsContextValue | null>(null);

export type TabsProps = React.HTMLAttributes<HTMLDivElement> & {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  /** Dual-mode: false renders instantly with no motion. Default true. */
  animated?: boolean;
};

export function Tabs({ value, defaultValue, onValueChange, animated = true, className, children, ...props }: TabsProps) {
  const [internal, setInternal] = React.useState(defaultValue ?? "");
  const current = value ?? internal;

  const setValue = React.useCallback(
    (next: string) => {
      if (value === undefined) setInternal(next);
      onValueChange?.(next);
    },
    [value, onValueChange]
  );

  return (
    <TabsCtx.Provider value={{ value: current, setValue, baseId: React.useId(), animated }}>
      <div className={className} {...props}>
        {children}
      </div>
    </TabsCtx.Provider>
  );
}

export function TabsList({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    const tabs = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>("[role=tab]:not(:disabled)"));
    if (tabs.length === 0) return;
    const currentIndex = tabs.findIndex((tab) => tab === document.activeElement);
    let nextIndex = currentIndex;
    if (event.key === "ArrowRight" || event.key === "ArrowDown") nextIndex = (currentIndex + 1) % tabs.length;
    else if (event.key === "ArrowLeft" || event.key === "ArrowUp") nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
    else if (event.key === "Home") nextIndex = 0;
    else if (event.key === "End") nextIndex = tabs.length - 1;
    else return;
    event.preventDefault();
    tabs[nextIndex]!.focus();
    tabs[nextIndex]!.click();
  };

  return (
    <div
      role="tablist"
      onKeyDown={handleKeyDown}
      className={cn("inline-flex h-10 items-center justify-center gap-1 rounded-md border border-dashed border-foreground/50 bg-transparent p-1 text-muted-foreground", className)}
      {...props}
    />
  );
}

export type TabsTriggerProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
  value: string;
  /** Dual-mode override: false renders instantly with no motion. Defaults to Tabs animated. */
  animated?: boolean;
};

export function TabsTrigger({ value, animated: animatedProp, className, onClick, ...props }: TabsTriggerProps) {
  const ctx = React.useContext(TabsCtx);
  const isActive = ctx?.value === value;
  const animated = animatedProp ?? ctx?.animated ?? true;
  return (
    <button
      type="button"
      role="tab"
      id={`${ctx?.baseId}-tab-${value}`}
      aria-selected={isActive}
      aria-controls={`${ctx?.baseId}-panel-${value}`}
      tabIndex={isActive ? 0 : -1}
      data-state={isActive ? "active" : "inactive"}
      onClick={(event) => {
        ctx?.setValue(value);
        onClick?.(event);
      }}
      className={cn(
        "relative inline-flex cursor-pointer items-center justify-center whitespace-nowrap rounded-[4px] px-3 py-1 font-mono text-xs uppercase tracking-[0.12em] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
        animated ? "transition-colors motion-reduce:transition-none" : "transition-none",
        isActive && "bg-primary text-primary-foreground shadow-sm",
        className
      )}
      {...props}
    />
  );
}

export type TabsContentProps = React.HTMLAttributes<HTMLDivElement> & { value: string };

export function TabsContent({ value, className, children, ...props }: TabsContentProps) {
  const ctx = React.useContext(TabsCtx);
  if (ctx?.value !== value) return null;
  return (
    <div
      role="tabpanel"
      id={`${ctx.baseId}-panel-${value}`}
      aria-labelledby={`${ctx.baseId}-tab-${value}`}
      tabIndex={0}
      className={cn("mt-3 text-sm focus-visible:outline-none", className)}
      {...props}
    >
      {children}
    </div>
  );
}

Props

PropTypeDescription
defaultValuestringInitially active tab value.
valuestringControlled active tab value.
onValueChange(value: string) => voidCalled when active tab changes.