bottega

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-banner

Usage

Usagetsx
<CtaBanner headline="" primaryLabel="" />

Props

PropTypeDefaultDescription
headline*stringMain CTA heading text, rendered as h2 by default.
subtextstringSupporting sentence below the headline.
primaryLabel*stringLabel for the primary action button.
primaryHrefstringhref for the primary button (renders as <a> when provided, <button> otherwise).
onPrimary() => voidonClick handler for primary action (used when no primaryHref).
secondaryLabelstringOptional label for secondary ghost/outline action.
secondaryHrefstringhref for secondary action.
onSecondary() => voidonClick handler for secondary action.
alignenumcenterText and button alignment; defaults to "center".
headingAsenumh2Heading level element; defaults to "h2" to fit the document outline.
asenumsectionHTML element for the outer wrapper; defaults to "section".
classNamestringExtra 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