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

---
// GENERATED from packages/demos/capsule/default.demo.tsx — edit the source, not this file.
import Capsule from "@pindoba/astro-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";
---

<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"
    />
    <Button slot="trailing" size="md" emphasis="primary" shape="pill">
      Message
    </Button>
    <Banner heading="Ada Lovelace" subheading="Online · just now" size="sm" />
  </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
---
// GENERATED from packages/demos/capsule/download-progress.demo.tsx — edit the source, not this file.
import Capsule from "@pindoba/astro-capsule";
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 { Pause, X } from "@lucide/astro";
import { stack } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";

const downloaded = 12.5;
const total = 85.0;
const percent = 45;
---

<div class={stack({ gap: "md", direction: "column", width: "100%" })}>
  <Capsule
    size="lg"
    background="surface.ground"
    border="muted"
    leadingPanel={{ padding: "none" }}
    bodyPanel={{ padding: "sm" }}
    trailingPanel={{ padding: "sm" }}
  >
    <Avatar
      slot="leading"
      name="Cyberpunk 2077"
      src="https://picsum.photos/seed/cyberpunk/96/96"
      alt="Cyberpunk 2077"
      size="xl"
      shape="circle"
    />
    <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>
    <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} tone="primary" />
    </div>
  </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

---
// GENERATED from packages/demos/capsule/social-activity.demo.tsx — edit the source, not this file.
import Capsule from "@pindoba/astro-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";
---

<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"
    />
    <Button
      slot="trailing"
      size="md"
      emphasis="primary"
      tone="primary"
      shape="pill"
    >
      Join Session
    </Button>
    <Banner
      size="sm"
      heading="Elena Rostova"
      subheading="Currently playing Elden Ring"
    />
  </Capsule>
</div>

System alert

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

Build failed

main · 12 min ago

---
// GENERATED from packages/demos/capsule/system-alert.demo.tsx — edit the source, not this file.
import Capsule from "@pindoba/astro-capsule";
import Stamp from "@pindoba/astro-stamp";
import Banner from "@pindoba/astro-banner";
import Button from "@pindoba/astro-button";
import { TriangleAlert } from "@lucide/astro";
import { stack } from "@pindoba/styled-system/patterns";
---

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

---
// GENERATED from packages/demos/capsule/floating-action.demo.tsx — edit the source, not this file.
import Capsule from "@pindoba/astro-capsule";
import Stamp from "@pindoba/astro-stamp";
import Banner from "@pindoba/astro-banner";
import Button from "@pindoba/astro-button";
import { Archive, FolderInput, Trash } from "@lucide/astro";
import { stack } from "@pindoba/styled-system/patterns";
---

<div class={stack({ gap: "md", direction: "column", width: "100%" })}>
  <Capsule
    border="muted"
    shadow="lg"
    bodyPanel={{ padding: "4xs" }}
    leadingPanel={{ padding: "xs" }}
    trailingPanel={{ padding: "xs" }}
  >
    <Stamp
      slot="leading"
      tone="primary"
      emphasis="primary"
      shape="circle"
      size="lg"
      border="none"
    >
      3
    </Stamp>
    <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"
        tone="danger"
        shape="circle"
        aria-label="Delete selection"
      >
        <Trash />
      </Button>
    </Fragment>
    <Banner size="sm" heading="3 items selected" subheading="Bulk actions" />
  </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, tone 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

Paired children scale with size

Ada Lovelace

Capsule md

Paired children scale with size

Ada Lovelace

Capsule lg

Paired children scale with size

---
// GENERATED from packages/demos/capsule/size.demo.tsx — edit the source, not this file.
import Capsule from "@pindoba/astro-capsule";
import Avatar from "@pindoba/astro-avatar";
import Banner from "@pindoba/astro-banner";
import Button from "@pindoba/astro-button";
import { CAPSULE_SIZE_PAIRINGS } from "@pindoba/core-capsule";
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: size }}
        trailingPanel={{ padding: "xs" }}
      >
        <Avatar
          slot="leading"
          name="Ada Lovelace"
          src="https://i.pravatar.cc/96?u=ada"
          alt="Ada Lovelace"
          size={pair.avatar}
        />
        <Button
          slot="trailing"
          size={pair.button}
          emphasis="primary"
          shape="pill"
        >
          Action
        </Button>
        <Banner
          heading={`Capsule ${size}`}
          subheading="Paired children scale with size"
        />
      </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.

---
// GENERATED from packages/demos/capsule/interactive.demo.tsx — edit the source, not this file.
import Capsule from "@pindoba/astro-capsule";
import Avatar from "@pindoba/astro-avatar";
import Banner from "@pindoba/astro-banner";
import Stamp from "@pindoba/astro-stamp";
import { ChevronRight } from "@lucide/astro";
import { stack } from "@pindoba/styled-system/patterns";
---

<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"
    />
    <Stamp
      slot="trailing"
      shape="circle"
      size="md"
      border="none"
      background="transparent"
    >
      <ChevronRight />
    </Stamp>
    <Banner
      size="sm"
      heading="Open project"
      subheading="Renders as a real <a> with href"
    />
  </Capsule>
</div>

Tone

Capsule forwards tone 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 tone

primary

Capsule with primary tone

success

Capsule with success tone

warning

Capsule with warning tone

danger

Capsule with danger tone

---
// GENERATED from packages/demos/capsule/tone.demo.tsx — edit the source, not this file.
import Capsule from "@pindoba/astro-capsule";
import Stamp from "@pindoba/astro-stamp";
import Banner from "@pindoba/astro-banner";
import { Info } from "@lucide/astro";
import { stack } from "@pindoba/styled-system/patterns";

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

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

Slot surfaces

The leadingPanel and trailingPanel props give each side its own Panel surface — background, tone, 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

---
// GENERATED from packages/demos/capsule/slot-surfaces.demo.tsx — edit the source, not this file.
import Capsule from "@pindoba/astro-capsule";
import Stamp from "@pindoba/astro-stamp";
import Banner from "@pindoba/astro-banner";
import Button from "@pindoba/astro-button";
import Affix from "@pindoba/astro-affix";
import { Check, RefreshCw, Zap } from "@lucide/astro";
import { stack } from "@pindoba/styled-system/patterns";
---

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

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

---
// GENERATED from packages/demos/capsule/custom.demo.tsx — edit the source, not this file.
import Capsule from "@pindoba/astro-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";
---

<div class={stack({ gap: "md", direction: "column", width: "100%" })}>
  <Capsule
    tone="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" />
    <Button slot="trailing" size="md" emphasis="secondary" shape="pill">
      Edit
    </Button>
    <Banner
      heading="Custom slot styling"
      subheading="passThrough applied to every slot"
    />
  </Capsule>
</div>
  • Components
  • Blocks