component
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.
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>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.
Calculating time remaining...
---
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>Avatar leading, single-line Banner body, primary action in trailing. The simplest case the spec covers.
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>Stamp leading, feedback="warning" flowing to surface and text, ghost trailing action.
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>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.
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>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, 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.
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 |
Paired children scale with size
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>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>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.
Capsule with neutral feedback
Capsule with primary feedback
Capsule with success feedback
Capsule with warning feedback
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>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.
2 seconds ago
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>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.
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>Semantic element. Defaults: `button` when `interactive && !href`, `a` when `href` is present, else `div`.
Surface background from the Panel scale (`surface.peak` → `surface.ground`, or `transparent`).
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 style: `none`, `default`, `bold`, or `muted`.
Border style applied on hover / focus interaction.
Body content. Compose any pindoba component or custom layout here.
No description yet.
Visual emphasis / prominence level.
Semantic color tone: `neutral`, `primary`, `success`, `warning`, `danger`, or `inherit`.
Enable interactive (hover / focus / press) affordances and states.
Leading zone — icon, avatar, stamp, thumbnail.
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.
Override the computed left padding. Pass any Panda spacing token name (e.g. `"sm"`, `"xs"`, `"none"`).
Override the computed right padding. Any Panda spacing token.
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.
Elevation shadow applied to the surface.
"md"Locks leading/trailing zone heights and feeds the asymmetric padding rule. `sm`: 24px. `md`: 32px. `lg`: 40px.
Trailing zone — action buttons, badges, status indicators.
falseSqueeze 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).
Panel surface options applied to the trailing wrapper. Same shape and default-padding behavior as `leadingPanel`.
Apply a frosted-glass effect with backdrop blur over a surface background.
Plus all standard <div> HTML
attributes.