Closing CTA card with a faint radial wash, dual CTAs, a trust-chip row, and a one-shot border shine on reveal.
npx shadcn@latest add @paragon/cta-panelAlso installs: button
"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>
);
}