CTA Band
A closing call-to-action strip — headline, one supporting line and a primary/secondary CTA pair, in four surfaces and two layouts.
Loading preview…
- Registry dependencies
- utils
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/cta-band.jsonPrompt
Build a React + TypeScript + Tailwind "CtaBand" block (no runtime deps beyond
a cn() class merger).
Contract
- type CtaBandAction =
| { label: string; href: string; onClick?: () => void }
| { label: string; href?: undefined; onClick: () => void }
The union is the point: an action must carry a destination or a handler, so
the component cannot render a clickable thing that does nothing. href wins
and renders <a href>; otherwise render <button type="button">.
- Props extend React.ComponentPropsWithoutRef<"section"> with "title" omitted
(it collides with the HTML title attribute): title (ReactNode, required),
eyebrow?, description?, footnote?, primaryAction (required),
secondaryAction?, variant?: "plain" | "card" | "muted" | "accent"
(default "card"), layout?: "row" | "stacked" (default "row"),
headingLevel?: 2 | 3 | 4 (default 2), className. Remaining props spread onto
the root <section>.
Behavior
- Static block: no internal state, no effects, no timers. It renders content
and delegates every action to the consumer.
- The heading gets a useId() id and the <section> carries aria-labelledby to
it, so the band is a named region for screen readers. headingLevel keeps the
document outline correct when the band sits under an existing h2.
- layout="row": a wrapping flex row (flex-wrap justify-between gap-x-10
gap-y-6) with the copy block on the start side (grow max-w-2xl, plus
basis-80 when there is a description) and the action group on the end
side. The band's own width decides, not a viewport breakpoint: while the
copy's floor (20rem with a description, the headline's natural width
without one) plus the action group's natural width fit, they share a line;
otherwise the actions wrap under the copy. A flex line only shrinks an item
that is alone and still too wide, so the buttons are never squeezed beside
the copy, and a band dropped into a narrow column cannot overflow it.
- layout="stacked": always a centred column (items-center text-center) with
the actions under the copy.
- Actions are w-full below sm and sm:w-auto above it, so on a 375px screen
they are full-width stacked bars instead of two shrunken pills. From sm the
button pair is a flex-wrap row (centred in stacked), so a pair wider than
the band puts the second button on the next line; a label longer than the
band wraps inside its button instead of overflowing it.
- footnote renders under the action group and inherits its alignment.
- Optional parts (eyebrow, description, footnote, secondaryAction) are real
conditional branches — title + primaryAction alone must render cleanly.
Rendering & styling
- Semantic tokens only, no hex/rgb/oklch, no hardcoded radii. Four surfaces:
plain = no chrome (py-10, inherits the page background);
card = rounded-xl border bg-card text-card-foreground;
muted = rounded-xl bg-muted;
accent = rounded-xl bg-primary text-primary-foreground.
- Every surface pairs its text with the matching -foreground token. On accent,
supporting copy is text-primary-foreground/80 — text-muted-foreground is
tuned for light surfaces and loses contrast on a primary fill.
- Buttons: on plain/card/muted the primary is bg-primary + text-primary-
foreground and the secondary is a bordered transparent button; on accent
both invert — primary becomes bg-primary-foreground + text-primary,
secondary becomes border-primary-foreground/50 + text-primary-foreground.
- Secondary hover is bg-foreground/10 (bg-primary-foreground/15 on accent),
not hover:bg-muted: in the light theme muted/accent/secondary resolve to the
same value, so hover:bg-muted on a muted surface is a zero-pixel change.
- Focus rings are per surface: focus-visible:ring-ring with a matching
ring-offset (background / card / muted), and ring-primary-foreground with
ring-offset-primary on accent, so the offset gap never paints the wrong
colour.
- transition-colors plus motion-reduce:transition-none; cn() merges className.
Customization levers
- Surfaces: the SURFACE / SUBDUED / EYEBROW / PRIMARY_ACTION /
SECONDARY_ACTION maps are keyed by variant — add a "gradient" or "outline"
surface by adding one row to each map; no other code changes.
- Density: swap p-6 sm:p-8 for p-8 sm:p-12 for a taller marketing band, or
py-10 to py-6 on plain for a tight in-page strip.
- Emphasis: drop the secondary action for a single-ask band, or drop eyebrow
and footnote for a bare headline plus button.
- Long CTA labels / side-by-side threshold: basis-80 on the copy block is the
narrowest the copy may get before the actions wrap under it. Measured: a
"Create workspace" + "Book a demo" pair is 303px wide, so the row holds from
a 663px band content box; two ~30-character labels are 476px and need 836px.
Lower it to basis-64 to keep more bands in a row (the headline gets
narrower and taller), raise it for roomier copy. The floor only applies
when there is a description: a title-only band sizes its copy to the
headline, so "Still not sure?" (about 185px) plus a 216px button stays in a
row in a 570px card. Do not swap the wrap for a viewport md:flex-row with a
shrink-0 action group: a band in a 570px docs column or sidebar then
squeezes the copy to one word per line and overflows its container.
- Full-bleed: the root is w-full with its own padding, so wrapping it in an
edge-to-edge bg-muted parent gives a full-width band while your container
still constrains the content.
- Framework links: replace the <a> branch with next/link or your router's Link
via an as/asChild escape hatch; keep the union so an action with neither
href nor onClick stays impossible.
- Icons: add a trailing arrow inside the action label (the action is an
inline-flex row) without touching the layout.Concepts
- Typed action contract — an action carries either an
hrefor anonClick, encoded as a union, so "a button that looks clickable and does nothing" fails to compile instead of shipping. - Paired foreground tokens — each surface names both its background and its text token (
bg-primarywithtext-primary-foreground), which is what keeps the band legible after a theme swap and in dark mode without a second stylesheet. - Zero-change hover trap — in the light theme
muted,accentandsecondaryresolve to the same value, so ahover:bg-mutedbutton sitting on a muted band has no visible hover state; alpha layers such asbg-foreground/10stay visible on every surface. - Per-surface focus offset —
ring-offset-2paints its gap in the offset colour, so a coloured band needs its own offset token or the focus ring draws a stripe of the wrong background around the button. - One loud element — the primary CTA is the only filled button and the secondary is always an outline, so the ask reads in a single glance instead of splitting attention.
- Breakpoint honesty — actions are full-width below
smand auto-width above it, which is what keeps a two-CTA band from overflowing a 375px screen; whether they sit beside the copy is decided by the band's own width (flex-wrapplus a copy basis), so the same band works full-bleed and inside a narrow column without a viewport breakpoint guessing its size.