A black hero with a drawn stage lamp at the top that swings a beam of light from side to side. The headline fades up one line at a time in a dim grey, and the part of each line that sits under the beam turns bright white, so the light appears to land on the words. Move a mouse or a finger across the section and the lamp aims at it; let go and the slow sweep resumes. It scales down to a phone with the same effect and a narrower swing.
import { useEffect, useRef } from "react";
import { motion, useMotionTemplate, useMotionValue, useReducedMotion, useSpring, useTransform } from "framer-motion";
const lines = ["Every cue.", "Every night.", "Exactly on time."];
const facts = [
{ value: "1,400", label: "venues running shows" },
{ value: "0.2 ms", label: "cue latency" },
{ value: "512", label: "channels per universe" },
];
/** How fast the idle sweep swings, in radians per second. */
const SWEEP_SPEED = 0.9;
export default function DarkHeroWithSpotlightSweepAndLineReveal() {
const reduce = !!useReducedMotion();
const ref = useRef<HTMLElement>(null);
const pointer = useRef<number | null>(null);
const throwLength = useRef(380);
// One number drives everything: how far left or right of centre the light lands, in pixels.
const target = useMotionValue(0);
const sx = useSpring(target, { stiffness: 38, damping: 14, mass: 1.2 });
const rotate = useTransform(sx, (v) => (-Math.atan2(v, throwLength.current) * 180) / Math.PI);
// Width of the lit patch; it narrows on small screens so the sweep still reads.
const spread = useMotionValue(300);
const mask = useMotionTemplate`radial-gradient(ellipse ${spread}px 900px at calc(50% + ${sx}px) 50%, #000 25%, transparent 72%)`;
useEffect(() => {
const el = ref.current;
if (!el || reduce) return;
let frame = 0;
let visible = true;
const start = performance.now();
const tick = (now: number) => {
const w = el.clientWidth;
throwLength.current = el.clientHeight * 0.55;
const reach = Math.min(w * 0.3, 360);
spread.set(Math.min(300, w * 0.4));
const idle = Math.sin(((now - start) / 1000) * SWEEP_SPEED + 0.5) * reach;
target.set(pointer.current === null ? idle : pointer.current - w / 2);
frame = requestAnimationFrame(tick);
};
const run = () => {
cancelAnimationFrame(frame);
if (visible && !document.hidden) frame = requestAnimationFrame(tick);
};
const io = new IntersectionObserver(([e]) => {
visible = e.isIntersecting;
run();
});
io.observe(el);
document.addEventListener("visibilitychange", run);
run();
return () => {
cancelAnimationFrame(frame);
io.disconnect();
document.removeEventListener("visibilitychange", run);
};
}, [reduce, target, spread]);
const onMove = (e: React.PointerEvent<HTMLElement>) => {
pointer.current = e.clientX - e.currentTarget.getBoundingClientRect().left;
};
const onLeave = () => {
pointer.current = null;
};
const ring = "focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300 focus-visible:ring-offset-2 focus-visible:ring-offset-black";
return (
<section ref={ref} onPointerMove={onMove} onPointerLeave={onLeave} onPointerCancel={onLeave} className="relative overflow-hidden bg-black px-6 pb-16 pt-28 text-white sm:pb-20 sm:pt-36">
<style>{`
@keyframes dark-hero-with-spotlight-sweep-and-line-reveal-flicker { 0%, 100% { opacity: 1; } 47% { opacity: 1; } 50% { opacity: 0.82; } 53% { opacity: 1; } }
.dhs-flicker { animation: dark-hero-with-spotlight-sweep-and-line-reveal-flicker 6s linear infinite; }
@media (prefers-reduced-motion: reduce) { .dhs-flicker { animation: none; } }
`}</style>
{/* The rig: a truss line, the lamp and its beam. The lamp and beam turn together around the top centre. */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
<div className="absolute inset-x-0 top-10 h-px bg-gradient-to-r from-transparent via-zinc-700 to-transparent" />
<motion.div className="dhs-flicker absolute left-1/2 top-10 h-full w-0 origin-top" style={{ rotate }}>
<div className="absolute left-0 top-0 h-full w-[900px] -translate-x-1/2 opacity-70 blur-2xl">
<div className="h-full w-full bg-gradient-to-b from-sky-100/60 via-sky-100/20 to-transparent [clip-path:polygon(50%_0,22%_100%,78%_100%)]" />
</div>
<div className="absolute left-0 top-0 h-full w-[420px] -translate-x-1/2 blur-md">
<div className="h-full w-full bg-gradient-to-b from-white/50 via-white/10 to-transparent [clip-path:polygon(50%_0,30%_100%,70%_100%)]" />
</div>
<div className="absolute -top-3 left-0 h-10 w-12 -translate-x-1/2 rounded-b-2xl rounded-t-md border border-zinc-700 bg-zinc-900" />
<div className="absolute left-0 top-5 h-3 w-9 -translate-x-1/2 rounded-full bg-white shadow-[0_0_30px_10px_rgba(186,230,253,0.7)]" />
</motion.div>
{/* The pool of light on the floor follows the same number. */}
<motion.div className="absolute bottom-0 left-1/2 h-40 w-0" style={{ x: sx }}>
<div className="absolute bottom-[-70px] left-0 h-40 w-[620px] -translate-x-1/2 rounded-[100%] bg-sky-100/20 blur-2xl" />
</motion.div>
<div className="absolute inset-x-0 bottom-0 h-px bg-gradient-to-r from-transparent via-zinc-600 to-transparent" />
</div>
<div className="relative mx-auto max-w-4xl text-center">
<motion.p initial={reduce ? false : { opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.8 }} className="text-sm font-medium uppercase tracking-widest text-zinc-400">
Cuelight, lighting control for live venues
</motion.p>
<h1 className="mt-6 text-5xl font-semibold leading-none tracking-tight sm:text-7xl lg:text-8xl">
{lines.map((line, i) => (
<motion.span
key={line}
className="relative block pb-2"
initial={reduce ? false : { opacity: 0, y: 34, filter: "blur(12px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
transition={{ duration: 0.9, delay: 0.15 + i * 0.22, ease: [0.2, 0.7, 0.2, 1] }}
>
{/* Unlit copy underneath, lit copy on top, cut out by a mask that tracks the beam. */}
<span className="text-zinc-500">{line}</span>
<motion.span aria-hidden="true" className="absolute inset-0 text-white" style={reduce ? undefined : { WebkitMaskImage: mask, maskImage: mask }}>
{line}
</motion.span>
</motion.span>
))}
</h1>
<motion.div initial={reduce ? false : { opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, delay: 0.85 }}>
<p className="mx-auto mt-7 max-w-xl text-lg leading-relaxed text-zinc-300">
Program a full show from a laptop, run it from a tablet at the back of the room, and trust that cue 214 fires the instant the drummer counts in.
</p>
<div className="mt-9 flex flex-col items-center justify-center gap-3 sm:flex-row">
<a href="#" className={`inline-flex w-full items-center justify-center rounded-md bg-white px-6 py-3.5 text-sm font-semibold text-black transition hover:bg-sky-100 sm:w-auto ${ring}`}>
Book a console demo
</a>
<a href="#" className={`inline-flex w-full items-center justify-center gap-2 rounded-md border border-zinc-700 px-6 py-3.5 text-sm font-semibold text-white transition hover:border-zinc-500 hover:bg-white/5 sm:w-auto ${ring}`}>
<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" className="h-4 w-4">
<path d="M6.3 3.8A1 1 0 0 0 4.8 4.7v10.6a1 1 0 0 0 1.5.9l9-5.3a1 1 0 0 0 0-1.8l-9-5.3Z" />
</svg>
Watch a show run
</a>
</div>
<dl className="mx-auto mt-14 grid max-w-2xl grid-cols-3 gap-4 border-t border-zinc-800 pt-8">
{facts.map((f) => (
<div key={f.label}>
<dt className="sr-only">{f.label}</dt>
<dd className="text-xl font-semibold tabular-nums tracking-tight sm:text-3xl">{f.value}</dd>
<dd className="mt-1 text-xs text-zinc-400 sm:text-sm">{f.label}</dd>
</div>
))}
</dl>
</motion.div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/dark-hero-with-spotlight-sweep-and-line-reveal.json
Each line is rendered twice in the same spot: once in grey and once in white on top. The white copy has a CSS mask shaped like a soft ellipse, and the horizontal centre of that ellipse is bound to the same value that aims the beam. Wherever the mask is opaque the white text shows through.
It is two triangles made with `clip-path`, filled with a gradient that fades out toward the floor and blurred by a parent element. The blur is on a wrapper because a filter on the clipped element itself would be cut off with a hard edge.
A pointer handler stores the horizontal position. A `requestAnimationFrame` loop sends either that position or a sine wave to a spring, and the spring's output rotates the lamp, moves the mask and slides the pool of light on the floor.
No. An IntersectionObserver and the page visibility event stop the frame loop when the hero is out of view or the tab is in the background, and start it again when it returns.
Yes. It uses pointer events, so dragging a finger across the section aims the lamp, and the idle sweep takes over when the touch ends.
Related: Hero with Word Reveal Headline and Floating Cards, Dark Hero with Gradient Glow and Product Window, Editorial Hero with Sliding Letters and Drawn Underline.
More hero sections or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.