VibeUI
ComponentsDocs
VibeUI

Components with taste, that you own. Copy the source, retint the tokens, keep every line.

Library

  • All components
  • Signature pieces
  • Text effects
  • Footers

Docs

  • Introduction
  • Installation
  • Theming
  • CLI

Project

  • GitHub
  • Agents and MCP
  • Issues
  • MIT license
© 2026 VibeUI · MIT licensed
  1. Components
  2. Navigation
  3. Tabs

Tabs

Tabs with arrow-key navigation, in pill or underline style.

NavigationClient component4 exports

Pill style. Arrow keys move between tabs.

Underline style.

Install

bash
npx @agents-npm-packages/vibeui add tabs --dir ./src/components/ui

Import

tsx
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"

API

Generated from the component's TypeScript types, so it always matches the source. * marks a required prop.

<Tabs>

PropTypeDefaultDescription
defaultValue*string—Initial value when uncontrolled.
children*ReactNode—Content rendered inside the component.
valuestring—Controlled value.
onValueChange((v: string) => void)—Called with the new value.
variant"pill" | "line""pill"Visual style.
classNamestring—Extra classes, merged with the component's own.

<TabsList>

also accepts every HTMLAttributes<HTMLDivElement> attribute

No props of its own: it forwards native attributes to the element.

<TabsTrigger>

also accepts every ButtonHTMLAttributes<HTMLButtonElement> attribute

PropTypeDefaultDescription
value*string | (readonly string[] & string)—Controlled value.

<TabsContent>

also accepts every HTMLAttributes<HTMLDivElement> attribute

PropTypeDefaultDescription
value*string—Controlled value.

Source

src/components/ui/tabs.tsx
"use client";

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

type TabsContextValue = {
  value: string;
  setValue: (v: string) => void;
  variant: "pill" | "line";
  baseId: string;
};

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

function useTabs() {
  const ctx = React.useContext(TabsContext);
  if (!ctx) throw new Error("Tabs components must be used within <Tabs>");
  return ctx;
}

/** Tabs with arrow-key navigation, in pill or underline style. */
export function Tabs({
  defaultValue,
  value: controlled,
  onValueChange,
  variant = "pill",
  className,
  children,
}: {
  defaultValue: string;
  value?: string;
  onValueChange?: (v: string) => void;
  variant?: "pill" | "line";
  className?: string;
  children: React.ReactNode;
}) {
  const baseId = React.useId();
  const [uncontrolled, setUncontrolled] = React.useState(defaultValue);
  const value = controlled ?? uncontrolled;
  const setValue = (v: string) => {
    setUncontrolled(v);
    onValueChange?.(v);
  };

  return (
    <TabsContext.Provider value={{ value, setValue, variant, baseId }}>
      <div className={cn("w-full", className)}>{children}</div>
    </TabsContext.Provider>
  );
}

export function TabsList({
  className,
  onKeyDown,
  ...props
}: React.HTMLAttributes<HTMLDivElement>) {
  const { variant } = useTabs();
  return (
    <div
      role="tablist"
      onKeyDown={(e) => {
        onKeyDown?.(e);
        if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
        const tabs = Array.from(
          e.currentTarget.querySelectorAll<HTMLElement>('[role="tab"]'),
        );
        const i = tabs.indexOf(document.activeElement as HTMLElement);
        const next = e.key === "ArrowRight" ? i + 1 : i - 1;
        const target = tabs[(next + tabs.length) % tabs.length];
        target?.focus();
        target?.click();
      }}
      className={cn(
        variant === "pill"
          ? "inline-flex gap-0.5 rounded-md bg-surface-muted p-1"
          : "flex gap-5 border-b border-line",
        className,
      )}
      {...props}
    />
  );
}

export function TabsTrigger({
  value,
  className,
  children,
  ...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { value: string }) {
  const { value: active, setValue, variant, baseId } = useTabs();
  const selected = active === value;

  return (
    <button
      type="button"
      role="tab"
      id={`${baseId}-tab-${value}`}
      aria-selected={selected}
      aria-controls={`${baseId}-panel-${value}`}
      tabIndex={selected ? 0 : -1}
      onClick={() => setValue(value)}
      className={cn(
        "text-[13px] font-medium transition-[color,background-color,box-shadow] duration-200",
        variant === "pill"
          ? cn(
              "h-8 rounded-sm px-3.5",
              selected
                ? "bg-surface text-ink shadow-quiet"
                : "text-muted hover:text-ink",
            )
          : cn(
              "-mb-px h-10 border-b-2",
              selected
                ? "border-accent text-ink"
                : "border-transparent text-muted hover:text-ink",
            ),
        className,
      )}
      {...props}
    >
      {children}
    </button>
  );
}

export function TabsContent({
  value,
  className,
  children,
  ...props
}: React.HTMLAttributes<HTMLDivElement> & { value: string }) {
  const { value: active, baseId } = useTabs();
  if (active !== value) return null;
  return (
    <div
      role="tabpanel"
      id={`${baseId}-panel-${value}`}
      aria-labelledby={`${baseId}-tab-${value}`}
      className={cn("mt-4 animate-fade-up", className)}
      {...props}
    >
      {children}
    </div>
  );
}

Exports Tabs and 3 more. Peer dependencies: clsx, tailwind-merge.

PreviousTable Of ContentsNextTimeline