component

Capsule

A horizontal pill-shaped widget for self-contained interactive states: download / progress widgets, social activity highlights, system alerts, floating action panels, and media controls. Capsule is a compositional shell — it owns the pill frame (leading zone, freeform body, trailing zone, radius="full" Panel surface, optional interactive semantics) and lets you fill the body with Banner, Progress, custom rows, or anything else you need.

Use Capsule when a horizontal pill draws helpful attention to a discrete, self-contained widget. Do not use it as a generic replacement for table rows or in deep dense lists — rectangular containers scan better and save vertical space.

Default

Ada Lovelace

Ada Lovelace

Online · just now

---
import Capsule from "../Capsule.astro";
import Avatar from "@pindoba/astro-avatar";
import Banner from "@pindoba/astro-banner";
import Button from "@pindoba/astro-button";
import { stack } from "@pindoba/styled-system/patterns";
---

<div class={stack({ gap: "md", direction: "column", width: "100%" })}>
  <Capsule
    border="muted"
    bodyPanel={{ padding: "3xs" }}
    leadingPanel={{ padding: "xs" }}
    trailingPanel={{ padding: "sm" }}
  >
    <Avatar
      slot="leading"
      name="Ada Lovelace"
      src="https://i.pravatar.cc/96?u=ada"
      alt="Ada Lovelace"
      size="lg"
    />
    <Banner heading="Ada Lovelace" subheading="Online · just now" size="sm" />
    <Button slot="trailing" size="md" emphasis="primary" shape="pill"
      >Message</Button
    >
  </Capsule>
</div>

Use cases

The body is intentionally freeform. Each demo below composes Capsule with different pindoba primitives to express a use case from the spec.

Download / progress widget

Thumbnail leading, body composed of a Banner row (heading + subheading) plus a right-aligned metric column (“45%” / “12.5 GB / 85.0 GB”), full-width Progress underneath, paired icon buttons in trailing. trailingIsIconOnly tightens the right padding so the icon buttons nest cleanly into the curve.

Cyberpunk 2077

Cyberpunk 2077

45%

Calculating time remaining...

12.5 GB / 85 GB
---
import Capsule from "../Capsule.astro";
import Avatar from "@pindoba/astro-avatar";
import Banner from "@pindoba/astro-banner";
import Button from "@pindoba/astro-button";
import Progress from "@pindoba/astro-progress";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
import { Pause, X } from "@lucide/astro";

// Numbers match the PS5-inspired reference: shown "downloaded so far" GB
// and total package GB, with an independently-tracked percent.
const downloaded = 12.5;
const total = 85.0;
const percent = 45;
---

<div class={stack({ gap: "md", direction: "column", width: "100%" })}>
  <!--
    Download widget — PS5-inspired. Percent and "downloaded/total GB" ride
    on Banner's own `heading.trailing` / `subheading.trailing` slots so
    they sit in the same rail as the text without a separate column. The
    progress bar lives in the body below; pause/cancel sit in trailing.
  -->
  <Capsule
    size="lg"
    background="surface.ground"
    border="muted"
    leadingPanel={{ padding: "sm" }}
    bodyPanel={{ padding: "none" }}
    trailingPanel={{ padding: "sm" }}
  >
    <Avatar
      slot="leading"
      name="Cyberpunk 2077"
      src="https://picsum.photos/seed/cyberpunk/96/96"
      alt="Cyberpunk 2077"
      size="xl"
      shape="circle"
    />

    <div class={stack({ gap: "xs", direction: "column", width: "100%" })}>
      <Banner
        heading={{ content: "Cyberpunk 2077", trailing: `${percent}%` }}
        subheading={{
          content: "Calculating time remaining...",
          trailing: `${downloaded} GB / ${total} GB`,
        }}
        passThrough={{
          headingTrailing: {
            style: css.raw({
              whiteSpace: "nowrap",
              fontVariantNumeric: "tabular-nums",
              fontSize: "sm",
              fontWeight: "semibold",
            }),
          },
          subheadingTrailing: {
            style: css.raw({
              whiteSpace: "nowrap",
              fontVariantNumeric: "tabular-nums",
              fontSize: "xs",
            }),
          },
        }}
      />
      <Progress value={percent} max={100} feedback="primary" />
    </div>

    <Fragment slot="trailing">
      <Button
        size="xl"
        emphasis="secondary"
        shape="circle"
        aria-label="Pause download"
      >
        <Pause />
      </Button>
      <Button
        size="xl"
        emphasis="ghost"
        shape="circle"
        aria-label="Cancel download"
      >
        <X />
      </Button>
    </Fragment>
  </Capsule>
</div>

Social activity

Avatar leading, single-line Banner body, primary action in trailing. The simplest case the spec covers.

Elena Rostova

Elena Rostova

Currently playing Elden Ring

---
import Capsule from "../Capsule.astro";
import Avatar from "@pindoba/astro-avatar";
import Banner from "@pindoba/astro-banner";
import Button from "@pindoba/astro-button";
import { stack } from "@pindoba/styled-system/patterns";
---

<div class={stack({ gap: "md", direction: "column", width: "100%" })}>
  <Capsule
    border="muted"
    bodyPanel={{ padding: "4xs" }}
    leadingPanel={{ padding: "xs" }}
    trailingPanel={{ padding: "sm" }}
  >
    <Avatar
      slot="leading"
      name="Elena Rostova"
      src="https://i.pravatar.cc/96?u=elena"
      alt="Elena Rostova"
      size="lg"
    />
    <Banner
      size="sm"
      heading="Elena Rostova"
      subheading="Currently playing Elden Ring"
    />
    <Button
      slot="trailing"
      size="md"
      emphasis="primary"
      feedback="primary"
      shape="pill"
    >
      Join Session
    </Button>
  </Capsule>
</div>

System alert

Stamp leading, feedback="warning" flowing to surface and text, ghost trailing action.

Build failed

main · 12 min ago

---
import Capsule from "../Capsule.astro";
import Stamp from "@pindoba/astro-stamp";
import Banner from "@pindoba/astro-banner";
import Button from "@pindoba/astro-button";
import { stack } from "@pindoba/styled-system/patterns";
import { TriangleAlert } from "@lucide/astro";
---

<div class={stack({ gap: "md", direction: "column", width: "100%" })}>
  <Capsule
    border="muted"
    bodyPanel={{ padding: "4xs" }}
    trailingPanel={{ padding: "sm" }}
    leadingPanel={{ padding: "xs" }}
  >
    <Stamp
      slot="leading"
      feedback="warning"
      emphasis="primary"
      shape="circle"
      size="lg"
      border="none"
    >
      <TriangleAlert />
    </Stamp>
    <Banner size="sm" heading="Build failed" subheading="main · 12 min ago" />
    <Button
      slot="trailing"
      size="md"
      emphasis="secondary"
      feedback="warning"
      shape="pill"
    >
      View logs
    </Button>
  </Capsule>
</div>

Floating action

as="button" + interactive turns the whole pill into a focusable trigger. The focus ring traces the full capsule shape. No trailing zone → padding is symmetric.

3

3 items selected

Bulk actions

---
import Capsule from "../Capsule.astro";
import Stamp from "@pindoba/astro-stamp";
import Banner from "@pindoba/astro-banner";
import Button from "@pindoba/astro-button";
import { stack } from "@pindoba/styled-system/patterns";
import { Archive, FolderInput, Trash2 } from "@lucide/astro";
---

<div class={stack({ gap: "md", direction: "column", width: "100%" })}>
  <!--
    Floating batch actions card. Leading shows the selection count via a
    Stamp; trailing groups bulk-action buttons in a padded rail. The
    capsule itself is non-interactive — each action button is the real
    click target.
  -->
  <Capsule
    border="muted"
    shadow="lg"
    bodyPanel={{ padding: "4xs" }}
    leadingPanel={{ padding: "xs" }}
    trailingPanel={{ padding: "xs" }}
  >
    <Stamp
      slot="leading"
      feedback="primary"
      emphasis="primary"
      shape="circle"
      size="lg"
      border="none"
    >
      3
    </Stamp>
    <Banner size="sm" heading="3 items selected" subheading="Bulk actions" />
    <Fragment slot="trailing">
      <Button
        size="md"
        emphasis="ghost"
        shape="circle"
        aria-label="Archive selection"
      >
        <Archive />
      </Button>
      <Button
        size="md"
        emphasis="ghost"
        shape="circle"
        aria-label="Move selection"
      >
        <FolderInput />
      </Button>
      <Button
        size="md"
        emphasis="ghost"
        feedback="danger"
        shape="circle"
        aria-label="Delete selection"
      >
        <Trash2 />
      </Button>
    </Fragment>
  </Capsule>
</div>

Asymmetric padding

Capsule auto-tightens the side under a leading asset (so it nests into the outer curve) and under an icon-only trailing button (same reason). When neither zone is present, padding stays symmetric.

LeadingTrailingLeft paddingRight padding
looseloose
tightloose
text buttonlooseloose
icon button (trailingIsIconOnly)loosetight
text buttontightloose
icon button (trailingIsIconOnly)tighttight

Override with paddingLeading / paddingTrailing if you need to force a specific value.

Slot padding defaults

You don’t need to pass leadingPanel / trailingPanel just to get sensible inner padding around the slot wrapper — Capsule bakes per-size defaults into both:

Capsule sizeLeading inner paddingTrailing inner paddingBody vertical padding
sm2xsxs3xs
md2xsxs4xs
lgxsxs3xs

Only pass leadingPanel / trailingPanel when you want a distinct slot surface (tinted background, feedback color, border, shadow) or want to override the default inner padding — Panel then owns the slot’s padding entirely.

Anchor capsules (as="a" or href present) drop the default text-underline so the inner Avatar/Banner composition reads as a clickable surface. Pass passThrough.root.style.textDecoration to bring an underline back.

Sizes

The size prop locks the leading and trailing zone heights so the pill stays the same height regardless of what you put in them. Pair nested children (Avatar, Stamp, Button) with the matching size for optical alignment — CAPSULE_SIZE_PAIRINGS exports the recommended mapping.

Capsule sizeAvatar / StampButtonBadge
smsmsmsm
mdmdmdmd
lglglgmd
Ada Lovelace

Capsule sm

Ada Lovelace

Capsule md

Paired children scale with size

Ada Lovelace

Capsule lg

Paired children scale with size

---
import Capsule from "../Capsule.astro";
import { CAPSULE_SIZE_PAIRINGS } from "@pindoba/core-capsule";
import Avatar from "@pindoba/astro-avatar";
import Banner from "@pindoba/astro-banner";
import Button from "@pindoba/astro-button";
import { stack } from "@pindoba/styled-system/patterns";

const sizes = ["sm", "md", "lg"] as const;
---

<div class={stack({ gap: "md", direction: "column", width: "100%" })}>
  {
    sizes.map((size) => {
      const pair = CAPSULE_SIZE_PAIRINGS[size];
      return (
        <Capsule
          size={size}
          border="muted"
          bodyPanel={{ padding: "none" }}
          leadingPanel={{ padding: CAPSULE_SIZE_PAIRINGS[size] }}
          trailingPanel={{ padding: "xs" }}
        >
          <Avatar
            slot="leading"
            name="Ada Lovelace"
            src="https://i.pravatar.cc/96?u=ada"
            alt="Ada Lovelace"
            size={pair.avatar}
          />
          <Banner
            heading={`Capsule ${size}`}
            subheading={
              size != "sm" ? "Paired children scale with size" : undefined
            }
            size="sm"
          />
          <Button
            slot="trailing"
            size={pair.button}
            emphasis="primary"
            shape="pill"
          >
            Action
          </Button>
        </Capsule>
      );
    })
  }
</div>

Interactive

When the entire capsule acts as a navigational element, pass as="a" with an href or as="button" with interactive. The focus ring traces the full pill, inheriting Panel’s :focus-visible outline.

When the capsule is just a container for smaller, independent interactions (separate Pause / Cancel buttons in trailing, for example), leave as as the default "div" and let the inner buttons take focus.

---
import Capsule from "../Capsule.astro";
import Avatar from "@pindoba/astro-avatar";
import Banner from "@pindoba/astro-banner";
import Stamp from "@pindoba/astro-stamp";
import { stack } from "@pindoba/styled-system/patterns";
import { ChevronRight } from "@lucide/astro";
---

<div class={stack({ gap: "md", direction: "column", width: "100%" })}>
  <Capsule
    href="#capsule-link-example"
    interactive
    border="muted"
    leadingPanel={{ padding: "xs" }}
    trailingPanel={{ padding: "2xs" }}
    bodyPanel={{ padding: "4xs" }}
  >
    <Avatar
      slot="leading"
      name="Project"
      src="https://picsum.photos/seed/project/96/96"
      alt="Project"
      size="lg"
    />
    <Banner
      size="sm"
      heading="Open project"
      subheading="Renders as a real <a> with href"
    />
    <Stamp
      slot="trailing"
      shape="circle"
      size="md"
      border="none"
      background="transparent"
    >
      <ChevronRight />
    </Stamp>
  </Capsule>
</div>

Feedback

Capsule forwards feedback to Panel, so the surface, border, and any panel-aware text tokens inside it (headings rendered by Banner, etc.) follow the selected palette.

neutral

Capsule with neutral feedback

primary

Capsule with primary feedback

success

Capsule with success feedback

warning

Capsule with warning feedback

danger

Capsule with danger feedback

---
import Capsule from "../Capsule.astro";
import Stamp from "@pindoba/astro-stamp";
import Banner from "@pindoba/astro-banner";
import { stack } from "@pindoba/styled-system/patterns";
import { Info } from "@lucide/astro";

const feedbacks = [
  "neutral",
  "primary",
  "success",
  "warning",
  "danger",
] as const;
---

<div class={stack({ gap: "md", direction: "column", width: "100%" })}>
  {
    feedbacks.map((feedback) => (
      <Capsule
        feedback={feedback}
        border="muted"
        bodyPanel={{ padding: "4xs" }}
        leadingPanel={{ padding: "2xs" }}
      >
        <Stamp
          slot="leading"
          feedback={feedback}
          emphasis="primary"
          shape="circle"
          size="lg"
          border="none"
        >
          <Info />
        </Stamp>
        <Banner
          size="sm"
          heading={feedback}
          subheading={`Capsule with ${feedback} feedback`}
        />
      </Capsule>
    ))
  }
</div>

Slot surfaces

The leadingPanel and trailingPanel props give each side its own Panel surface — background, feedback, emphasis, padding, border, radius — independent of the capsule body. Use it for status-tinted leading icons, contrasting right-side action rails, or stepped indicator zones. Mirrors how Card lets its header and footer carry their own surface.

Changes saved

2 seconds ago

Pro tier

Unlock advanced analytics

---
import Capsule from "../Capsule.astro";
import Banner from "@pindoba/astro-banner";
import Stamp from "@pindoba/astro-stamp";
import Button from "@pindoba/astro-button";
import { stack } from "@pindoba/styled-system/patterns";
import { Check, RefreshCw, Zap } from "@lucide/astro";
---

<div class={stack({ gap: "md", direction: "column", width: "100%" })}>
  <!-- Success-tinted leading with a check icon -->
  <Capsule
    border="muted"
    trailingPanel={{
      emphasis: "secondary",
      feedback: "neutral",
    }}
    bodyPanel={{
      padding: "none",
    }}
  >
    <Stamp
      slot="leading"
      emphasis="primary"
      feedback="success"
      shape="circle"
      size="lg"
      border="none"
    >
      <Check />
    </Stamp>
    <Banner heading="Changes saved" subheading="2 seconds ago" />
    <Button slot="trailing" size="lg" emphasis="ghost" shape="pill">
      <RefreshCw />
      Undo
    </Button>
  </Capsule>

  <!-- Tinted trailing zone -->
  <Capsule
    border="muted"
    leadingPanel={{
      emphasis: "secondary",
      feedback: "neutral",
      padding: "none",
    }}
    trailingPanel={{
      emphasis: "secondary",
      feedback: "neutral",
      padding: "md",
    }}
    bodyPanel={{
      padding: "none",
    }}
  >
    <Stamp
      slot="leading"
      shape="circle"
      size="xl"
      border="none"
      background="surface.ground"
    >
      <Zap />
    </Stamp>
    <Banner heading="Pro tier" subheading="Unlock advanced analytics" />
    <Button
      slot="trailing"
      size="lg"
      emphasis="primary"
      feedback="primary"
      shape="pill"
    >
      Upgrade
    </Button>
  </Capsule>
</div>

Custom styling

passThrough accepts a { style, props } pair for each of the four slots: root, leading, body, trailing. Use it to override pill borders, slot outlines, body typography, or to inject HTML attributes onto any slot wrapper.

CU

Custom slot styling

passThrough applied to every slot

---
import Capsule from "../Capsule.astro";
import Avatar from "@pindoba/astro-avatar";
import Banner from "@pindoba/astro-banner";
import Button from "@pindoba/astro-button";
import { stack } from "@pindoba/styled-system/patterns";
---

<div class={stack({ gap: "md", direction: "column", width: "100%" })}>
  <Capsule
    feedback="primary"
    border="muted"
    bodyPanel={{ padding: "sm" }}
    trailingPanel={{ padding: "md" }}
    passThrough={{
      root: { style: { borderStyle: "dashed" } },
      leading: {
        style: {
          outline: "2px dotted",
          outlineColor: "primary.border.muted",
          outlineOffset: "2px",
        },
      },
      body: { style: { fontStyle: "italic" } },
    }}
  >
    <Avatar slot="leading" name="Custom" size="lg" />
    <Banner
      heading="Custom slot styling"
      subheading="passThrough applied to every slot"
    />
    <Button slot="trailing" size="md" emphasis="secondary" shape="pill"
      >Edit</Button
    >
  </Capsule>
</div>
props · 21 shown · 21 total
as
"div""section""aside""a""button""label"

Semantic element. Defaults: `button` when `interactive && !href`, `a` when `href` is present, else `div`.

background
"surface.peak""surface.hill""surface.base""surface.valley""surface.ground""transparent"

Surface background from the Panel scale (`surface.peak` → `surface.ground`, or `transparent`).

bodyPanel
CapsuleSlotPanel

Panel surface options applied to the body. When set, takes full ownership of the body's padding — replacing both the size-driven vertical padding and the asymmetric horizontal rule. Default radius is `none` so a tinted body reads as a strip rather than a rounded chip.

border
"none""bold""default""muted""accent"

Border style: `none`, `default`, `bold`, or `muted`.

borderInteract
"none""bold""default""muted""accent"

Border style applied on hover / focus interaction.

children slot svelte
Snippet

Body content. Compose any pindoba component or custom layout here.

element binding svelte
HTMLElementnull

No description yet.

emphasis
"primary""secondary""tertiary"

Visual emphasis / prominence level.

feedback
"primary""neutral""success""warning""danger""inherit"

Semantic color tone: `neutral`, `primary`, `success`, `warning`, `danger`, or `inherit`.

interactive
boolean

Enable interactive (hover / focus / press) affordances and states.

leading slot svelte
Snippet

Leading zone — icon, avatar, stamp, thumbnail.

leadingPanel
CapsuleSlotPanel

Panel surface options applied to the leading wrapper (background, feedback, emphasis, padding, border, radius, shadow, translucent) — like Card's `header` slot. When omitted, Capsule applies a sensible per-size inner padding; when supplied, Panel owns the slot's padding.

paddingLeading
string

Override the computed left padding. Pass any Panda spacing token name (e.g. `"sm"`, `"xs"`, `"none"`).

paddingTrailing
string

Override the computed right padding. Any Panda spacing token.

passThrough
CapsulePassThrough<RootElementAttributes>

Per-slot style and HTML-attribute override bag. Each slot accepts a `style` (Panda `SystemStyleObject`) merged into the slot's class and a `props` object spread onto the slot element.

shadow
"sm""md""lg""xl""none""xs"

Elevation shadow applied to the surface.

size
"sm""md""lg"
default "md"

Locks leading/trailing zone heights and feeds the asymmetric padding rule. `sm`: 24px. `md`: 32px. `lg`: 40px.

trailing slot svelte
Snippet

Trailing zone — action buttons, badges, status indicators.

trailingIsIconOnly
boolean
default false

Squeeze right-side padding so a single icon button nests into the right curve. The body's left edge tightens automatically when a leading asset is present (detected structurally — no `hasLeading` flag needed).

trailingPanel
CapsuleSlotPanel

Panel surface options applied to the trailing wrapper. Same shape and default-padding behavior as `leadingPanel`.

translucent
boolean

Apply a frosted-glass effect with backdrop blur over a surface background.

Plus all standard <div> HTML attributes.

Type

  • Components
  • Blocks