Tabs

Tabs pairs a tab list with panels. The active panel slides in from the side you came from. The selection pill springs between tabs. Use it for page sections that show one dataset from several angles. It is not CardTabs: that one lives in card headers and has no panels.

import Tabs, { TabsList, TabsTab, TabsPanels, TabsPanel } from "@haloid/azucar/ui/tabs";

Two spellings name the same components. Tabs.List and the other dot-notation parts work only in Client Components: the dot-notation properties do not survive the server to client reference proxy. The named parts work everywhere. When in doubt, use the named parts.

Anatomy

Every tab and panel pairs through a required string value. Give the root a defaultValue and the component owns its state. Pick a tab in the panel below, or change a value in the code.

variant
animated
clip
<Tabs defaultValue="accuracy" className="flex w-100 flex-col gap-6" variant="primary">
<TabsList>
  <TabsTab value="accuracy">Accuracy</TabsTab>
  <TabsTab value="power">Power gap</TabsTab>
  <TabsTab value="records">Record gap</TabsTab>
</TabsList>
<TabsPanels>
  <TabsPanel value="accuracy">Accuracy history, audits marked.</TabsPanel>
  <TabsPanel value="power">Billed power against floor-measured power.</TabsPanel>
  <TabsPanel value="records">Records with no matching audit entry.</TabsPanel>
</TabsPanels>
</Tabs>

primary shows the house pill behind the active tab. ghost shows no pill. filled is reserved. Its pill is not styled yet. animated={false} swaps panels with no slide. Reduced-motion users get instant swaps in every variant.

Spacing between the list and the panels is the job of the call site. The demo above uses flex flex-col gap-6 on the root. Do the same. The fixed width also belongs to the demo: panels size to their content, so give the root a width when the content varies.

Controlled tabs

For state the page owns, pass value and onValueChange instead of defaultValue. The two forms are exclusive. Typescript rejects a mix. Values are strings only.

Clipping

TabsPanels crops the slide by default, so a leaving panel never paints outside its box. Full-bleed content needs the crop off:

<TabsPanels clip={false}>

The slide keeps working. The reconciliation overview turns the crop off so its accuracy chart can extend to the full viewport width.

API

type TabsVariant = "primary" | "filled" | "ghost";
// root: common props plus exactly one state form
interface TabsCommonProps {
children: React.ReactNode;
variant?: TabsVariant; // default "primary"
animated?: boolean; // default true; false = instant swap
className?: string;
}
// controlled: { value: string; onValueChange: (value: string) => void }
// uncontrolled: { defaultValue: string }
// TabsList: { children; className? }
// TabsTab: { value: string; children; disabled?; className? }
// TabsPanels: { children; clip?: boolean; className? } // clip default true
// TabsPanel: { value: string; children; className? }

Animated panels must sit inside TabsPanels. It stacks the leaving and entering panels in one grid cell so the page never jumps.