Website Sections
CTAReveal
A call to action that opens from a thin band of light when it comes on screen. Words rise in, then the buttons, and a soft light follows the pointer.
Download
- React
- Tailwind CSS v4
- motion
components/ui/cta-reveal.tsx
"use client";
import { useRef, type CSSProperties, type ElementType, type PointerEvent, type ReactNode } from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
type CTARevealProps = {
title: string;
text?: string;
/** Small line above the title. */
eyebrow?: string;
/** Buttons or links. */
children?: ReactNode;
/** Panel colour at one corner. */
from?: string;
/** Panel colour at the other corner. */
to?: string;
/** Colour of the light under the pointer and of the opening flash. */
glow?: string;
/** Plays once. false replays each time it comes back on screen. */
once?: boolean;
/** h2 by default. */
headingAs?: ElementType;
/** Set padding here. The panel sits inside. */
className?: string;
};
const EASE = [0.16, 1, 0.3, 1] as const;
/* CTAReveal: a call to action that opens from a thin band of light when it comes on screen. The words rise in, then the buttons, and a soft light follows the pointer. */
export function CTAReveal({
title,
text,
eyebrow,
children,
from = "#4c1d95",
to = "#0e7490",
glow = "#67e8f9",
once = true,
headingAs: Heading = "h2",
className = "bg-[#0b0a14] px-5 py-16",
}: CTARevealProps) {
const reduce = useReducedMotion();
const box = useRef<HTMLElement>(null);
const seen = useInView(box, { once, amount: 0.3 });
const state = seen ? "open" : "shut";
const words = title.split(" ");
const track = (e: PointerEvent<HTMLDivElement>) => {
const box = e.currentTarget.getBoundingClientRect();
e.currentTarget.style.setProperty("--cr-x", `${e.clientX - box.left}px`);
e.currentTarget.style.setProperty("--cr-y", `${e.clientY - box.top}px`);
};
const rise = (delay: number) => ({
shut: reduce ? { opacity: 0 } : { opacity: 0, y: "0.6em" },
open: { opacity: 1, y: 0, transition: { duration: reduce ? 0.3 : 0.7, delay: reduce ? 0 : delay, ease: EASE } },
});
return (
<section ref={box} className={`@container ${className}`}>
<motion.div
onPointerMove={track}
className="group/cta relative isolate mx-auto max-w-5xl overflow-hidden rounded-3xl px-6 py-14 text-center text-white @2xl:py-20"
style={{ backgroundImage: `linear-gradient(120deg, ${from}, ${to})`, "--cr-x": "50%", "--cr-y": "50%" } as CSSProperties}
initial={false}
animate={state}
variants={{
shut: { clipPath: reduce ? "inset(0% 0% 0% 0% round 24px)" : "inset(47% 8% 47% 8% round 24px)" },
open: { clipPath: "inset(0% 0% 0% 0% round 24px)", transition: { duration: reduce ? 0 : 1, ease: EASE } },
}}
>
<span
aria-hidden
className="absolute inset-0 -z-10 opacity-0 transition-opacity duration-500 group-hover/cta:opacity-45 motion-reduce:transition-none"
style={{ backgroundImage: `radial-gradient(20rem at var(--cr-x) var(--cr-y), ${glow}, transparent)` }}
/>
<motion.span
aria-hidden
className="pointer-events-none absolute inset-0"
style={{ backgroundColor: glow }}
initial={false}
animate={state}
variants={{
shut: { opacity: reduce ? 0 : 1 },
open: { opacity: 0, transition: { duration: reduce ? 0 : 0.8, delay: reduce ? 0 : 0.1, ease: "easeOut" } },
}}
/>
{eyebrow && (
<motion.p initial={false} animate={state} variants={rise(0.2)} className="mb-3 text-xs font-medium tracking-[0.2em] uppercase opacity-80">
{eyebrow}
</motion.p>
)}
<Heading className="text-[clamp(1.6rem,6cqw,3rem)] leading-tight font-semibold tracking-tight text-balance">
<span className="sr-only">{title}</span>
<span aria-hidden>
{words.map((word, i) => (
<span key={i}>
<span className="inline-block overflow-hidden pb-[0.1em] align-bottom">
<motion.span initial={false} animate={state} variants={rise(0.3 + i * 0.05)} className="inline-block">
{word}
</motion.span>
</span>
{i < words.length - 1 && " "}
</span>
))}
</span>
</Heading>
{text && (
<motion.p initial={false} animate={state} variants={rise(0.45 + words.length * 0.05)} className="mx-auto mt-4 max-w-xl text-pretty opacity-85">
{text}
</motion.p>
)}
{children && (
<motion.div initial={false} animate={state} variants={rise(0.55 + words.length * 0.05)} className="mt-8 flex flex-wrap items-center justify-center gap-3">
{children}
</motion.div>
)}
</motion.div>
</section>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/cta-reveal.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/cta-reveal-demo.tsx
import { CTAReveal } from "@/components/ui/cta-reveal"; export default function CTARevealExample() { return ( <div className="h-full overflow-y-auto bg-[#0b0a14]"> <CTAReveal eyebrow="Ready when you are" title="Ship your next page this week" text="Copy one piece or the whole set. It is your code from the first paste." headingAs="h3" once={false} className="flex min-h-full w-full items-center p-4 [&>div]:w-full" > <a href="#" className="rounded-xl bg-white px-5 py-2.5 text-sm font-semibold text-[#0b0a14]"> Get started </a> <a href="#" className="rounded-xl px-5 py-2.5 text-sm font-semibold ring-1 ring-white/40"> See examples </a> </CTAReveal> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| title | string | — | The headline. |
| text | string | — | Line under the title. |
| eyebrow | string | — | Small line above the title. |
| children | ReactNode | — | Buttons or links. |
| from | string | "#4c1d95" | Panel colour at one corner. |
| to | string | "#0e7490" | Panel colour at the other corner. |
| glow | string | "#67e8f9" | Colour of the pointer light and the opening flash. |
| once | boolean | true | false replays each time it comes back on screen. |
| headingAs | ElementType | "h2" | Heading tag. |
| className | string | "bg-[#0b0a14] px-5 py-16" | Set padding here. The panel sits inside. |