---
url: "https://vueda.dev/v3/reference/components/containers.md"
title: "Containers"
---

<script setup>
import Card from "@vueda/shell/card/Card.vue";
import CardHeader from "@vueda/shell/card/CardHeader.vue";
import CardTitle from "@vueda/shell/card/CardTitle.vue";
import CardDescription from "@vueda/shell/card/CardDescription.vue";
import CardAction from "@vueda/shell/card/CardAction.vue";
import CardContent from "@vueda/shell/card/CardContent.vue";
import CardFooter from "@vueda/shell/card/CardFooter.vue";
import Accordion from "@vueda/shell/accordion/Accordion.vue";
import AccordionItem from "@vueda/shell/accordion/AccordionItem.vue";
import AccordionTrigger from "@vueda/shell/accordion/AccordionTrigger.vue";
import AccordionContent from "@vueda/shell/accordion/AccordionContent.vue";
import Collapsible from "@vueda/shell/collapsible/Collapsible.vue";
import CollapsibleTrigger from "@vueda/shell/collapsible/CollapsibleTrigger.vue";
import CollapsibleContent from "@vueda/shell/collapsible/CollapsibleContent.vue";
import Item from "@vueda/shell/item/Item.vue";
import ItemMedia from "@vueda/shell/item/ItemMedia.vue";
import ItemContent from "@vueda/shell/item/ItemContent.vue";
import ItemTitle from "@vueda/shell/item/ItemTitle.vue";
import ItemDescription from "@vueda/shell/item/ItemDescription.vue";
import ItemActions from "@vueda/shell/item/ItemActions.vue";
import ItemGroup from "@vueda/shell/item/ItemGroup.vue";
import ItemSeparator from "@vueda/shell/item/ItemSeparator.vue";
import Stepper from "@vueda/shell/stepper/Stepper.vue";
import StepperItem from "@vueda/shell/stepper/StepperItem.vue";
import StepperTrigger from "@vueda/shell/stepper/StepperTrigger.vue";
import StepperIndicator from "@vueda/shell/stepper/StepperIndicator.vue";
import StepperTitle from "@vueda/shell/stepper/StepperTitle.vue";
import StepperDescription from "@vueda/shell/stepper/StepperDescription.vue";
import StepperSeparator from "@vueda/shell/stepper/StepperSeparator.vue";
import ScrollArea from "@vueda/shell/scroll-area/ScrollArea.vue";
import Separator from "@vueda/shell/separator/Separator.vue";
import Tabs from "@vueda/shell/tabs/Tabs.vue";
import TabsList from "@vueda/shell/tabs/TabsList.vue";
import TabsTrigger from "@vueda/shell/tabs/TabsTrigger.vue";
import TabsContent from "@vueda/shell/tabs/TabsContent.vue";
import Drawer from "@vueda/shell/drawer/Drawer.vue";
import DrawerTrigger from "@vueda/shell/drawer/DrawerTrigger.vue";
import DrawerContent from "@vueda/shell/drawer/DrawerContent.vue";
import DrawerHeader from "@vueda/shell/drawer/DrawerHeader.vue";
import DrawerTitle from "@vueda/shell/drawer/DrawerTitle.vue";
import DrawerDescription from "@vueda/shell/drawer/DrawerDescription.vue";
import DrawerFooter from "@vueda/shell/drawer/DrawerFooter.vue";
import DrawerClose from "@vueda/shell/drawer/DrawerClose.vue";
import ResizablePanelGroup from "@vueda/shell/resizable/ResizablePanelGroup.vue";
import ResizablePanel from "@vueda/shell/resizable/ResizablePanel.vue";
import ResizableHandle from "@vueda/shell/resizable/ResizableHandle.vue";
import AspectRatio from "@vueda/display/aspect-ratio/AspectRatio.vue";
import Button from "@vueda/controls/button/Button.vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { faCheck, faChevronRight, faClock, faEllipsis, faFile, faFileExport, faFolder, faImage, faUser } from "@fortawesome/free-solid-svg-icons";
</script>

# Containers

The container family covers the box-shaped surfaces that wrap other content:
cards, accordions, collapsibles, list items, steppers, scrollable regions,
and separators. They live under `shell/` because they hold the page chrome
together rather than collecting input or driving an action. Their visual
contract is a small set of values: a 14 px corner radius (the card
[--vueda-card-radius](/v3/reference/theming/tokens/semantic-radius-tokens.md#css-token-vueda-card-radius)), a 1 px [--border](/v3/reference/theming/tokens/color-palette-light.md#css-token-border), the
[--card](/v3/reference/theming/tokens/color-palette-light.md#css-token-card) fill, and the [--vueda-shadow-card](/v3/reference/theming/tokens/semantic-shadow-tokens.md#css-token-vueda-shadow-card) drop.

This page is the visual contract the default theme guarantees. Use it as the
target spec when you re-skin: every cell shown here should still read as the
same surface after a customization, even if its color, radius, or density
shifts. If a cell breaks, the change has crossed from skin into design
language.

For the mechanics of overriding any of this, see
[Customize VUEDA Appearance](/v3/guides/customize-vueda-appearance.md). In
brief: values (color, dimension, duration) belong in
[CSS tokens](/v3/reference/theming/tokens.md); compositions (class arrangements,
state recipes) belong in [theme keys](/v3/reference/theming/keys.md).

One structural decision worth flagging up front. Card uses vertical-only
padding on its root (`py-6`, no `px`) and lets each child apply its own
`px-6`. The result is that a child can opt out of the gutter (a table that
needs to bleed to the edge) by setting its own padding to zero, and a header
that wants a divider rule can add `border-b` and pick up `pb-6` automatically
via `[.border-b]:pb-6`. Header / content / footer divisions stay
self-similar without per-position wrappers.

## Card

Card is the primary surface for presenting a single object's information.
It is a flex column with `gap-6` between regions and seven theme keys:
the shell ([Card](/v3/reference/theming/keys/Card.md)) plus six region keys
([CardHeader](/v3/reference/theming/keys/CardHeader.md), [CardTitle](/v3/reference/theming/keys/CardTitle.md),
[CardDescription](/v3/reference/theming/keys/CardDescription.md), [CardAction](/v3/reference/theming/keys/CardAction.md),
[CardContent](/v3/reference/theming/keys/CardContent.md), [CardFooter](/v3/reference/theming/keys/CardFooter.md)). The header is a
two-row CSS grid; when a [CardAction](/v3/reference/theming/keys/CardAction.md) child is present, the
grid switches to `[1fr auto]` so the action lands flush right and spans both
rows.

Token surface: [--card](/v3/reference/theming/tokens/color-palette-light.md#css-token-card) (fill),
[--card-foreground](/v3/reference/theming/tokens/color-palette-light.md#css-token-card-foreground) (text), [--border](/v3/reference/theming/tokens/color-palette-light.md#css-token-border)
(stroke), [--vueda-card-radius](/v3/reference/theming/tokens/semantic-radius-tokens.md#css-token-vueda-card-radius) (corners),
[--vueda-shadow-card](/v3/reference/theming/tokens/semantic-shadow-tokens.md#css-token-vueda-shadow-card) (drop).

<VuedaDemo class="grid gap-6 sm:grid-cols-2">
  <DemoCard title="header + content">
    <Card>
      <CardHeader>
        <CardTitle>Invoice 2024-10-0482</CardTitle>
        <CardDescription>Acme Pressroom, Ltd. · due in 4 days</CardDescription>
      </CardHeader>
      <CardContent>
        <p class="text-sm text-muted-foreground m-0">
          Balance outstanding of <strong class="text-foreground">$4,280.00</strong>
          after a partial payment on March 14.
        </p>
      </CardContent>
    </Card>
    <template #footer>
      <span>gap-6 between children</span>
      <span>py-6 on root, px-6 on each child</span>
    </template>
  </DemoCard>
  <DemoCard title="header + action">
    <Card>
      <CardHeader>
        <CardTitle>Payment reminders</CardTitle>
        <CardDescription>Automated emails sent to overdue customers.</CardDescription>
        <CardAction>
          <Button size="sm" emphasis="outline">Configure</Button>
        </CardAction>
      </CardHeader>
      <CardContent>
        <p class="text-sm text-foreground font-medium m-0">
          3 reminders queued · next send <code class="font-mono text-xs">2026-04-26 09:00</code>
        </p>
      </CardContent>
    </Card>
    <template #footer>
      <span>header grid becomes <code>[1fr auto]</code> when action is present</span>
    </template>
  </DemoCard>
  <DemoCard title="header + content + footer">
    <Card>
      <CardHeader>
        <CardTitle>Archive invoice</CardTitle>
        <CardDescription>Archived invoices are hidden from the main ledger.</CardDescription>
      </CardHeader>
      <CardContent>
        <p class="text-sm text-muted-foreground m-0">
          They remain searchable and are retained for 7 years per retention policy.
        </p>
      </CardContent>
      <CardFooter class="justify-end gap-2">
        <Button size="sm" emphasis="ghost">Cancel</Button>
        <Button size="sm" tone="primary">Archive</Button>
      </CardFooter>
    </Card>
    <template #footer>
      <span>CardFooter is a flex row; consumer aligns it</span>
    </template>
  </DemoCard>
  <DemoCard title="border-b-hairline on header — tight grouping">
    <Card>
      <CardHeader class="border-b-hairline">
        <CardTitle>Transactions</CardTitle>
        <CardDescription>Last 3 days, from all connected feeds.</CardDescription>
      </CardHeader>
      <CardContent>
        <table class="w-full text-sm">
          <tbody>
            <tr>
              <td class="py-1 text-muted-foreground font-mono text-xs">04-22</td>
              <td>Stripe payout</td>
              <td class="text-right tabular-nums">$2,480.00</td>
            </tr>
            <tr>
              <td class="py-1 text-muted-foreground font-mono text-xs">04-21</td>
              <td>AWS</td>
              <td class="text-right tabular-nums text-destructive">−$312.40</td>
            </tr>
            <tr>
              <td class="py-1 text-muted-foreground font-mono text-xs">04-20</td>
              <td>Mercury interest</td>
              <td class="text-right tabular-nums">$18.22</td>
            </tr>
          </tbody>
        </table>
      </CardContent>
    </Card>
    <template #footer>
      <span><code>[.border-b]:pb-6</code> picks up the rule</span>
      <span>consumer adds <code>border-b</code> on the header</span>
    </template>
  </DemoCard>
  <DemoCard title="border-t-hairline on footer — danger zone">
    <Card>
      <CardHeader>
        <CardTitle>Danger zone</CardTitle>
        <CardDescription>These actions can't be undone.</CardDescription>
      </CardHeader>
      <CardContent>
        <p class="text-sm text-muted-foreground m-0">
          Deleting a customer removes all linked invoices, payments, and contact records.
        </p>
      </CardContent>
      <CardFooter class="border-t-hairline justify-end">
        <Button size="sm" tone="destructive">Delete customer…</Button>
      </CardFooter>
    </Card>
    <template #footer>
      <span><code>[.border-t]:pt-6</code> on footer</span>
    </template>
  </DemoCard>
  <DemoCard title="content-only — metric tile">
    <Card>
      <CardContent>
        <div class="flex items-baseline gap-2">
          <span class="text-3xl font-semibold tabular-nums tracking-tight">$48,204</span>
          <span class="font-mono text-xs text-success">+12.4%</span>
        </div>
        <p class="mt-1 font-mono text-xs text-muted-foreground">
          outstanding · 28 invoices
        </p>
      </CardContent>
    </Card>
    <template #footer>
      <span>the shell is the whole component; no header required</span>
    </template>
  </DemoCard>
</VuedaDemo>

## Accordion

Accordion is a stack of disclosure rows. Each row is an
[AccordionItem](/v3/reference/theming/keys/AccordionItem.md) (a 1 px bottom border, suppressed on the
last item) with a header trigger ([AccordionTrigger](/v3/reference/theming/keys/AccordionTrigger.md)) and a
collapsible panel ([AccordionContent](/v3/reference/theming/keys/AccordionContent.md)). The chevron rotates
180° via `[&[data-state=open]>svg]:rotate-180` on the trigger; the panel
animates with the `accordion-up` / `accordion-down` keyframes inlined at
the base layer.

Theme keys: [Accordion](/v3/reference/theming/keys/Accordion.md), [AccordionItem](/v3/reference/theming/keys/AccordionItem.md),
[AccordionTrigger](/v3/reference/theming/keys/AccordionTrigger.md), [AccordionContent](/v3/reference/theming/keys/AccordionContent.md).

<VuedaDemo class="grid gap-6 sm:grid-cols-2">
  <DemoCard title="FAQ pattern — single, collapsible">
    <Accordion type="single" collapsible default-value="terms">
      <AccordionItem value="overdue">
        <AccordionTrigger>When does an invoice become overdue?</AccordionTrigger>
        <AccordionContent>
          An invoice is overdue the day after its due date. Reminders fire on
          a configurable schedule from that point.
        </AccordionContent>
      </AccordionItem>
      <AccordionItem value="terms">
        <AccordionTrigger>Can I change the payment terms after sending?</AccordionTrigger>
        <AccordionContent>
          Yes, until the invoice is paid. Editing terms triggers a revision
          and a new PDF is sent to the customer. If the invoice is already
          partially paid, only the balance follows the new terms.
        </AccordionContent>
      </AccordionItem>
      <AccordionItem value="bank">
        <AccordionTrigger>How do bank-feed matches work?</AccordionTrigger>
        <AccordionContent>
          Incoming transactions are matched to invoices by amount, currency,
          and a fuzzy memo comparison. Confidence below 90% is queued for
          manual review.
        </AccordionContent>
      </AccordionItem>
    </Accordion>
    <template #footer>
      <span>border-b between items, suppressed on last</span>
      <span>content inner uses <code>pt-0 pb-4</code></span>
    </template>
  </DemoCard>
  <DemoCard title="disabled item">
    <Accordion type="single" collapsible>
      <AccordionItem value="2fa">
        <AccordionTrigger>Two-factor authentication</AccordionTrigger>
        <AccordionContent>Enrollment options for TOTP and WebAuthn devices.</AccordionContent>
      </AccordionItem>
      <AccordionItem value="sso" disabled>
        <AccordionTrigger>SSO (Enterprise plan only)</AccordionTrigger>
        <AccordionContent>Available on the Enterprise plan.</AccordionContent>
      </AccordionItem>
      <AccordionItem value="session">
        <AccordionTrigger>Session timeout</AccordionTrigger>
        <AccordionContent>Idle sessions expire after 30 minutes by default.</AccordionContent>
      </AccordionItem>
    </Accordion>
    <template #footer>
      <span>disabled trigger: <code>opacity 50</code>, no pointer events</span>
    </template>
  </DemoCard>
</VuedaDemo>

## Collapsible

Collapsible is a logic primitive, not a styled surface. All three of its
theme keys ([Collapsible](/v3/reference/theming/keys/Collapsible.md), [CollapsibleTrigger](/v3/reference/theming/keys/CollapsibleTrigger.md),
[CollapsibleContent](/v3/reference/theming/keys/CollapsibleContent.md)) ship empty in the default theme; the
component contributes show / hide behavior and `data-state` attributes, and
the consumer brings the chrome. Use it when you want disclosure without
the FAQ-style row treatment of [Accordion](/v3/reference/theming/keys/Accordion.md).

<VuedaDemo class="grid gap-6 sm:grid-cols-2">
  <DemoCard title="bare — closed">
    <Collapsible>
      <CollapsibleTrigger as-child>
        <button type="button" class="flex items-center gap-2 text-sm font-medium">
          <FontAwesomeIcon :icon="faChevronRight" class="size-3 transition-transform data-[state=open]:rotate-90" />
          Advanced filters
        </button>
      </CollapsibleTrigger>
      <CollapsibleContent>
        <p class="mt-2 text-sm text-muted-foreground">
          Applies to: invoice date · status · amount · memo
        </p>
      </CollapsibleContent>
    </Collapsible>
    <template #footer>
      <span>trigger and panel are consumer-styled</span>
    </template>
  </DemoCard>
  <DemoCard title="bare — open by default">
    <Collapsible default-open>
      <CollapsibleTrigger as-child>
        <button type="button" class="flex items-center gap-2 text-sm font-medium">
          <FontAwesomeIcon :icon="faChevronRight" class="size-3 transition-transform data-[state=open]:rotate-90" />
          Advanced filters
        </button>
      </CollapsibleTrigger>
      <CollapsibleContent>
        <p class="mt-2 text-sm text-muted-foreground">
          Applies to: invoice date · status · amount · memo
        </p>
      </CollapsibleContent>
    </Collapsible>
    <template #footer>
      <span><code>data-state</code> on trigger and panel drives the chrome</span>
    </template>
  </DemoCard>
</VuedaDemo>

## Item / ItemGroup

Item is a flexible row primitive used for list-like layouts where each row
shows a heading, an optional media slot, and optional trailing actions. The
shell ([Item](/v3/reference/theming/keys/Item.md)) takes a `variant`
(`outline`, `muted`, default transparent) and a `size` (`sm`, default `md`).
Sub-keys carve the row into regions: [ItemMedia](/v3/reference/theming/keys/ItemMedia.md) (left
icon / image), [ItemContent](/v3/reference/theming/keys/ItemContent.md) (title + description),
[ItemActions](/v3/reference/theming/keys/ItemActions.md) (right buttons), with
[ItemHeader](/v3/reference/theming/keys/ItemHeader.md) and [ItemFooter](/v3/reference/theming/keys/ItemFooter.md) for full-width
auxiliary rows. [ItemGroup](/v3/reference/theming/keys/ItemGroup.md) is a vertical flex column;
[ItemSeparator](/v3/reference/theming/keys/ItemSeparator.md) drops a 1 px rule between rows.

The default Item is `bg-transparent`; an `is-link` (or anchor) item raises
to `bg-accent/50` on hover via `[a]:hover:bg-accent/50` on the root.

<VuedaDemo class="grid gap-6 sm:grid-cols-2">
  <DemoCard title="variants — default · outline · muted">
    <ItemGroup class="gap-2">
      <Item>
        <ItemContent>
          <ItemTitle>default — bg transparent</ItemTitle>
          <ItemDescription>No border, no fill. Flush against the container.</ItemDescription>
        </ItemContent>
      </Item>
      <Item variant="outline">
        <ItemContent>
          <ItemTitle>outline — border-border</ItemTitle>
          <ItemDescription>Use for standalone items where a separator would be awkward.</ItemDescription>
        </ItemContent>
      </Item>
      <Item variant="muted">
        <ItemContent>
          <ItemTitle>muted — bg-muted/50</ItemTitle>
          <ItemDescription>For inline grouping inside a Card or row.</ItemDescription>
        </ItemContent>
      </Item>
    </ItemGroup>
  </DemoCard>
  <DemoCard title="sizes — sm · md">
    <ItemGroup class="gap-2">
      <Item size="sm" variant="outline">
        <ItemMedia variant="icon"><FontAwesomeIcon :icon="faClock" /></ItemMedia>
        <ItemContent>
          <ItemTitle>sm — py-3 px-4 gap-2.5</ItemTitle>
        </ItemContent>
      </Item>
      <Item variant="outline">
        <ItemMedia variant="icon"><FontAwesomeIcon :icon="faClock" /></ItemMedia>
        <ItemContent>
          <ItemTitle>md — p-4 gap-4 (default)</ItemTitle>
        </ItemContent>
      </Item>
    </ItemGroup>
  </DemoCard>
  <DemoCard title="ItemMedia — icon · image · none">
    <ItemGroup class="gap-2">
      <Item variant="outline">
        <ItemMedia variant="icon"><FontAwesomeIcon :icon="faFile" /></ItemMedia>
        <ItemContent>
          <ItemTitle>icon — size-8 bordered</ItemTitle>
          <ItemDescription>32 px square, 1 px border, bg-muted, svg size-4 inside.</ItemDescription>
        </ItemContent>
      </Item>
      <Item variant="outline">
        <ItemMedia variant="image"><FontAwesomeIcon :icon="faImage" class="size-6 text-muted-foreground" /></ItemMedia>
        <ItemContent>
          <ItemTitle>image — size-10 rounded</ItemTitle>
          <ItemDescription>40 px square, rounded-sm, object-cover. For avatars and thumbs.</ItemDescription>
        </ItemContent>
      </Item>
      <Item variant="outline">
        <ItemContent>
          <ItemTitle>no media</ItemTitle>
          <ItemDescription>Absent slot collapses; title and description sit flush left.</ItemDescription>
        </ItemContent>
      </Item>
    </ItemGroup>
  </DemoCard>
  <DemoCard title="ItemActions — trailing buttons">
    <ItemGroup class="gap-2">
      <Item variant="outline">
        <ItemMedia variant="icon"><FontAwesomeIcon :icon="faFileExport" /></ItemMedia>
        <ItemContent>
          <ItemTitle>Exports</ItemTitle>
          <ItemDescription>CSV, Parquet, JSON · 3 destinations.</ItemDescription>
        </ItemContent>
        <ItemActions>
          <Button size="sm" emphasis="ghost">Rename</Button>
          <Button size="sm" emphasis="outline">Run</Button>
        </ItemActions>
      </Item>
      <Item variant="outline">
        <ItemMedia variant="icon"><FontAwesomeIcon :icon="faFolder" /></ItemMedia>
        <ItemContent>
          <ItemTitle>Sandbox webhook</ItemTitle>
          <ItemDescription>https://webhook.site/abc-123 · last ping 2 h ago</ItemDescription>
        </ItemContent>
        <ItemActions>
          <Button size="sm" emphasis="ghost" aria-label="More">
            <FontAwesomeIcon :icon="faEllipsis" />
          </Button>
        </ItemActions>
      </Item>
    </ItemGroup>
  </DemoCard>
  <DemoCard title="ItemGroup + ItemSeparator — divider rows" class="sm:col-span-2">
    <ItemGroup class="rounded-vueda-card hairline hairline-border overflow-hidden">
      <Item>
        <ItemMedia variant="icon"><FontAwesomeIcon :icon="faUser" /></ItemMedia>
        <ItemContent>
          <ItemTitle>Erin Meyers</ItemTitle>
          <ItemDescription>erin@acme.co · admin</ItemDescription>
        </ItemContent>
        <ItemActions>
          <span class="rounded-full bg-muted px-2 py-1 font-mono text-[11px] text-muted-foreground">Admin</span>
        </ItemActions>
      </Item>
      <ItemSeparator />
      <Item>
        <ItemMedia variant="icon"><FontAwesomeIcon :icon="faUser" /></ItemMedia>
        <ItemContent>
          <ItemTitle>Sam Rao</ItemTitle>
          <ItemDescription>sam@acme.co · member</ItemDescription>
        </ItemContent>
        <ItemActions>
          <span class="rounded-full bg-muted px-2 py-1 font-mono text-[11px] text-muted-foreground">Member</span>
        </ItemActions>
      </Item>
      <ItemSeparator />
      <Item>
        <ItemMedia variant="icon"><FontAwesomeIcon :icon="faUser" /></ItemMedia>
        <ItemContent>
          <ItemTitle>Jules Park</ItemTitle>
          <ItemDescription>jules@acme.co · viewer · invited</ItemDescription>
        </ItemContent>
        <ItemActions>
          <span class="rounded-full bg-warning/10 px-2 py-1 font-mono text-[11px] text-warning">Pending</span>
        </ItemActions>
      </Item>
    </ItemGroup>
    <template #footer>
      <span>outer border belongs to the parent; items stay default (transparent)</span>
    </template>
  </DemoCard>
</VuedaDemo>

## Stepper

Stepper renders a sequence of milestone indicators, optionally with title
and description, separated by a connecting bar. The state cube is encoded
in `data-state` on the [StepperItem](/v3/reference/theming/keys/StepperItem.md): `active` flips the
indicator to [--primary](/v3/reference/theming/tokens/color-palette-light.md#css-token-primary) / [--primary-foreground](/v3/reference/theming/tokens/color-palette-light.md#css-token-primary-foreground),
`completed` flips it to [--accent](/v3/reference/theming/tokens/color-palette-light.md#css-token-accent) /
[--accent-foreground](/v3/reference/theming/tokens/color-palette-light.md#css-token-accent-foreground), and the unset (future) state shows
muted-foreground at 50% opacity. `data-disabled` propagates opacity 50
across the indicator and the separator that follows.

Theme keys: [Stepper](/v3/reference/theming/keys/Stepper.md), [StepperItem](/v3/reference/theming/keys/StepperItem.md),
[StepperTrigger](/v3/reference/theming/keys/StepperTrigger.md), [StepperIndicator](/v3/reference/theming/keys/StepperIndicator.md),
[StepperTitle](/v3/reference/theming/keys/StepperTitle.md), [StepperDescription](/v3/reference/theming/keys/StepperDescription.md),
[StepperSeparator](/v3/reference/theming/keys/StepperSeparator.md).

<VuedaDemo class="flex flex-col gap-6">
  <DemoCard title="4 steps — completed · completed · active · future">
    <Stepper :default-value="3">
      <StepperItem :step="1" completed>
        <StepperTrigger>
          <StepperIndicator><FontAwesomeIcon :icon="faCheck" class="size-3" /></StepperIndicator>
          <StepperTitle>Connect bank</StepperTitle>
          <StepperDescription>Plaid or manual</StepperDescription>
        </StepperTrigger>
        <StepperSeparator class="h-px flex-1" />
      </StepperItem>
      <StepperItem :step="2" completed>
        <StepperTrigger>
          <StepperIndicator><FontAwesomeIcon :icon="faCheck" class="size-3" /></StepperIndicator>
          <StepperTitle>Import customers</StepperTitle>
          <StepperDescription>42 imported</StepperDescription>
        </StepperTrigger>
        <StepperSeparator class="h-px flex-1" />
      </StepperItem>
      <StepperItem :step="3">
        <StepperTrigger>
          <StepperIndicator>3</StepperIndicator>
          <StepperTitle>Set up reminders</StepperTitle>
          <StepperDescription>3 templates available</StepperDescription>
        </StepperTrigger>
        <StepperSeparator class="h-px flex-1" />
      </StepperItem>
      <StepperItem :step="4">
        <StepperTrigger>
          <StepperIndicator>4</StepperIndicator>
          <StepperTitle>Invite team</StepperTitle>
          <StepperDescription>Optional</StepperDescription>
        </StepperTrigger>
      </StepperItem>
    </Stepper>
    <template #footer>
      <span>active <code>bg-primary</code></span>
      <span>completed <code>bg-accent</code></span>
      <span>future <code>text-muted-foreground/50</code></span>
    </template>
  </DemoCard>
  <DemoCard title="with a disabled step">
    <Stepper :default-value="2">
      <StepperItem :step="1" completed>
        <StepperTrigger>
          <StepperIndicator><FontAwesomeIcon :icon="faCheck" class="size-3" /></StepperIndicator>
          <StepperTitle>Upload CSV</StepperTitle>
        </StepperTrigger>
        <StepperSeparator class="h-px flex-1" />
      </StepperItem>
      <StepperItem :step="2">
        <StepperTrigger>
          <StepperIndicator>2</StepperIndicator>
          <StepperTitle>Map columns</StepperTitle>
        </StepperTrigger>
        <StepperSeparator class="h-px flex-1" />
      </StepperItem>
      <StepperItem :step="3" disabled>
        <StepperTrigger>
          <StepperIndicator>3</StepperIndicator>
          <StepperTitle>Import</StepperTitle>
        </StepperTrigger>
      </StepperItem>
    </Stepper>
    <template #footer>
      <span>disabled propagates opacity 50 to indicator + trailing separator</span>
    </template>
  </DemoCard>
</VuedaDemo>

## ScrollArea

ScrollArea wraps a fixed-height region and replaces the platform's native
scrollbar with a 10 px track plus a `bg-border` thumb. The component renders
both axes; whichever isn't needed stays inert. The viewport is what receives
focus, so the ring outline lands inside the parent's overflow clip.

Theme keys: [ScrollArea](/v3/reference/theming/keys/ScrollArea.md) (root + viewport),
[ScrollBar](/v3/reference/theming/keys/ScrollBar.md) (track + thumb). The thumb reads from
[--border](/v3/reference/theming/tokens/color-palette-light.md#css-token-border).

<VuedaDemo class="grid gap-6 sm:grid-cols-2">
  <DemoCard title="vertical">
    <div class="rounded-vueda-card hairline hairline-border overflow-hidden">
      <ScrollArea style="height: 200px;" class="p-3">
        <ul class="m-0 list-none p-0 text-sm leading-relaxed">
          <li>01. Draft a new invoice</li>
          <li>02. Send to customer</li>
          <li>03. Record payment</li>
          <li>04. Match to bank transaction</li>
          <li>05. Generate a statement</li>
          <li>06. Apply a credit note</li>
          <li>07. Record a refund</li>
          <li>08. Set up a recurring schedule</li>
          <li>09. Split a line item</li>
          <li>10. Add a tax rate</li>
          <li>11. Duplicate an invoice</li>
          <li>12. Archive overdue invoices</li>
          <li>13. Export a ledger</li>
          <li>14. Reconcile a feed</li>
          <li>15. Add a team member</li>
          <li>16. Invite an accountant</li>
        </ul>
      </ScrollArea>
    </div>
    <template #footer>
      <span>10 px scrollbar</span>
      <span>thumb <code>bg-border</code>, rounded-full</span>
    </template>
  </DemoCard>
  <DemoCard title="inside a Card">
    <Card class="py-0">
      <CardHeader class="border-b-hairline py-4">
        <CardTitle class="text-sm">Activity</CardTitle>
        <CardDescription class="text-xs">Last 30 events</CardDescription>
      </CardHeader>
      <ScrollArea style="height: 180px;" class="px-5 py-2">
        <ul class="m-0 list-none p-0 font-mono text-xs leading-relaxed text-muted-foreground">
          <li><span class="text-foreground">10:12</span> · invoice.sent · INV-0482</li>
          <li><span class="text-foreground">10:08</span> · webhook.delivered · stripe</li>
          <li><span class="text-foreground">09:54</span> · invoice.created · INV-0482</li>
          <li><span class="text-foreground">09:12</span> · customer.updated · acme-pressroom</li>
          <li><span class="text-foreground">08:47</span> · bank.match · txn_48B2Kq</li>
          <li><span class="text-foreground">08:40</span> · invoice.paid · INV-0479</li>
          <li><span class="text-foreground">08:36</span> · reminder.sent · INV-0470</li>
          <li><span class="text-foreground">08:01</span> · session.login · erin@acme</li>
          <li><span class="text-foreground">07:44</span> · export.completed · reconciliation-q1</li>
          <li><span class="text-foreground">07:02</span> · invoice.viewed · INV-0478</li>
          <li><span class="text-foreground">06:51</span> · webhook.failed · zapier (retry 2/3)</li>
        </ul>
      </ScrollArea>
    </Card>
    <template #footer>
      <span>card's rounded corners clip the scroll viewport</span>
    </template>
  </DemoCard>
</VuedaDemo>

## Separator

Separator is a hairline rule. It picks an axis from `orientation` and stretches
along the cross axis: a horizontal separator is `h-hairline w-full`, a vertical
one is `w-hairline h-full`. Both modes read color from [--border](/v3/reference/theming/tokens/color-palette-light.md#css-token-border).
A hairline resolves to one device pixel, so it measures 2 px at a device pixel
ratio of 1 and 1 px at 2.
The vertical mode requires the parent to set an explicit height because
`h-full` collapses inside a `flex` row otherwise.

Theme key: [Separator](/v3/reference/theming/keys/Separator.md). The default theme does not ship a
labeled "OR" variant; compose two separators around a span when you need
one.

<VuedaDemo class="grid gap-6 sm:grid-cols-2">
  <DemoCard title="horizontal — section break">
    <div class="rounded-vueda-card hairline hairline-border p-4">
      <div class="text-sm font-medium">Account</div>
      <p class="mt-1 text-xs text-muted-foreground">Change the email and password used to sign in.</p>
      <Separator class="my-3" />
      <div class="text-sm font-medium">Billing</div>
      <p class="mt-1 text-xs text-muted-foreground">Plan, payment method, invoices.</p>
    </div>
    <template #footer>
      <span>1 px <code>bg-border</code>, full width</span>
    </template>
  </DemoCard>
  <DemoCard title="vertical — inline items">
    <div class="inline-flex w-fit items-center gap-3 rounded-vueda-card hairline hairline-border px-3 py-2 text-sm font-medium">
      <span>Overview</span>
      <Separator orientation="vertical" class="h-4" />
      <span>Transactions</span>
      <Separator orientation="vertical" class="h-4" />
      <span>Customers</span>
      <Separator orientation="vertical" class="h-4" />
      <span class="text-muted-foreground">Reports</span>
    </div>
    <template #footer>
      <span>parent sets an explicit height for vertical</span>
    </template>
  </DemoCard>
  <DemoCard title="labeled — composed &quot;OR&quot;" class="sm:col-span-2">
    <div class="flex items-center gap-3">
      <Separator class="flex-1" />
      <span class="font-mono text-[11px] uppercase tracking-widest text-muted-foreground">or</span>
      <Separator class="flex-1" />
    </div>
    <template #footer>
      <span>Separator does not ship a labeled variant; compose two with a span between</span>
    </template>
  </DemoCard>
</VuedaDemo>

## Tabs

`Tabs` switches between sibling panels that occupy the same space. The root owns
the selected value (`default-value` uncontrolled, `v-model` controlled), a
`TabsList` holds the `TabsTrigger`s, and one `TabsContent` per value renders the
panel. Trigger and content pair up by matching `value`, not by order.

`activation-mode` decides what keyboard arrows do: the default `automatic` selects
as focus moves, while `manual` moves focus and waits for Enter or Space. Prefer
`manual` when selecting a tab does real work, such as issuing a request.

Tabs are for peer views of the same subject. They are not a wizard: nothing in
the component enforces an order or a completion state, which is what
[Stepper](/v3/reference/components/containers.md#stepper) is for.

Theme keys: [Tabs](/v3/reference/theming/keys/Tabs.md), [TabsList](/v3/reference/theming/keys/TabsList.md),
[TabsTrigger](/v3/reference/theming/keys/TabsTrigger.md), [TabsContent](/v3/reference/theming/keys/TabsContent.md).

<VuedaDemo class="grid gap-6">
  <DemoCard title="three panels" description=" (default value, automatic activation)">
    <Tabs default-value="overview">
      <TabsList>
        <TabsTrigger value="overview">Overview</TabsTrigger>
        <TabsTrigger value="invoices">Invoices</TabsTrigger>
        <TabsTrigger value="history">History</TabsTrigger>
        <TabsTrigger disabled value="billing">Billing</TabsTrigger>
      </TabsList>
      <TabsContent value="overview">
        <p class="text-sm text-muted-foreground">Northwind Logistics, enterprise tier, owned by Mara Tani.</p>
      </TabsContent>
      <TabsContent value="invoices">
        <p class="text-sm text-muted-foreground">14 invoices, 3 overdue.</p>
      </TabsContent>
      <TabsContent value="history">
        <p class="text-sm text-muted-foreground">Last edited 2026-04-12 by Linnea Borg.</p>
      </TabsContent>
      <TabsContent value="billing">
        <p class="text-sm text-muted-foreground">Not reachable: this trigger is disabled.</p>
      </TabsContent>
    </Tabs>
    <template #footer>
      <span>the active trigger carries <code>data-state=active</code>; only the matching panel mounts</span>
      <span>the fourth trigger sets <code>disabled</code>, so it is skipped by both pointer and keyboard</span>
      <span>arrow keys move between triggers, and Home and End jump to the ends</span>
    </template>
  </DemoCard>
</VuedaDemo>

## Drawer

`Drawer` is an edge-anchored overlay that slides in and can be dismissed by
dragging it back. It is built on vaul, so it carries touch affordances a `Sheet`
does not: a drag handle, a velocity-aware close threshold, and optional snap
points. Reach for it on touch-first surfaces, and for `Sheet` on pointer-first
ones; the two are otherwise the same shape of thing.

`DrawerContent` portals to the document body and renders its own overlay and
handle. Composition mirrors the dialog family: a trigger, then a content region
holding a header with a title and description, a body, and a footer.

Two defaults matter for embedding a drawer anywhere other than a full page.
`should-scale-background` is **on** by default and scales the page behind the
drawer, and vaul also writes styles onto `document.body` while open. Both are
right in an application and wrong inside a documentation page, so the demo turns
them off. An application usually leaves them alone.

The drawer needs real layout and pointer measurement, so it renders client-side
only. Its markup follows the documented composition; unlike the rest of this page
it is not covered by a unit test, because vaul cannot mount under jsdom.

Theme keys: [DrawerContent](/v3/reference/theming/keys/DrawerContent.md), [DrawerHeader](/v3/reference/theming/keys/DrawerHeader.md),
[DrawerTitle](/v3/reference/theming/keys/DrawerTitle.md), [DrawerDescription](/v3/reference/theming/keys/DrawerDescription.md),
[DrawerFooter](/v3/reference/theming/keys/DrawerFooter.md), [DrawerOverlay](/v3/reference/theming/keys/DrawerOverlay.md).

<VuedaDemo class="grid gap-6">
  <DemoCard title="bottom drawer" description=" (drag the handle down to dismiss)">
    <div class="flex justify-center py-4">
      <ClientOnly>
        <Drawer :no-body-styles="true" :should-scale-background="false">
          <DrawerTrigger as-child>
            <Button emphasis="outline">Edit payment terms</Button>
          </DrawerTrigger>
          <DrawerContent>
            <DrawerHeader>
              <DrawerTitle>Payment terms</DrawerTitle>
              <DrawerDescription>Override the customer default for this invoice only.</DrawerDescription>
            </DrawerHeader>
            <div class="px-4 text-sm text-muted-foreground">
              <p>Net 30 applies unless you set something else here.</p>
            </div>
            <DrawerFooter>
              <Button tone="primary">Apply</Button>
              <DrawerClose as-child>
                <Button emphasis="ghost">Cancel</Button>
              </DrawerClose>
            </DrawerFooter>
          </DrawerContent>
        </Drawer>
      </ClientOnly>
    </div>
    <template #footer>
      <span>the handle above the header is drawn by <code>DrawerContent</code> itself, not placed by the consumer</span>
      <span>dismiss by dragging the handle down, pressing Escape, or clicking the overlay; <code>DrawerClose</code> wraps any control to close</span>
      <span>this demo sets <code>:should-scale-background="false"</code> and <code>:no-body-styles="true"</code> so opening it does not scale or lock the documentation page</span>
    </template>
  </DemoCard>
</VuedaDemo>

## Resizable

`ResizablePanelGroup` splits a region into panels the reader can resize by
dragging the handle between them. `direction` sets the axis. Each
`ResizablePanel` takes sizes as **percentages** of the group, and a panel can be
`collapsible` with a `collapsed-size` so a drag past its minimum snaps it shut
instead of fighting the pointer.

Give the group an `auto-save-id` and it persists the layout to storage, so a
reader's split survives a reload. The demo below omits it deliberately: a
documentation page should not write layout state to your browser.

`ResizableHandle` is the drag target; `with-handle` adds a visible grip. Without
it the handle is still there and still draggable, just a hairline.

Theme keys: [ResizablePanelGroup](/v3/reference/theming/keys/ResizablePanelGroup.md), [ResizableHandle](/v3/reference/theming/keys/ResizableHandle.md).

<VuedaDemo class="grid gap-6 lg:grid-cols-2">
  <DemoCard title="horizontal" description=" (drag the grip between panels)">
    <ResizablePanelGroup class="h-48 rounded-vueda-card hairline hairline-border" direction="horizontal">
      <ResizablePanel :default-size="35" :min-size="20">
        <div class="flex h-full items-center justify-center p-4 text-sm text-muted-foreground">List</div>
      </ResizablePanel>
      <ResizableHandle with-handle />
      <ResizablePanel :default-size="65" :min-size="30">
        <div class="flex h-full items-center justify-center p-4 text-sm text-muted-foreground">Detail</div>
      </ResizablePanel>
    </ResizablePanelGroup>
    <template #footer>
      <span>sizes are percentages of the group, so they stay proportional as the card reflows</span>
      <span>the handle is keyboard operable: focus it and use the arrow keys</span>
      <span><code>:min-size</code> on both panels is what stops a drag from collapsing either to nothing</span>
    </template>
  </DemoCard>
  <DemoCard title="vertical" description=" (nested group, no visible grip)">
    <ResizablePanelGroup class="h-48 rounded-vueda-card hairline hairline-border" direction="vertical">
      <ResizablePanel :default-size="60">
        <div class="flex h-full items-center justify-center p-4 text-sm text-muted-foreground">Editor</div>
      </ResizablePanel>
      <ResizableHandle />
      <ResizablePanel :default-size="40" :min-size="15">
        <div class="flex h-full items-center justify-center p-4 text-sm text-muted-foreground">Output</div>
      </ResizablePanel>
    </ResizablePanelGroup>
    <template #footer>
      <span><code>direction="vertical"</code> stacks the panels and turns the handle into a horizontal rule</span>
      <span>this handle omits <code>with-handle</code>: still draggable, just without the grip glyph</span>
      <span>the group needs a height; a percentage split has nothing to divide otherwise</span>
    </template>
  </DemoCard>
</VuedaDemo>

## AspectRatio

`AspectRatio` holds a box at a fixed width-to-height ratio while its width is
free to change. Pass `ratio` as a number, so 16:9 is `16 / 9`. The child fills the
reserved box.

Use it to stop layout shift: the space is reserved before the content arrives, so
a slow image or an embed does not push the page around as it loads.

Theme keys: [AspectRatio](/v3/reference/theming/keys/AspectRatio.md).

<VuedaDemo class="grid gap-6 sm:grid-cols-3">
  <DemoCard title="16 / 9">
    <AspectRatio class="overflow-hidden rounded-vueda-card bg-muted" :ratio="16 / 9">
      <div class="flex h-full w-full items-center justify-center font-mono text-xs text-muted-foreground">16 / 9</div>
    </AspectRatio>
    <template #footer>
      <span>the box keeps its ratio as the column width changes</span>
    </template>
  </DemoCard>
  <DemoCard title="1 / 1">
    <AspectRatio class="overflow-hidden rounded-vueda-card bg-muted" :ratio="1">
      <div class="flex h-full w-full items-center justify-center font-mono text-xs text-muted-foreground">1 / 1</div>
    </AspectRatio>
    <template #footer>
      <span>a square avatar or thumbnail slot</span>
    </template>
  </DemoCard>
  <DemoCard title="3 / 4" description=" (portrait)">
    <AspectRatio class="overflow-hidden rounded-vueda-card bg-muted" :ratio="3 / 4">
      <div class="flex h-full w-full items-center justify-center font-mono text-xs text-muted-foreground">3 / 4</div>
    </AspectRatio>
    <template #footer>
      <span>a ratio below 1 reserves a taller box than it is wide</span>
    </template>
  </DemoCard>
</VuedaDemo>
