Blocks

CTA Band

A closing call-to-action strip — headline, one supporting line and a primary/secondary CTA pair, in four surfaces and two layouts.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/**
 * What a CTA does. The type forces one of the two: either `href` (rendered as an `<a>`) or
 * `onClick` (rendered as a `<button type="button">`). Supplying neither is a compile error —
 * this component can never draw a dead button with no behaviour behind it.
 */
export type CtaBandAction =
  | { label: string; href: string; onClick?: () => void }
  | { label: string; href?: undefined; onClick: () => void }

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/cta-band.json

Prompt

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 href or an onClick, 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-primary with text-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, accent and secondary resolve to the same value, so a hover:bg-muted button sitting on a muted band has no visible hover state; alpha layers such as bg-foreground/10 stay visible on every surface.
  • Per-surface focus offsetring-offset-2 paints 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 sm and auto-width above it, which is what keeps a two-CTA band from overflowing a 375px screen.

On This Page