CTA with Border Beam
A light beam travels around the call-to-action card.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/cta-section.jsonManual
Install the dependencies
npm install clsx lucide-react tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.ts. See Installation if you have not set them up yet.Add the theme tokens
This component uses --link, which shadcn does not define by default. Add it to your global CSS (--link falls back to your --primary unless you set it):
app/globals.css@theme inline { --color-link: var(--link, var(--primary)); }Copy the source
components/ui/cta-section/cta-section.tsx"use client"; import { useEffect, useRef, type ComponentProps, type ReactNode } from "react"; import { ArrowRight } from "lucide-react"; import { cn } from "@/lib/utils"; export interface CtaAction { label: string; href?: string; onClick?: () => void; } export interface CtaSectionProps extends Omit<ComponentProps<"section">, "children" | "title"> { /** Headline. */ title: ReactNode; /** Supporting line under the headline. */ description?: ReactNode; /** Small line above the headline. */ eyebrow?: string; /** Main call to action. */ primaryAction?: CtaAction; /** Secondary call to action. */ secondaryAction?: CtaAction; /** Beam color (any CSS color). @default the primary color */ beamColor?: string; /** Seconds for one lap around the border. @default 6 */ duration?: number; /** Corner radius of the card in pixels. @default 24 */ radius?: number; } function Action({ action, primary }: { action: CtaAction; primary?: boolean }) { const classes = cn( "inline-flex h-11 cursor-pointer items-center justify-center gap-2 rounded-full px-5 text-sm font-semibold outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", primary ? "group bg-foreground text-background hover:bg-foreground/90" : "border border-border bg-background text-foreground hover:bg-surface", ); const content = ( <> {action.label} {primary ? <ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5 motion-reduce:transition-none" aria-hidden="true" /> : null} </> ); return action.href ? ( <a href={action.href} onClick={action.onClick} className={classes}> {content} </a> ) : ( <button type="button" onClick={action.onClick} className={classes}> {content} </button> ); } export function CtaSection({ title, description, eyebrow, primaryAction, secondaryAction, beamColor = "var(--primary)", duration = 6, radius = 24, className, ...props }: CtaSectionProps) { const beam = useRef<SVGGElement>(null); // Both dashes travel together with the Web Animations API: constant speed around any shape, // no per-frame JavaScript. Reduced motion leaves a still highlight on the border. useEffect(() => { const group = beam.current; if (!group) return; const query = window.matchMedia("(prefers-reduced-motion: reduce)"); let animations: Animation[] = []; const start = () => { animations.forEach((a) => a.cancel()); animations = []; if (query.matches) return; group.querySelectorAll("rect").forEach((rect) => { const from = Number(rect.dataset.offset ?? 0); animations.push( rect.animate([{ strokeDashoffset: from }, { strokeDashoffset: from - 1 }], { duration: duration * 1000, iterations: Infinity, easing: "linear" }), ); }); }; start(); query.addEventListener("change", start); return () => { query.removeEventListener("change", start); animations.forEach((a) => a.cancel()); }; }, [duration]); return ( <section className={cn("relative w-full", className)} {...props}> <div className="relative overflow-hidden border border-border bg-background px-6 py-14 text-center shadow-sm sm:px-12" style={{ borderRadius: radius }}> <div aria-hidden="true" className="pointer-events-none absolute inset-0 opacity-70" style={{ background: `radial-gradient(60% 80% at 50% 0%, color-mix(in oklab, ${beamColor} 9%, transparent), transparent 70%)` }} /> <svg aria-hidden="true" className="pointer-events-none absolute inset-0 size-full overflow-visible"> <g ref={beam} style={{ filter: `drop-shadow(0 0 6px ${beamColor})` }}> <rect x="0.5" y="0.5" width="100%" height="100%" rx={radius} fill="none" stroke={beamColor} strokeOpacity={0.35} strokeWidth={1.5} pathLength={1} strokeDasharray="0.18 0.82" strokeDashoffset={0} data-offset={0} style={{ width: "calc(100% - 1px)", height: "calc(100% - 1px)" }} /> <rect x="0.5" y="0.5" width="100%" height="100%" rx={radius} fill="none" stroke={beamColor} strokeWidth={2} strokeLinecap="round" pathLength={1} strokeDasharray="0.04 0.96" strokeDashoffset={-0.14} data-offset={-0.14} style={{ width: "calc(100% - 1px)", height: "calc(100% - 1px)" }} /> </g> </svg> <div className="relative mx-auto max-w-xl"> {eyebrow ? <p className="text-sm font-medium text-link">{eyebrow}</p> : null} <h2 className="mt-2 text-3xl font-semibold tracking-tight text-balance text-foreground sm:text-4xl">{title}</h2> {description ? <p className="mx-auto mt-3 max-w-md text-[15px] leading-7 text-pretty text-muted-foreground">{description}</p> : null} {primaryAction || secondaryAction ? ( <div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row"> {primaryAction ? <Action action={primaryAction} primary /> : null} {secondaryAction ? <Action action={secondaryAction} /> : null} </div> ) : null} </div> </div> </section> ); }
Usage
import { CtaSection } from "@/components/ui/cta-section/cta-section";
<CtaSection
title="Ship interfaces that feel alive"
description="Copy one in and see the difference in minutes."
primaryAction={{ label: "Get started", href: "/docs" }}
secondaryAction={{ label: "See pricing", href: "/pricing" }}
/>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
title* | ReactNode | – | Headline. |
description | ReactNode | – | Supporting line under the headline. |
eyebrow | string | – | Small line above the headline. |
primaryAction | CtaAction | – | Main call to action. |
secondaryAction | CtaAction | – | Secondary call to action. |
beamColor | string | the primary color | Beam color (any CSS color). |
duration | number | 6 | Seconds for one lap around the border. |
radius | number | 24 | Corner radius of the card in pixels. |
Also accepts every native <section> attribute, including ref and className.
- The beam is two dashed SVG strokes (a soft tail and a bright head) on a rounded rect with pathLength 1, animated with the Web Animations API. It moves at constant speed around any card size with no per-frame JavaScript.
- Match radius to your card's corner radius so the beam follows the border exactly.
Accessibility
- The headline is an h2 and the actions are real links or buttons with visible labels; the beam and glow are aria-hidden.
- With reduced motion the beam doesn't travel. It stays as a still highlight on the border, and the change applies live if the setting flips.