Sections

CTA Panel

Closing CTA card with a faint radial wash, dual CTAs, a trust-chip row, and a one-shot border shine on reveal.

Install

npx shadcn@latest add @paragon/cta-panel

Also installs: button

cta-panel.tsx

"use client";

import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { Activity, Lock, ShieldCheck } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/paragon/ui/button";

const REVEAL =
  "transition-[opacity,translate,filter] duration-500 ease-out motion-reduce:transition-none";
const HIDDEN = "translate-y-3 opacity-0 blur-[4px]";
const SHOWN = "translate-y-0 opacity-100 blur-[0px]";

const TRUST_CHIPS = [
  { icon: ShieldCheck, label: "SOC 2 Type II" },
  { icon: Lock, label: "GDPR ready" },
  { icon: Activity, label: "99.99% uptime SLA" },
];

export interface CtaPanelProps extends React.ComponentProps<"section"> {
  headline?: string;
  subcopy?: string;
  primaryCta?: string;
  secondaryCta?: string;
}

/**
 * Closing CTA panel: a contained card with a faint radial wash, dual CTAs,
 * and a tiny trust row. On first reveal a single shine sweeps once around
 * the card's border (gradient position on a double-masked ring — one
 * interpolated custom property, border-beam idiom).
 */
export function CtaPanel({
  headline = "Sync your data stack in an afternoon",
  subcopy = "Meridian moves data between your warehouse and 200+ business tools — reverse ETL without the pipeline babysitting.",
  primaryCta = "Start for free",
  secondaryCta = "Talk to sales",
  className,
  ...props
}: CtaPanelProps) {
  const id = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
  const ref = React.useRef<HTMLElement>(null);
  const inView = useInView(ref, { once: true, margin: "-80px" });
  const reduced = useReducedMotion();
  const revealed = inView || !!reduced;
  const group = (i: number): React.CSSProperties => ({
    transitionDelay: `${i * 80}ms`,
  });

  return (
    <section
      ref={ref}
      aria-labelledby="cta-panel-headline"
      className={cn("w-full bg-background px-4 py-16 sm:px-6 md:py-24", className)}
      {...props}
    >
      <style href={`paragon-cta-panel-${id}`} precedence="paragon">{`
        @property --cta-sweep-${id} {
          syntax: "<percentage>";
          initial-value: 120%;
          inherits: false;
        }
        @keyframes cta-sweep-${id} {
          from { --cta-sweep-${id}: 120%; }
          to { --cta-sweep-${id}: -20%; }
        }
        [data-cta-shine="${id}"][data-run] {
          animation: cta-sweep-${id} 1.2s var(--ease-in-out) 0.35s 1 both;
        }
        @media (prefers-reduced-motion: reduce) {
          [data-cta-shine="${id}"] { animation: none !important; }
        }
      `}</style>

      <div className="relative mx-auto w-full max-w-4xl overflow-hidden rounded-2xl bg-card px-6 py-14 text-center shadow-border sm:px-14">
        {/* Faint radial wash — the only background effect. */}
        <div
          aria-hidden
          className="pointer-events-none absolute inset-0 bg-[radial-gradient(75%_60%_at_50%_0%,color-mix(in_oklab,var(--color-primary)_5%,transparent),transparent)]"
        />

        {/* One-shot border shine on reveal. */}
        <div
          aria-hidden
          data-cta-shine={id}
          data-run={revealed || undefined}
          className="pointer-events-none absolute inset-0 rounded-2xl"
          style={{
            padding: 1,
            background: `linear-gradient(115deg, transparent 35%, color-mix(in oklab, var(--color-foreground) 55%, transparent) 50%, transparent 65%)`,
            backgroundSize: "250% 100%",
            backgroundRepeat: "no-repeat",
            backgroundPosition: `var(--cta-sweep-${id}) 0%`,
            mask: "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)",
            WebkitMask:
              "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)",
            maskComposite: "exclude",
            WebkitMaskComposite: "xor",
          }}
        />

        <div className="relative">
          <h2
            id="cta-panel-headline"
            className={cn(
              "text-3xl font-semibold tracking-tight text-balance sm:text-4xl",
              REVEAL,
              revealed ? SHOWN : HIDDEN,
            )}
            style={group(0)}
          >
            {headline}
          </h2>

          <div
            className={cn(REVEAL, revealed ? SHOWN : HIDDEN)}
            style={group(1)}
          >
            <p className="mx-auto mt-4 max-w-lg text-base text-pretty text-muted-foreground">
              {subcopy}
            </p>
            <div className="mt-8 flex flex-wrap items-center justify-center gap-3">
              <Button size="lg">{primaryCta}</Button>
              <Button size="lg" variant="outline">
                {secondaryCta}
              </Button>
            </div>
          </div>

          <ul
            className={cn(
              "mt-8 flex flex-wrap items-center justify-center gap-2",
              REVEAL,
              revealed ? SHOWN : HIDDEN,
            )}
            style={group(2)}
          >
            {TRUST_CHIPS.map(({ icon: Icon, label }) => (
              <li
                key={label}
                className="flex items-center gap-1.5 rounded-full bg-muted/60 px-2.5 py-1 text-xs text-muted-foreground"
              >
                <Icon aria-hidden className="size-3.5" strokeWidth={1.75} />
                {label}
              </li>
            ))}
          </ul>
        </div>
      </div>
    </section>
  );
}