Browse the library
Blocks / Conversion
CTA section
FreeHigh contrast closing CTA panel with brand glow, two actions, reassurance line and an animated beam border.
components/facade/cta-section.tsx
"use client";
import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { ArrowRight, Check, Phone } from "lucide-react";
import { cn } from "@/lib/utils";
export interface CtaSectionProps {
eyebrow?: string;
title?: string;
description?: string;
primaryCta?: { label: string; href: string };
secondaryCta?: { label: string; href: string };
/** Short reassurance points shown below the buttons. */
reassurance?: string[];
className?: string;
}
const EASE = [0.22, 1, 0.36, 1] as const;
export function CtaSection({
eyebrow = "Booking now for this week",
title = "Your neighbors already switched. Your turn.",
description = "Get a written, flat-rate quote in under 10 minutes. If we can't beat your last invoice, the first service call is on us.",
primaryCta = { label: "Get my free quote", href: "#quote" },
secondaryCta = { label: "(303) 555-0187", href: "tel:+13035550187" },
reassurance = ["No obligation", "Licensed & insured in Colorado", "Same-day slots open"],
className,
}: CtaSectionProps) {
const reduce = useReducedMotion();
return (
<section className={cn("bg-background px-4 py-20 text-foreground sm:px-8 sm:py-28", className)}>
<motion.div
initial={{ opacity: 0, y: 24, scale: 0.98 }}
whileInView={{ opacity: 1, y: 0, scale: 1 }}
viewport={{ once: true, margin: "-80px 0px" }}
transition={{ duration: 0.9, ease: EASE }}
className="relative mx-auto max-w-6xl overflow-hidden rounded-[1.75rem] p-px"
>
{/* Animated beam border */}
<div aria-hidden className="absolute inset-0 rounded-[1.75rem] bg-foreground/10" />
<motion.div
aria-hidden
className="absolute left-1/2 top-1/2 aspect-square w-[200%] -translate-x-1/2 -translate-y-1/2"
style={{
background:
"conic-gradient(from 0deg, transparent 0deg, transparent 280deg, color-mix(in oklch, var(--brand) 80%, transparent) 320deg, var(--brand-foreground) 340deg, transparent 360deg)",
}}
animate={reduce ? undefined : { rotate: 360 }}
transition={{ duration: 7, repeat: Infinity, ease: "linear" }}
/>
{/* Panel */}
<div className="relative isolate overflow-hidden rounded-[calc(1.75rem-1px)] bg-primary px-6 py-16 text-primary-foreground sm:px-12 sm:py-20 lg:px-20 lg:py-24">
{/* Brand glow */}
<div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
<motion.div
className="absolute -top-1/2 left-1/2 h-[140%] w-[90%] -translate-x-1/2 rounded-full bg-brand/40 blur-[110px]"
animate={reduce ? undefined : { opacity: [0.55, 0.85, 0.55], scale: [1, 1.06, 1] }}
transition={{ duration: 8, repeat: Infinity, ease: "easeInOut" }}
/>
<div
className="absolute inset-0 [mask-image:radial-gradient(ellipse_at_center,black_20%,transparent_75%)]"
style={{
backgroundImage:
"linear-gradient(to right, var(--primary-foreground) 1px, transparent 1px), linear-gradient(to bottom, var(--primary-foreground) 1px, transparent 1px)",
backgroundSize: "48px 48px",
opacity: 0.08,
}}
/>
<div className="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-primary to-transparent" />
</div>
<div className="mx-auto max-w-3xl text-center">
<motion.p
initial={{ opacity: 0, y: 8 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.2, ease: EASE }}
className="inline-flex items-center gap-2 rounded-full border border-primary-foreground/15 bg-primary-foreground/5 px-3 py-1 text-xs font-medium text-primary-foreground/80 backdrop-blur"
>
<span className="relative flex size-1.5">
<span className="absolute inline-flex size-full animate-ping-slow rounded-full bg-success" />
<span className="relative inline-flex size-1.5 rounded-full bg-success" />
</span>
{eyebrow}
</motion.p>
<motion.h2
initial={{ opacity: 0, y: 14 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8, delay: 0.28, ease: EASE }}
className="mt-6 font-display text-4xl font-semibold leading-[1.02] tracking-tight text-balance sm:text-6xl"
>
{title}
</motion.h2>
<motion.p
initial={{ opacity: 0, y: 14 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8, delay: 0.36, ease: EASE }}
className="mx-auto mt-5 max-w-xl text-lg leading-relaxed text-primary-foreground/70 text-pretty"
>
{description}
</motion.p>
<motion.div
initial={{ opacity: 0, y: 14 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8, delay: 0.44, ease: EASE }}
className="mt-10 flex flex-col items-stretch justify-center gap-3 sm:flex-row sm:items-center"
>
<a
href={primaryCta.href}
className="group relative inline-flex h-13 items-center justify-center gap-2 overflow-hidden rounded-full bg-brand px-7 py-3.5 text-[15px] font-semibold text-brand-foreground shadow-[0_10px_40px_-10px_var(--brand)] transition-transform hover:-translate-y-px focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-primary"
>
<span
aria-hidden
className="absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-brand-foreground/25 to-transparent transition-transform duration-700 group-hover:translate-x-full"
/>
<span className="relative">{primaryCta.label}</span>
<ArrowRight className="relative size-4 transition-transform group-hover:translate-x-0.5" aria-hidden />
</a>
<a
href={secondaryCta.href}
className="inline-flex items-center justify-center gap-2 rounded-full border border-primary-foreground/20 bg-primary-foreground/5 px-7 py-3.5 text-[15px] font-semibold text-primary-foreground backdrop-blur transition-colors hover:bg-primary-foreground/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-primary"
>
<Phone className="size-4" aria-hidden />
{secondaryCta.label}
</a>
</motion.div>
{reassurance.length > 0 && (
<motion.ul
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.8, delay: 0.6 }}
className="mt-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-sm text-primary-foreground/60"
>
{reassurance.map((r) => (
<li key={r} className="flex items-center gap-1.5">
<Check className="size-3.5 text-brand" aria-hidden />
{r}
</li>
))}
</motion.ul>
)}
</div>
</div>
</motion.div>
</section>
);
}
export default CtaSection;app/page.tsx
import { CtaSection } from "@/components/facade/cta-section";
export default function Demo() {
return <CtaSection />;
}Installation
$
npx shadcn@latest add https://facadeui.com/r/cta-section.jsonUsage
import { CtaSection } from "@/components/facade/cta-section";
export default function Page() {
return <CtaSection />;
}Props
CtaSectionProps is exported, so you can extend or wrap it. Every prop has a default; <CtaSection /> renders the example above.
| Prop | Type | Description |
|---|---|---|
| eyebrow | string | |
| title | string | |
| description | string | |
| primaryCta | { label: string; href: string } | |
| secondaryCta | { label: string; href: string } | |
| reassurance | string[] | Short reassurance points shown below the buttons. |
| className | string |
Ask your agent
With the Facade MCP server connected, this prompt is enough:
Add the Facade cta section to this page and adapt the copy to our business.