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.
---
// 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>The body is intentionally freeform. Each demo below composes Capsule with different pindoba primitives to express a use case from the spec.
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.
---
// 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>Avatar leading, single-line Banner body, primary action in trailing. The simplest case the spec covers.
---
// 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>Stamp leading, tone="warning" flowing to surface and text, ghost trailing action.
---
// 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>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.
---
// 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>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.
| Leading | Trailing | Left padding | Right padding |
|---|---|---|---|
| – | – | loose | loose |
| ✓ | – | tight | loose |
| – | text button | loose | loose |
| – | icon button (trailingIsIconOnly) | loose | tight |
| ✓ | text button | tight | loose |
| ✓ | icon button (trailingIsIconOnly) | tight | tight |
Override with paddingLeading / paddingTrailing if you need to force a specific value.
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 size | Leading inner padding | Trailing inner padding | Body vertical padding |
|---|---|---|---|
sm | 2xs | xs | 3xs |
md | 2xs | xs | 4xs |
lg | xs | xs | 3xs |
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.
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 size | Avatar / Stamp | Button | Badge |
|---|---|---|---|
sm | sm | sm | sm |
md | md | md | md |
lg | lg | lg | md |
---
// 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>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>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.
---
// 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>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.
---
// 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>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.
---
// 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>