Skip to content

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.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts. See Installation if you have not set them up yet.

  3. 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));
    }
  4. 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

PropTypeDefaultDescription
title*ReactNode–Headline.
descriptionReactNode–Supporting line under the headline.
eyebrowstring–Small line above the headline.
primaryActionCtaAction–Main call to action.
secondaryActionCtaAction–Secondary call to action.
beamColorstringthe primary colorBeam color (any CSS color).
durationnumber6Seconds for one lap around the border.
radiusnumber24Corner 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.