# CTA Banner

> Large rounded call-to-action panel on a faded dot grid with a headline, two buttons and a soft volt spotlight that trails the cursor.

- Collection: Blocks
- Tier: free
- Docs: https://geturui.com/docs/cta-banner
- Install: `npx shadcn@latest add @geturui/cta-banner`
- Dependencies: motion, lucide-react

## Usage

```tsx
import { CtaBanner } from "@/components/blocks/cta-banner";

export default function Cta() {
  return (
    <CtaBanner
      headline="Start building in minutes"
      subcopy="Free for personal projects. No card needed."
      primaryAction={{ label: "Create account", href: "/sign-up" }}
      secondaryAction={{ label: "Book a demo", href: "/demo" }}
      align="left"
    />
  );
}
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| headline | `string` | — | Large heading; blurs up into view. |
| subcopy | `string` | — | Supporting line. |
| eyebrow | `string | null` | "Free to start" | Pulsing badge above the headline. null hides it. |
| primaryAction | `{ label; href?; onClick? }` | — | Volt button with a nudging arrow. |
| secondaryAction | `{ label; href?; onClick? } | null` | — | Secondary button. null hides it. |
| spotlight | `boolean` | true | Radial glow that follows the pointer. Static under reduced motion. |
| align | `"center" | "left"` | "center" | Left puts the buttons on the right on wide containers. |

## Accessibility

- The dot grid and spotlight are decorative and aria-hidden.
- Actions are real links or buttons with visible focus rings.

## Source

### components/blocks/cta-banner.tsx

```tsx
"use client";

import * as React from "react";
import { motion, useMotionTemplate, useMotionValue, useReducedMotion, useSpring } from "motion/react";
import { ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button, buttonClasses, type ButtonVariant } from "@/components/ui/button";

export interface CtaAction {
  label: string;
  /** Renders an anchor when set, otherwise a button. */
  href?: string;
  onClick?: () => void;
}

export interface CtaBannerProps {
  /** Small badge above the headline. Pass null to hide. */
  eyebrow?: string | null;
  headline?: string;
  subcopy?: string;
  primaryAction?: CtaAction;
  secondaryAction?: CtaAction | null;
  /** Radial glow that follows the pointer. Off under reduced motion. */
  spotlight?: boolean;
  /** "center" or "left" aligned copy; left puts the buttons on the right on large screens. */
  align?: "center" | "left";
  className?: string;
}

function Action({ action, variant }: { action: CtaAction; variant: ButtonVariant }) {
  const primary = variant === "primary";
  if (action.href) {
    return (
      <a href={action.href} onClick={action.onClick} className={buttonClasses({ variant, size: "lg" })}>
        <span className="inline-flex items-center gap-[inherit]">
          {action.label}
          {primary && <ArrowRight aria-hidden className="size-4 transition-transform duration-300 ease-[var(--ease-spring)] group-hover/btn:translate-x-[3px]" />}
        </span>
      </a>
    );
  }
  return (
    <Button size="lg" variant={variant} trailing={primary ? "arrow" : undefined} onClick={action.onClick}>
      {action.label}
    </Button>
  );
}

/** Large rounded call-to-action panel on a dot grid, with a soft spotlight that trails the cursor. */
export function CtaBanner({
  eyebrow = "Free to start",
  headline = "Ship your next launch page this afternoon",
  subcopy = "Pick a hero, a pricing table and an FAQ. Paste them in, swap the copy, and you are live.",
  primaryAction = { label: "Get started free" },
  secondaryAction = { label: "Talk to sales" },
  spotlight = true,
  align = "center",
  className,
}: CtaBannerProps) {
  const reduce = useReducedMotion() ?? false;
  const ref = React.useRef<HTMLDivElement>(null);
  const mx = useMotionValue(50);
  const my = useMotionValue(30);
  const x = useSpring(mx, { stiffness: 180, damping: 26 });
  const y = useSpring(my, { stiffness: 180, damping: 26 });
  const glow = useMotionValue(0.55);
  const glowSpring = useSpring(glow, { stiffness: 200, damping: 30 });
  const background = useMotionTemplate`radial-gradient(520px circle at ${x}% ${y}%, var(--volt-soft), transparent 70%)`;
  const live = spotlight && !reduce;
  const left = align === "left";

  function onMove(e: React.PointerEvent) {
    if (!live) return;
    const r = ref.current?.getBoundingClientRect();
    if (!r) return;
    mx.set(((e.clientX - r.left) / r.width) * 100);
    my.set(((e.clientY - r.top) / r.height) * 100);
    glow.set(1);
  }

  return (
    <section className={cn("@container w-full px-4 py-12 sm:px-6 sm:py-16", className)}>
      <div
        ref={ref}
        onPointerMove={onMove}
        onPointerLeave={() => {
          glow.set(0.55);
          mx.set(50);
          my.set(30);
        }}
        className="relative isolate mx-auto max-w-6xl overflow-hidden rounded-xl border border-border bg-surface"
      >
        <div
          aria-hidden
          className="dot-grid absolute inset-0 -z-20 [mask-image:radial-gradient(ellipse_at_center,#000_30%,transparent_80%)]"
        />
        {spotlight && (
          <motion.div aria-hidden className="pointer-events-none absolute inset-0 -z-10" style={{ background, opacity: glowSpring }} />
        )}
        <div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-px bg-gradient-to-r from-transparent via-volt-ink/50 to-transparent" />

        <div
          className={cn(
            "flex flex-col gap-8 px-6 py-14 @xl:px-12 @xl:py-20",
            left ? "items-start @4xl:flex-row @4xl:items-end @4xl:justify-between" : "items-center text-center",
          )}
        >
          <div className={cn("grid gap-4", left ? "max-w-2xl" : "max-w-3xl justify-items-center")}>
            {eyebrow && (
              <Badge tone="new" pulse>
                {eyebrow}
              </Badge>
            )}
            <motion.h2
              className="text-balance font-display text-[34px] font-semibold leading-[1.08] tracking-[-1px] text-ink @xl:text-5xl @xl:tracking-[-1.6px]"
              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 14, filter: "blur(8px)" }}
              whileInView={{ opacity: 1, y: 0, filter: "blur(0px)" }}
              viewport={{ once: true }}
              transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
            >
              {headline}
            </motion.h2>
            {subcopy && (
              <motion.p
                className="text-balance text-base leading-7 text-ink-muted @xl:text-lg"
                initial={{ opacity: 0 }}
                whileInView={{ opacity: 1 }}
                viewport={{ once: true }}
                transition={{ duration: 0.5, delay: 0.15 }}
              >
                {subcopy}
              </motion.p>
            )}
          </div>
          <motion.div
            className="flex flex-wrap items-center gap-3"
            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 10 }}
            whileInView={{ opacity: 1, y: 0 }}
            viewport={{ once: true }}
            transition={{ type: "spring", stiffness: 300, damping: 30, delay: 0.25 }}
          >
            <Action action={primaryAction} variant="primary" />
            {secondaryAction && <Action action={secondaryAction} variant="secondary" />}
          </motion.div>
        </div>
      </div>
    </section>
  );
}

```
