Skip to content

Navigation

Tabs

Keyboard-navigable tab panels with roving focus, controlled or uncontrolled.

<script>
  import { Tabs, TabsList, TabsTrigger, TabsContent } from "@codeatlasdev/ui";
</script>

<Tabs value="overview">
  <TabsList>
    <TabsTrigger value="overview">Overview</TabsTrigger>
    <TabsTrigger value="props">Props</TabsTrigger>
  </TabsList>
  <TabsContent value="overview">...</TabsContent>
  <TabsContent value="props">...</TabsContent>
</Tabs>

Arrow keys move between tabs, Home and End jump to the first and last, and focusing a tab selects it (automatic activation). Disabled tabs are skipped. The panels use the tabpanel role and are focusable. Each TabsTrigger and TabsContent pair must share a value. Tabs is horizontal only.

TabsList keeps the width of its tabs (it never stretches across the column) and, when the tabs do not fit, scrolls sideways on its own with the scrollbar hidden. The selected tab is scrolled into view on mount and whenever it changes. The leading icon snippet and brand logos are covered on the Dropdown Menu page too (same icon / brandIcon pair). Add fade to mask the edge that still has tabs to reveal. No wrapper element or CSS is needed for 7 tabs on a phone.

variant="line" drops the glass pill: bare text tabs with a hairline under the strip and a 2px underline on the active tab, the usual look in a page header. Tabs that only switch a view (no TabsContent) are fine: a trigger points aria-controls at its panel only while a TabsContent with the same value is mounted, so nothing references a missing element. Name the strip with aria-label (or aria-labelledby) on TabsList; TabsList and TabsTrigger forward any other attribute (data-*, id, aria-*, event handlers) to their element.

TabsTrigger forwards any other button attribute (data-testid, id, aria-*, onclick, onfocus) to its <button role="tab">, and TabsList forwards data-*, id and aria-* to the role="tablist" element. role, type and the roving tabindex stay owned by the component.

#164

Default

Three tabs with content panels.

The Tabs component implements the WAI-ARIA tabs pattern. Arrow keys cycle through tabs, Home and End jump to the first and last.

Reference #164 · Pick in the gallery

Code
svelte
<script lang="ts">
  import { Tabs, TabsList, TabsTrigger, TabsContent } from '@codeatlasdev/ui';
</script>

<div class="wrap">
  <Tabs value="overview">
    <TabsList>
      <TabsTrigger value="overview">Overview</TabsTrigger>
      <TabsTrigger value="props">Props</TabsTrigger>
      <TabsTrigger value="examples">Examples</TabsTrigger>
    </TabsList>
    <TabsContent value="overview">
      <div class="panel">
        <p>The <strong>Tabs</strong> component implements the WAI-ARIA tabs pattern. Arrow keys cycle through tabs, Home and End jump to the first and last.</p>
      </div>
    </TabsContent>
    <TabsContent value="props">
      <div class="panel">
        <p>Tabs accepts a <code>value</code> prop for controlled mode and <code>onchange</code> for change notifications. Uncontrolled mode works too.</p>
      </div>
    </TabsContent>
    <TabsContent value="examples">
      <div class="panel">
        <p>Common uses: settings pages, detail views with several data categories, code and preview panes, and stepped forms.</p>
      </div>
    </TabsContent>
  </Tabs>
</div>

<style>
  .wrap { width: 100%; max-width: 560px; }
  .panel { padding: 20px 0 8px; font-size: var(--ca-font-size-body2); color: var(--ca-text-secondary); line-height: 1.6; }
  .panel p { margin: 0; }
  .panel strong { color: var(--ca-text-primary); }
  .panel code {
    font-family: var(--ca-font-mono);
    font-size: 0.9em;
    color: var(--ca-brand-text);
    background: color-mix(in oklch, var(--ca-brand-main) 10%, transparent);
    padding: 1px 5px;
    border-radius: var(--ca-radius-xs);
  }
</style>
#163

Controlled

The parent owns the active tab with bind:value.

Overview panel.

Active tab: overview

Reference #163 · Pick in the gallery

Code
svelte
<script lang="ts">
  import { Tabs, TabsList, TabsTrigger, TabsContent } from '@codeatlasdev/ui';

  let active = $state('overview');
</script>

<div class="wrap">
  <Tabs bind:value={active}>
    <TabsList>
      <TabsTrigger value="overview">Overview</TabsTrigger>
      <TabsTrigger value="props">Props</TabsTrigger>
      <TabsTrigger value="examples">Examples</TabsTrigger>
    </TabsList>
    <TabsContent value="overview"><div class="panel">Overview panel.</div></TabsContent>
    <TabsContent value="props"><div class="panel">Props panel.</div></TabsContent>
    <TabsContent value="examples"><div class="panel">Examples panel.</div></TabsContent>
  </Tabs>
  <p class="state">Active tab: <code>{active}</code></p>
</div>

<style>
  .wrap { width: 100%; max-width: 480px; }
  .panel { padding: 20px 0 8px; font-size: var(--ca-font-size-body2); color: var(--ca-text-secondary); }
  .state { font-size: var(--ca-font-size-caption); color: var(--ca-text-secondary); margin: 12px 0 0; }
  .state code { font-family: var(--ca-font-mono); color: var(--ca-brand-text); }
</style>
#238

Icons

A leading icon per tab. A brand logo keeps its own colors with brandIcon; SchemeAsset swaps a monochrome mark between light and dark.

Shell session.

Reference #238 · Pick in the gallery

Code
svelte
<script lang="ts">
  import { Tabs, TabsList, TabsTrigger, TabsContent, SchemeAsset } from '@codeatlasdev/ui';
  import { Terminal } from '@lucide/svelte';
</script>

<div class="wrap">
  <Tabs value="shell">
    <TabsList>
      <TabsTrigger value="shell">
        {#snippet icon()}<Terminal />{/snippet}
        Shell
      </TabsTrigger>
      <!-- A brand mark keeps its own colors in every state: brandIcon opts it out of the tab's text color. -->
      <TabsTrigger value="claude" brandIcon>
        {#snippet icon()}
          <svg viewBox="0 0 16 16"><rect width="16" height="16" rx="4" fill="#d97757" /><path d="M5 11 8 4l3 7" fill="none" stroke="#fff" stroke-width="1.6" /></svg>
        {/snippet}
        Claude Code
      </TabsTrigger>
      <TabsTrigger value="codex" brandIcon>
        {#snippet icon()}
          <SchemeAsset>
            {#snippet light()}<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="7" fill="#111" /></svg>{/snippet}
            {#snippet dark()}<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="7" fill="#fff" /></svg>{/snippet}
          </SchemeAsset>
        {/snippet}
        Codex
      </TabsTrigger>
    </TabsList>
    <TabsContent value="shell"><div class="panel">Shell session.</div></TabsContent>
    <TabsContent value="claude"><div class="panel">Claude Code session.</div></TabsContent>
    <TabsContent value="codex"><div class="panel">Codex session.</div></TabsContent>
  </Tabs>
</div>

<style>
  .wrap { width: 100%; max-width: 480px; }
  .panel { padding: 20px 0 8px; font-size: var(--ca-font-size-body2); color: var(--ca-text-secondary); }
</style>
#460

Line

variant=line as a view switcher: aria-label on the list, data-testid on a trigger, no panels.

Showing the board view.

Reference #460 · Pick in the gallery

Code
svelte
<script lang="ts">
  import { Tabs, TabsList, TabsTrigger } from '@codeatlasdev/ui';

  let view = $state('board');
</script>

<div class="wrap">
  <!-- View switcher: no TabsContent, so no aria-controls points at a missing panel. -->
  <Tabs bind:value={view} variant="line">
    <TabsList aria-label="View">
      <TabsTrigger value="board" data-testid="view-board">Board</TabsTrigger>
      <TabsTrigger value="list" data-testid="view-list">List</TabsTrigger>
      <TabsTrigger value="timeline" data-testid="view-timeline">Timeline</TabsTrigger>
    </TabsList>
  </Tabs>
  <p class="out">Showing the {view} view.</p>
</div>

<style>
  .wrap { width: 100%; max-width: 420px; }
  .out { margin: 16px 0 0; font-size: var(--ca-font-size-body2); color: var(--ca-text-secondary); }
</style>
#237

Scrollable

Seven tabs in a narrow column: the list scrolls on its own, the active tab starts in view, and fade marks the edge.

Integrations settings.

Reference #237 · Pick in the gallery

Code
svelte
<script lang="ts">
  import { Tabs, TabsList, TabsTrigger, TabsContent } from '@codeatlasdev/ui';

  const sections = ['General', 'Billing', 'Members', 'Webhooks', 'Security', 'Integrations', 'Danger zone'];
  const key = (s: string) => s.toLowerCase().replace(' ', '-');
</script>

<div class="wrap">
  <Tabs value="integrations">
    <TabsList fade>
      {#each sections as s (s)}
        <TabsTrigger value={key(s)}>{s}</TabsTrigger>
      {/each}
    </TabsList>
    {#each sections as s (s)}
      <TabsContent value={key(s)}><div class="panel">{s} settings.</div></TabsContent>
    {/each}
  </Tabs>
</div>

<style>
  .wrap { width: 100%; max-width: 360px; }
  .panel { padding: 20px 0 8px; font-size: var(--ca-font-size-body2); color: var(--ca-text-secondary); }
</style>

Tabs

PropTypeDefaultDescription
valuestring–Bindable active tab value.
onchange(v: string) => void–Fires when the active tab changes by click or keyboard.
variant'pill' | 'line''pill'`pill` fills the active tab on a glass list. `line` is bare text with an underline on the active tab, for page headers.
childrenSnippet–`TabsList` and `TabsContent` children.
classstring''Extra classes on the root.

TabsTrigger

PropTypeDefaultDescription
valuestring–Required. Links the trigger to its TabsContent.
disabledbooleanfalsePrevents selecting the tab.
iconSnippet–Leading icon in a fixed 14px box, `aria-hidden`: the label stays the accessible name. Any `svg` or `img` inside the box fills it, including one wrapped in `SchemeAsset`. A plain icon follows the tab's text color.
brandIconbooleanfalseThe icon is a brand mark with its own colors: no active, hover or disabled color reaches it. Pair it with `SchemeAsset` when the mark needs a light and a dark asset.
childrenSnippet–Trigger label.
classstring''Extra CSS classes.

TabsContent

PropTypeDefaultDescription
valuestring–Required. Shown while the matching trigger is active.
childrenSnippet–Panel content.
classstring''Extra CSS classes.

TabsList

PropTypeDefaultDescription
fadebooleanfalseFade the edge(s) that still hide tabs, so the strip reads as scrollable.
aria-labelstring–Accessible name of the tablist (also `aria-labelledby`). Name it whenever the page has more than one tablist or the purpose is not obvious.
childrenSnippet–The `TabsTrigger` elements.
classstring''Extra CSS classes.