A reusable, slot-rich title bar with flanking leading/trailing snippets and an optional nested heading + subheading, each with their own leading/trailing. Banner is built on top of Panel (surfaces, borders, padding, radius) so it can be used standalone as a section header, embedded inside a card or dialog, or dropped anywhere a “title with actions” pattern is needed.
A minimal banner with only a heading.
---
// GENERATED from packages/demos/banner/basic.demo.tsx — edit the source, not this file.
import Banner from "@pindoba/astro-banner";
---
<Banner heading="Project settings" />Add a subheading for a two-line title stack.
---
// GENERATED from packages/demos/banner/with-subheading.demo.tsx — edit the source, not this file.
import Banner from "@pindoba/astro-banner";
---
<Banner
heading="Project settings"
subheading="Manage workspace access, billing, and integrations"
/>eyebrow adds a small kicker line above the heading — case-preserving and muted by default. It inherits panel-aware text color, so under emphasis="primary" it automatically flips to a contrast variant. Accepts the same string | { leading, content, trailing } shape as heading.
Whether the eyebrow reads as a quiet meta line, a label-style kicker, or an uppercase overline is a styling decision the consumer owns. Reach for passThrough.eyebrow.style (or write the text uppercased yourself) when you want a tighter, ALL-CAPS look:
<Banner
eyebrow="Wednesday"
heading="Dec 14"
passThrough={{
eyebrow: {
style: { textTransform: "uppercase", letterSpacing: "0.05em" },
},
}}
/>
---
// GENERATED from packages/demos/banner/with-eyebrow.demo.tsx — edit the source, not this file.
import Banner from "@pindoba/astro-banner";
import Badge from "@pindoba/astro-badge";
import Stamp from "@pindoba/astro-stamp";
import { Calendar, Sparkles, Bell } from "@lucide/astro";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "xl", direction: "column", width: "100%" })}>
<Banner eyebrow="Wednesday" heading="Dec 14" />
<Banner
eyebrow={"Today"}
heading="Project review"
subheading="2:00 PM with the design team"
>
<Stamp
slot="eyebrow-leading"
size="2xs"
shape="square"
tone="neutral"
border="none"
>
<Calendar />
</Stamp>
</Banner>
<Banner
eyebrow={"Featured"}
heading="Animation framework"
subheading="Smoother, more dynamic transitions"
>
<Badge slot="eyebrow-trailing" size="xs" tone="primary">
New
</Badge>
</Banner>
<Banner
eyebrow={"Siri suggestions"}
heading="Add Safina"
subheading="@saffina28 on Instagram"
>
<Stamp
slot="eyebrow-leading"
size="2xs"
shape="square"
tone="neutral"
border="none"
>
<Sparkles />
</Stamp>
</Banner>
<Banner
tone="primary"
emphasis="primary"
background="surface.peak"
padding="md"
radius="md"
eyebrow={"Notification"}
heading="Eyebrow flips to contrast"
subheading="Same auto-contrast cascade as heading and subheading."
>
<Stamp
slot="eyebrow-leading"
size="2xs"
shape="square"
emphasis="adaptive"
border="none"
>
<Bell />
</Stamp>
</Banner>
</div>Banner’s slots compose cleanly with Stamp — use them at the top level (leading, trailing) or inside the heading itself via the { leading, content, trailing } shape.
---
// GENERATED from packages/demos/banner/with-stamp.demo.tsx — edit the source, not this file.
import Banner from "@pindoba/astro-banner";
import Stamp from "@pindoba/astro-stamp";
import { Inbox, Check, Star, Bell } from "@lucide/astro";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "xl", direction: "column" })}>
<Banner heading="Inbox" subheading="12 new notifications">
<Stamp slot="leading" background="surface.ground" shape="square" size="lg">
<Inbox />
</Stamp>
<Stamp slot="trailing" tone="success" emphasis="secondary" shape="circle">
<Check />
</Stamp>
</Banner>
<Banner
heading={"Featured item"}
subheading="Stamps composed inline with the heading"
>
<Stamp
slot="heading-leading"
size="sm"
background="surface.ground"
tone="warning"
>
<Star />
</Stamp>
<Stamp slot="heading-trailing" size="sm" tone="danger" shape="circle">
<Bell />
</Stamp>
</Banner>
</div>An addon beside a row’s text — heading={{ leading, content, trailing }}, and the same object form on eyebrow / subheading — is sized and placed from that row’s own type. Each row publishes its font size and line box, and a <Badge>, <Stamp>, <Kbd> or <Button> in its leading / trailing slot sizes to about 0.84 of the line box (its label at about 0.6 of the row’s font, floored at the xs Badge), whatever the size or *TextStyle. The default firstLine row align then sits the addon on the row’s first line, so a heading that wraps keeps its badge beside its opening words instead of in the middle of the paragraph. The addons are Affixes on the Control Var Contract: pass passThrough.headingTrailing.props.fit: "content" to keep an addon’s own size, or layout.heading.align: "center" for the old block-centred alignment.
---
// GENERATED from packages/demos/banner/inline-addons.demo.tsx — edit the source, not this file.
import Banner from "@pindoba/astro-banner";
import Badge from "@pindoba/astro-badge";
import Button from "@pindoba/astro-button";
import Stamp from "@pindoba/astro-stamp";
import { Globe } from "@lucide/astro";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
const narrow = css({ maxWidth: "26rem" });
---
<div class={stack({ gap: "xl", direction: "column" })}>
<Banner
size="xs"
layout={{ heading: { trailing: "together" } }}
heading={"quintal.studio"}
subheading="size xs"
>
<Stamp slot="heading-leading" emphasis="ghost">
<Globe />
</Stamp>
<Button slot="heading-trailing" size="xs" emphasis="secondary" shape="pill">
View docs
</Button>
</Banner>
<Banner
size="sm"
layout={{ heading: { trailing: "together" } }}
heading={"quintal.studio"}
subheading="size sm"
>
<Stamp slot="heading-leading" emphasis="ghost">
<Globe />
</Stamp>
<Button slot="heading-trailing" size="xs" emphasis="secondary" shape="pill">
View docs
</Button>
</Banner>
<Banner
size="md"
layout={{ heading: { trailing: "together" } }}
heading={"quintal.studio"}
subheading="size md"
>
<Stamp slot="heading-leading" emphasis="ghost">
<Globe />
</Stamp>
<Button slot="heading-trailing" size="xs" emphasis="secondary" shape="pill">
View docs
</Button>
</Banner>
<Banner
size="lg"
layout={{ heading: { trailing: "together" } }}
heading={"quintal.studio"}
subheading="size lg"
>
<Stamp slot="heading-leading" emphasis="ghost">
<Globe />
</Stamp>
<Button slot="heading-trailing" size="xs" emphasis="secondary" shape="pill">
View docs
</Button>
</Banner>
<Banner
size="md"
class={narrow}
layout={{ heading: { trailing: "together" } }}
eyebrow={"Zone"}
heading={"A long heading that wraps onto a second line"}
subheading={"Proxied through the edge"}
>
<Badge slot="eyebrow-trailing" tone="primary">
New
</Badge>
<Badge slot="heading-trailing" tone="success">
Active
</Badge>
<Badge slot="subheading-trailing">3 rules</Badge>
</Banner>
</div>Four sizes scale all three text rows together — the heading and subheading each step through a distinct token per size, and the eyebrow nudges up at lg. Padding/radius come from Panel and can be set independently.
---
// GENERATED from packages/demos/banner/sizes.demo.tsx — edit the source, not this file.
import Banner from "@pindoba/astro-banner";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "lg", direction: "column" })}>
<Banner
size="xs"
eyebrow="Eyebrow"
heading="Extra small"
subheading="size xs"
/>
<Banner size="sm" eyebrow="Eyebrow" heading="Small" subheading="size sm" />
<Banner size="md" eyebrow="Eyebrow" heading="Medium" subheading="size md" />
<Banner size="lg" eyebrow="Eyebrow" heading="Large" subheading="size lg" />
</div>While size scales all three text rows together, headingTextStyle, eyebrowTextStyle, and subheadingTextStyle let each slot adopt a design-system textStyle token directly — e.g. a big tabular numeric.lg heading over a mono uppercase eyebrow label for KPI/stat cards. A token fully owns its slot’s typography (overriding the size-derived sizing and, for the heading, headingWeight); passThrough.<slot>.style still layers on top. Because these live on the shared base props, any component that extends Banner (such as Card’s header) inherits them.
---
// GENERATED from packages/demos/banner/text-styles.demo.tsx — edit the source, not this file.
import Banner from "@pindoba/astro-banner";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "xl", direction: "column", width: "100%" })}>
<Banner
eyebrow="Expenses · June 2026"
eyebrowTextStyle="eyebrow"
heading="R$ 12,840.55"
headingTextStyle="numeric.lg"
subheading="Across 312 transactions"
subheadingTextStyle="body.sm"
/>
<Banner
size="sm"
eyebrow="Revenue"
eyebrowTextStyle="eyebrow"
heading="R$ 48,200.00"
headingTextStyle="numeric.md"
/>
<Banner heading="Section title" headingTextStyle="heading.lg" />
</div>The heading row renders as an <h3> by default — the right level for a card or
dialog title. When a banner is the page or section heading, set
headingLevel (1–6) to place it correctly in the document outline;
headingLevel={1} renders an <h1>. It is purely semantic: typography still
comes from size / headingWeight / headingTextStyle, so raising the level
never changes how the banner looks. The docs site’s own page masthead is a
Banner with headingLevel={1} and headingTextStyle="heading.2xl".
All layout lives under a single layout prop, keyed per slot (the same shape as passThrough), so you don’t reach for raw passThrough CSS just to nudge alignment or placement. layout.eyebrow, layout.heading, and layout.subheading each take an align (cross-axis alignment of that row’s items) and a trailing placement — "apart" (default) flushes the trailing element to the row’s right edge, while "together" lets it sit next to the content (useful for a small badge after a label, which otherwise drifts to the far edge). layout.content.align aligns the whole text block horizontally — "center" and "end" give you a centered or right-aligned banner. layout.root.align aligns the top-level leading/trailing slots against the whole text block — e.g. "start" top-aligns a leading icon over a multi-line body — and layout.leading.align / layout.trailing.align override it for a single slot (center a leading avatar while the root stays top-aligned). Per-row overrides stay independent, and passThrough.<slot>.style still layers on top for anything outside these axes.
---
// GENERATED from packages/demos/banner/layout.demo.tsx — edit the source, not this file.
import Banner from "@pindoba/astro-banner";
import Badge from "@pindoba/astro-badge";
import Stamp from "@pindoba/astro-stamp";
import { Info } from "@lucide/astro";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "xl", direction: "column", width: "100%" })}>
<Banner
layout={{ eyebrow: { trailing: "together" } }}
eyebrow={"Featured"}
heading="Trailing together"
subheading="The eyebrow badge sits next to the label."
>
<Badge slot="eyebrow-trailing" size="xs" tone="primary">
New
</Badge>
</Banner>
<Banner
layout={{ eyebrow: { trailing: "apart" } }}
eyebrow={"Featured"}
heading="Trailing apart"
subheading="The eyebrow badge flushes to the right edge."
>
<Badge slot="eyebrow-trailing" size="xs" tone="primary">
New
</Badge>
</Banner>
<Banner
layout={{ root: { align: "start" } }}
heading="Top-aligned leading icon"
subheading="With root align start, the icon hugs the top of a multi-line text block rather than centering against it."
>
<Stamp slot="leading" size="md" shape="square" tone="primary" border="none">
<Info />
</Stamp>
</Banner>
<Banner
layout={{ root: { align: "start" }, leading: { align: "center" } }}
heading="Per-slot self-alignment"
subheading="root align start pins the trailing badge to the top, while leading align center keeps the icon centered on the text block."
>
<Stamp slot="leading" size="md" shape="square" tone="primary" border="none">
<Info />
</Stamp>
<Badge slot="trailing" size="xs" tone="primary">
New
</Badge>
</Banner>
<Banner
layout={{ content: { align: "center" } }}
eyebrow="Centered"
heading="Centered banner"
subheading="The whole text block is centered horizontally."
/>
<Banner
layout={{ content: { align: "end" } }}
eyebrow="Right aligned"
heading="Right-aligned banner"
subheading="The whole text block hugs the right edge."
/>
</div>The top-level trailing slot always sits at the right edge (the text block takes the remaining width), and its content is centered inside the slot by default. When the trailing region is wider than its content — a fixed-width metadata column, or a truncating path — layout.trailing.justify positions that content along the slot’s main axis: "end" flushes it to the card’s right edge, "start" hugs the text block, "center" (default) centers it. This replaces reaching for passThrough.trailing.style.justifyContent. It composes with layout.trailing.align (the vertical/cross-axis knob), so { trailing: { align: "start", justify: "end" } } pins content to the top-right corner.
---
// GENERATED from packages/demos/banner/trailing-justify.demo.tsx — edit the source, not this file.
import Banner from "@pindoba/astro-banner";
import { stack } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";
const metaClass = css({
width: "12rem",
fontFamily: "mono",
fontSize: "xs",
color: "panel.text.muted",
whiteSpace: "nowrap",
});
---
<div class={stack({ gap: "xl", direction: "column", width: "100%" })}>
<Banner
layout={{ trailing: { align: "start", justify: "end" } }}
heading="Flush right"
subheading="layout.trailing.justify: 'end' pins the path to the right edge — no passThrough needed."
>
<span slot="trailing" class={metaClass}>
…/Projects/pindoba
</span>
</Banner>
<Banner
layout={{ trailing: { align: "start", justify: "start" } }}
heading="Left of its column"
subheading="justify: 'start' left-aligns the same content within its trailing region."
>
<span slot="trailing" class={metaClass}>
…/Projects/pindoba
</span>
</Banner>
</div>By default the leading element (an icon or Stamp) is centered against the whole text block. layout.leading.span lets it flank just a pair of rows instead, which reads more balanced for changelog-style cards. The heading is always flanked; span controls which neighbour rows the element also reaches. "above" flanks the heading + the eyebrow above it and drops the subheading to a full-width row below; "below" flanks the heading + the subheading below it and lifts the eyebrow to a full-width row above. The trailing element rides the flanked row — pair it with layout.trailing.align: "start" to pin it top-right. layout.leading.span has no effect without a leading element.
---
// GENERATED from packages/demos/banner/leading-span.demo.tsx — edit the source, not this file.
import Banner from "@pindoba/astro-banner";
import Badge from "@pindoba/astro-badge";
import Stamp from "@pindoba/astro-stamp";
import { Sparkles } from "@lucide/astro";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "xl", direction: "column", width: "100%" })}>
<Banner
layout={{ leading: { span: "all" }, trailing: { align: "start" } }}
eyebrow="Changelog · v2.4"
heading="Design tokens, refreshed"
subheading="June 3, 2026 · 4 min read"
>
<Stamp slot="leading" size="lg" shape="square" tone="primary">
<Sparkles />
</Stamp>
<Badge slot="trailing" size="xs" tone="success">
New
</Badge>
</Banner>
<Banner
layout={{
leading: { span: "above" },
trailing: { align: "start" },
}}
eyebrow="Changelog · v2.4"
heading="Design tokens, refreshed"
subheading="June 3, 2026 · 4 min read"
>
<Stamp slot="leading" size="lg" shape="square" tone="primary">
<Sparkles />
</Stamp>
<Badge slot="trailing" size="xs" tone="success">
New
</Badge>
</Banner>
<Banner
layout={{
leading: { span: "below" },
trailing: { align: "start" },
}}
eyebrow="Changelog · v2.4"
heading="Design tokens, refreshed"
subheading="June 3, 2026 · 4 min read"
>
<Stamp slot="leading" size="lg" shape="square" tone="primary">
<Sparkles />
</Stamp>
<Badge slot="trailing" size="xs" tone="success">
New
</Badge>
</Banner>
</div>Banner inherits Panel’s tone surfaces, useful when the banner itself is the component (not wrapped by a card or dialog).
---
// GENERATED from packages/demos/banner/tone.demo.tsx — edit the source, not this file.
import Banner from "@pindoba/astro-banner";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "md", direction: "column" })}>
<Banner
tone="primary"
padding="md"
radius="lg"
heading="Primary"
subheading="Inherits panel surface from `tone`"
/>
<Banner
tone="success"
padding="md"
radius="lg"
heading="Success"
subheading="Inherits panel surface from `tone`"
/>
<Banner
tone="warning"
padding="md"
radius="lg"
heading="Warning"
subheading="Inherits panel surface from `tone`"
/>
<Banner
tone="danger"
padding="md"
radius="lg"
heading="Danger"
subheading="Inherits panel surface from `tone`"
/>
</div>Set control ("xs" to "xl") to render the banner as a sized control row, on the same Control Var Contract as a Button or an Input: the row takes the control height, gap and inline padding of that size, the leading / trailing affixes flip the padding per side, and the heading inherits the row’s font and colour. Add a subheading and the text becomes a two-line block.
Sent every Monday at 9:00
Last run finished 2 hours ago
---
// GENERATED from packages/demos/banner/row-mode.demo.tsx — edit the source, not this file.
import Banner from "@pindoba/astro-banner";
import Badge from "@pindoba/astro-badge";
import Button from "@pindoba/astro-button";
import Kbd from "@pindoba/astro-kbd";
import Stamp from "@pindoba/astro-stamp";
import { Inbox, Calendar, Settings, Archive } from "@lucide/astro";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "xl", direction: "column", align: "stretch" })}>
<div class={stack({ gap: "sm", direction: "column", align: "stretch" })}>
<Banner control="xs" heading="Inbox">
<Stamp slot="leading" emphasis="ghost">
<Inbox />
</Stamp>
<Badge slot="trailing" emphasis="primary">
12
</Badge>
</Banner>
<Banner control="sm" heading="Calendar">
<Stamp slot="leading" emphasis="ghost">
<Calendar />
</Stamp>
<Kbd slot="trailing">G</Kbd>
</Banner>
<Banner control="md" heading="Settings">
<Stamp slot="leading" emphasis="ghost">
<Settings />
</Stamp>
<Badge slot="trailing" emphasis="secondary" tone="success">
New
</Badge>
</Banner>
<Banner control="lg" heading="Archive">
<Stamp slot="leading" emphasis="ghost">
<Archive />
</Stamp>
<Kbd slot="trailing">G</Kbd>
</Banner>
<Banner control="xl" heading="Inbox">
<Stamp slot="leading" emphasis="ghost">
<Inbox />
</Stamp>
<Badge slot="trailing" emphasis="primary">
12
</Badge>
</Banner>
</div>
<div
class={stack({
gap: "md",
direction: "row",
align: "center",
flexWrap: "wrap",
})}
>
<Banner control="md" heading="Share report">
<Stamp slot="leading" emphasis="ghost">
<Calendar />
</Stamp>
<Kbd slot="trailing">G</Kbd>
</Banner>
<Button size="md">Share report</Button>
</div>
<Banner
control="lg"
heading="Weekly digest"
subheading="Sent every Monday at 9:00"
>
<Stamp slot="leading" emphasis="ghost">
<Calendar />
</Stamp>
<Badge slot="trailing" emphasis="secondary" tone="success">
New
</Badge>
</Banner>
<Banner
control="md"
heading="Backups"
subheading="Last run finished 2 hours ago"
layout={{ leading: { align: "firstLine" } }}
passThrough={{
root: { style: css.raw({ height: "auto", py: "xs" }) },
}}
>
<Stamp slot="leading" emphasis="ghost">
<Archive />
</Stamp>
<Kbd slot="trailing">B</Kbd>
</Banner>
</div>