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…
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": one column below md, then md:flex-row with the copy block on
the start side (md:max-w-2xl) and the action group on the end side
(md:shrink-0 so the buttons are never squeezed by long copy).
- 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; long labels
wrap inside the 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: in row layout the action group is md:shrink-0, so it keeps
its natural width and the copy column takes the remainder (measured: two
~30-character labels in a 900px container leave the headline 332px wide and
four lines tall, with no overflow). Labels past ~20 characters read better
with layout="stacked". Dropping md:shrink-0 is not the fix — flex then
shrinks the buttons themselves, and even "Create workspace" wraps into a
60px-tall two-line button.
- 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.