sections
CTA Banner
A contained full-width call-to-action banner with a living radial gradient field that drifts via pure CSS keyframes. On scroll-into-view (Motion's useInView), the headline words stagger up, the subtext fades in, the button group slides up, and a hairline border snaps in around the content — one choreographed reveal. The primary button carries a subtle 3s glow heartbeat; the secondary is a ghost outline. Renders as <a> or <button> per action, supports center/start alignment, and is fully keyboard- and screen-reader-accessible.
Ship your next idea this weekend
Copy-and-own components with real motion, tokens, and accessibility baked in — no lock-in, no runtime.
Install
1. Register the namespace (once per project):
json
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
bash
npx shadcn add @bottega/cta-bannerUsage
Usagetsx
<CtaBanner headline="" primaryLabel="" />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| headline* | string | — | Main CTA heading text, rendered as h2 by default. |
| subtext | string | — | Supporting sentence below the headline. |
| primaryLabel* | string | — | Label for the primary action button. |
| primaryHref | string | — | href for the primary button (renders as <a> when provided, <button> otherwise). |
| onPrimary | () => void | — | onClick handler for primary action (used when no primaryHref). |
| secondaryLabel | string | — | Optional label for secondary ghost/outline action. |
| secondaryHref | string | — | href for secondary action. |
| onSecondary | () => void | — | onClick handler for secondary action. |
| align | enum | center | Text and button alignment; defaults to "center". |
| headingAs | enum | h2 | Heading level element; defaults to "h2" to fit the document outline. |
| as | enum | section | HTML element for the outer wrapper; defaults to "section". |
| className | string | — | Extra class names merged onto the outer wrapper for slot overrides. |
Source
cta-banner.tsxtsx
"use client";
import { useEffect, useRef, useState } from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
import type { ComponentType, Ref } from "react";
import type { Variants } from "motion/react";
import styles from "./cta-banner.module.css";
// expo-out: snappy arrival, long tail — premium SaaS feel.
const EASE_EXPO_OUT = [0.16, 1, 0.3, 1] as const;
// Choreographed reveal timings, in SECONDS (Motion JS API) — deliberately
// distinct from the --duration CSS UI-transition token (~300ms).
const WORD_DURATION = 0.4;
const WORD_STAGGER = 0.06;
export interface CtaBannerProps {
/** Main CTA heading text, rendered as h2 by default. */
headline: string;
/** Supporting sentence below the headline. */
subtext?: string;
/** Label for the primary action button. */
primaryLabel: string;
/** href for the primary button (renders as <a> when provided, <button> otherwise). */
primaryHref?: string;
/** onClick handler for primary action (used when no primaryHref). */
onPrimary?: () => void;
/** Optional label for secondary ghost/outline action. */
secondaryLabel?: string;
/** href for secondary action. */
secondaryHref?: string;
/** onClick handler for secondary action. */
onSecondary?: () => void;
/** Text and button alignment; defaults to "center". */
align?: "center" | "start";
/** Heading level element; defaults to "h2" to fit the document outline. */
headingAs?: "h2" | "h3";
/** HTML element for the outer wrapper; defaults to "section". */
as?: "section" | "div" | "aside";
/** Extra class names merged onto the outer wrapper for slot overrides. */
className?: string;
}
function cx(...parts: Array<string | false | undefined>): string {
return parts.filter(Boolean).join(" ");
}
const containerVariants: Variants = {
hidden: {},
visible: { transition: { staggerChildren: WORD_STAGGER } },
};
const wordVariants: Variants = {
hidden: { y: 20, opacity: 0 },
visible: {
y: 0,
opacity: 1,
transition: { duration: WORD_DURATION, ease: EASE_EXPO_OUT },
},
};
export function CtaBanner({
headline,
subtext,
primaryLabel,
primaryHref,
onPrimary,
secondaryLabel,
secondaryHref,
onSecondary,
align = "center",
headingAs = "h2",
as = "section",
className,
}: CtaBannerProps) {
const ref = useRef<HTMLElement>(null);
// Mount-gate + null-safe reduced-motion check (see Bottega contract): the
// static base renders on SSR and the first client render so server HTML ===
// client first render (no React #418). The animated layer only turns on after
// hydration for users who haven't asked for reduced motion.
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const prefersReducedMotion = useReducedMotion();
const reduce = prefersReducedMotion === null || prefersReducedMotion;
const animate = mounted && !reduce;
const inView = useInView(ref, { once: true, amount: 0.4 });
const show = animate && inView;
// Dynamic tag selection. The motion proxy is indexed by a string union, which
// yields a union of motion components whose ref types don't unify cleanly in
// JSX — type both as permissive motion-prop components so a single
// RefObject<HTMLElement> ref and the shared variants/className apply.
const Wrapper = motion[as] as ComponentType<
Record<string, unknown> & { ref?: Ref<HTMLElement> }
>;
const Heading = motion[headingAs] as ComponentType<Record<string, unknown>>;
const alignClasses =
align === "center" ? "items-center text-center" : "items-start text-left";
const words = headline.split(/\s+/).filter(Boolean);
const ariaLabel = headline.trim().slice(0, 60);
// Subtext begins after the headline finishes: lead-in + per-word stagger.
const subtextDelay = WORD_DURATION + words.length * WORD_STAGGER;
const buttonsDelay = subtextDelay + 0.1;
return (
<Wrapper
ref={ref}
aria-label={ariaLabel}
className={cx(
"relative isolate overflow-hidden w-full",
"bg-background text-foreground",
"rounded-[calc(var(--radius)+4px)]",
"px-6 py-16 sm:px-12 sm:py-20",
className,
)}
>
{/* Living radial gradient field — always painted (no layout shift),
drift driven by the CSS module keyframes. */}
<div aria-hidden="true" className={styles.field} />
{/* Hairline border that "snaps in" around the content as the banner
enters view. Static (opacity 1) at rest / reduced-motion / pre-mount. */}
<motion.div
aria-hidden="true"
className="pointer-events-none absolute inset-0 rounded-[calc(var(--radius)+4px)] border border-border"
initial={false}
animate={{ opacity: show ? 1 : animate ? 0 : 1 }}
transition={{ duration: 0.2, ease: EASE_EXPO_OUT, delay: show ? 0.05 : 0 }}
/>
<div
className={cx(
"relative z-10 mx-auto flex max-w-2xl flex-col gap-6",
alignClasses,
)}
>
{/* Headline: each word staggers up. Variants stay at "hidden" until in
view; reduced-motion / pre-mount users get the static "visible"
state (no transform offset) immediately. */}
<Heading
variants={animate ? containerVariants : undefined}
initial={animate ? "hidden" : false}
animate={show ? "visible" : animate ? "hidden" : false}
className="text-3xl font-bold tracking-tight text-foreground sm:text-4xl"
>
{words.map((word, i) => (
<motion.span
key={`${word}-${i}`}
variants={animate ? wordVariants : undefined}
className="inline-block"
>
{word}
{i < words.length - 1 ? " " : ""}
</motion.span>
))}
</Heading>
{subtext && (
<motion.p
initial={animate ? { opacity: 0 } : false}
animate={
show ? { opacity: 1 } : animate ? { opacity: 0 } : false
}
transition={{
duration: 0.4,
ease: EASE_EXPO_OUT,
delay: show ? subtextDelay : 0,
}}
className="max-w-xl text-base leading-relaxed text-foreground opacity-60"
>
{subtext}
</motion.p>
)}
<motion.div
initial={animate ? { y: 12, opacity: 0 } : false}
animate={
show
? { y: 0, opacity: 1 }
: animate
? { y: 12, opacity: 0 }
: false
}
transition={{
duration: 0.4,
ease: EASE_EXPO_OUT,
delay: show ? buttonsDelay : 0,
}}
className={cx(
"mt-2 flex flex-wrap gap-3",
align === "center" ? "justify-center" : "justify-start",
)}
>
<PrimaryAction
label={primaryLabel}
href={primaryHref}
onClick={onPrimary}
/>
{secondaryLabel && (
<SecondaryAction
label={secondaryLabel}
href={secondaryHref}
onClick={onSecondary}
/>
)}
</motion.div>
</div>
</Wrapper>
);
}
const PRIMARY_CLASSES = cx(
"inline-flex items-center justify-center select-none",
"px-6 py-3 text-sm font-medium",
"bg-primary text-primary-foreground",
"rounded-[var(--radius)]",
"transition-[transform,filter] duration-[var(--duration)] ease-[var(--ease)]",
"hover:scale-[1.02] hover:brightness-90 active:scale-[0.98]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
);
function PrimaryAction({
label,
href,
onClick,
}: {
label: string;
href?: string;
onClick?: () => void;
}) {
const className = cx(PRIMARY_CLASSES, styles.pulse);
if (href) {
return (
<a href={href} onClick={onClick} className={className}>
{label}
</a>
);
}
return (
<button type="button" onClick={onClick} className={className}>
{label}
</button>
);
}
const SECONDARY_CLASSES = cx(
"inline-flex items-center justify-center select-none",
"px-6 py-3 text-sm font-medium",
"bg-transparent text-foreground",
"border border-border",
"rounded-[var(--radius)]",
"transition-colors duration-[var(--duration)] ease-[var(--ease)]",
"hover:border-primary hover:text-primary",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
);
function SecondaryAction({
label,
href,
onClick,
}: {
label: string;
href?: string;
onClick?: () => void;
}) {
if (href) {
return (
<a href={href} onClick={onClick} className={SECONDARY_CLASSES}>
{label}
</a>
);
}
return (
<button type="button" onClick={onClick} className={SECONDARY_CLASSES}>
{label}
</button>
);
}
Dependencies
- motion
- @bottega/tokens