A full-bleed hero that plays like the opening shot of a film, with no video file to host. A desert road at dusk is drawn in SVG on four layers that zoom at different speeds, so the camera seems to push slowly toward the horizon. On top sit the things that make footage feel like footage: jittering grain, warm light leaks drifting across the frame, a faint flicker, an occasional scratch, a vignette, letterbox bars and a running timecode. A pause button in the lower bar stops everything. On phones the same frame is cropped around the road and the title stacks above full-width buttons.
import { useEffect, useRef, useState } from "react";
import { motion, useReducedMotion } from "framer-motion";
/** Editable copy. */
const film = {
studio: "Meridian Overland",
reel: "Reel 04 · Valley Road, 7:42 pm",
title: ["The long way", "is the whole point."],
line: "Twelve day drives through canyon country in groups of eight. No flights, no schedule to beat, and a fire every night.",
primary: "Watch the route film",
secondary: "See 2027 departures",
};
// Film grain: a small tile of fractal noise, drawn once by the browser and shifted around many times a second.
const grainSvg =
"<svg xmlns='http://www.w3.org/2000/svg' width='240' height='240'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.6 0'/></filter><rect width='100%' height='100%' filter='url(#n)'/></svg>";
const grain = `url("data:image/svg+xml,${encodeURIComponent(grainSvg)}")`;
// The road runs from the horizon (800, 588) to the bottom edge. `t` is how far along it a thing sits.
const along = (t: number) => ({ y: 588 + 312 * t * t, half: 6 + 234 * t * t });
const dashes = [0.16, 0.28, 0.4, 0.52, 0.64, 0.76, 0.88, 1, 1.12, 1.24].map((t) => {
const a = along(t);
const b = along(t + 0.045);
return `800,${a.y} ${800 + a.half * 0.035},${a.y} ${800 + b.half * 0.035},${b.y} ${800 - b.half * 0.035},${b.y} ${800 - a.half * 0.035},${a.y}`;
});
const poles = [0.2, 0.32, 0.45, 0.6, 0.78, 0.98, 1.2].map((t) => {
const a = along(t);
return { x: 800 + a.half * 1.45, y: a.y, h: 26 + 250 * t * t, w: 1 + 5 * t * t };
});
const stars = Array.from({ length: 46 }, (_, i) => {
const r = (n: number) => {
const x = Math.sin(i * 91.7 + n * 47.3) * 43758.5453;
return x - Math.floor(x);
};
return { x: r(1) * 1600, y: r(2) * 170, s: 0.6 + r(3) * 1.4, o: 0.25 + r(4) * 0.6 };
});
const svg = "absolute inset-0 h-full w-full";
export default function CinematicHeroWithFilmGrainLightLeaksAndSlowZoom() {
const reduce = !!useReducedMotion();
const ref = useRef<HTMLElement>(null);
const clock = useRef<HTMLSpanElement>(null);
const [onScreen, setOnScreen] = useState(true);
const [playing, setPlaying] = useState(true);
const running = playing && onScreen && !reduce;
useEffect(() => {
const el = ref.current;
if (!el || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver(([e]) => setOnScreen(e.isIntersecting), { threshold: 0.05 });
io.observe(el);
return () => io.disconnect();
}, []);
// The timecode in the corner counts at 24 frames a second while the film is playing.
const frames = useRef(2024);
useEffect(() => {
if (!running) return;
let last = performance.now();
const id = setInterval(() => {
const now = performance.now();
if (!document.hidden) frames.current += ((now - last) / 1000) * 24;
last = now;
const f = Math.floor(frames.current);
const two = (n: number) => String(n).padStart(2, "0");
if (clock.current) clock.current.textContent = `00:${two(Math.floor(f / 1440) % 60)}:${two(Math.floor(f / 24) % 60)}:${two(f % 24)}`;
}, 84);
return () => clearInterval(id);
}, [running]);
const ring = "focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-300 focus-visible:ring-offset-2 focus-visible:ring-offset-black";
const rise = (delay: number) => ({
initial: reduce ? false : ({ opacity: 0, y: 26, filter: "blur(12px)" } as const),
animate: { opacity: 1, y: 0, filter: "blur(0px)" },
transition: { duration: 1.1, delay, ease: [0.2, 0.7, 0.2, 1] as const },
});
return (
<section ref={ref} data-paused={!running} className="chf-root relative h-[820px] overflow-hidden bg-black text-white sm:h-[780px]">
<style>{`
@keyframes cinematic-hero-with-film-grain-light-leaks-and-slow-zoom-push { from { transform: scale(1); } to { transform: scale(var(--chf-to)); } }
@keyframes cinematic-hero-with-film-grain-light-leaks-and-slow-zoom-grain {
0% { transform: translate(0, 0); } 10% { transform: translate(-42px, 28px); } 20% { transform: translate(54px, -36px); }
30% { transform: translate(-18px, -62px); } 40% { transform: translate(66px, 40px); } 50% { transform: translate(-70px, 12px); }
60% { transform: translate(24px, 70px); } 70% { transform: translate(-56px, -30px); } 80% { transform: translate(38px, 16px); }
90% { transform: translate(-12px, -48px); } 100% { transform: translate(0, 0); }
}
@keyframes cinematic-hero-with-film-grain-light-leaks-and-slow-zoom-leak {
0% { transform: translateX(-30%) rotate(8deg); opacity: 0; } 35% { opacity: 0.95; } 70% { opacity: 0.5; }
100% { transform: translateX(150%) rotate(-6deg); opacity: 0; }
}
@keyframes cinematic-hero-with-film-grain-light-leaks-and-slow-zoom-leak-back {
0% { transform: translateX(40%) scale(1); opacity: 0; } 40% { opacity: 0.8; } 100% { transform: translateX(-140%) scale(1.3); opacity: 0; }
}
@keyframes cinematic-hero-with-film-grain-light-leaks-and-slow-zoom-flicker {
0%, 100% { opacity: 0; } 12% { opacity: 0.07; } 25% { opacity: 0.01; } 48% { opacity: 0.09; } 61% { opacity: 0.02; } 83% { opacity: 0.06; }
}
@keyframes cinematic-hero-with-film-grain-light-leaks-and-slow-zoom-scratch {
0%, 100% { opacity: 0; transform: translateX(0); } 4% { opacity: 0.5; } 7% { opacity: 0; }
41% { opacity: 0; transform: translateX(38vw); } 43% { opacity: 0.4; transform: translateX(38vw); } 47% { opacity: 0; transform: translateX(39vw); }
78% { opacity: 0; transform: translateX(-22vw); } 80% { opacity: 0.45; transform: translateX(-22vw); } 82% { opacity: 0; }
}
@keyframes cinematic-hero-with-film-grain-light-leaks-and-slow-zoom-weave {
0%, 100% { transform: translate(0, 0); } 25% { transform: translate(0.6px, -0.8px); } 50% { transform: translate(-0.7px, 0.5px); } 75% { transform: translate(0.4px, 0.9px); }
}
@keyframes cinematic-hero-with-film-grain-light-leaks-and-slow-zoom-fade { from { opacity: 1; } to { opacity: 0; } }
@keyframes cinematic-hero-with-film-grain-light-leaks-and-slow-zoom-bar { from { transform: scaleY(0); } to { transform: scaleY(1); } }
@keyframes cinematic-hero-with-film-grain-light-leaks-and-slow-zoom-reel { from { transform: scaleX(0); } to { transform: scaleX(1); } }
@keyframes cinematic-hero-with-film-grain-light-leaks-and-slow-zoom-fly { from { transform: translate(0, 0); } to { transform: translate(260px, -70px); } }
@keyframes cinematic-hero-with-film-grain-light-leaks-and-slow-zoom-glow { 0%, 100% { opacity: 0.75; } 50% { opacity: 1; } }
.chf-push { transform-origin: 50% 35%; animation: cinematic-hero-with-film-grain-light-leaks-and-slow-zoom-push 22s cubic-bezier(0.4, 0, 0.6, 1) infinite alternate; will-change: transform; }
.chf-grain { animation: cinematic-hero-with-film-grain-light-leaks-and-slow-zoom-grain 0.9s steps(1) infinite; }
.chf-leak { animation: cinematic-hero-with-film-grain-light-leaks-and-slow-zoom-leak 9s ease-in-out infinite; }
.chf-leak-back { animation: cinematic-hero-with-film-grain-light-leaks-and-slow-zoom-leak-back 13s ease-in-out infinite; }
.chf-flicker { animation: cinematic-hero-with-film-grain-light-leaks-and-slow-zoom-flicker 0.7s steps(1) infinite; }
.chf-scratch { animation: cinematic-hero-with-film-grain-light-leaks-and-slow-zoom-scratch 6s steps(1) infinite; }
.chf-weave { animation: cinematic-hero-with-film-grain-light-leaks-and-slow-zoom-weave 0.5s steps(1) infinite; }
.chf-fade { animation: cinematic-hero-with-film-grain-light-leaks-and-slow-zoom-fade 1s ease-out both; }
.chf-bar { animation: cinematic-hero-with-film-grain-light-leaks-and-slow-zoom-bar 1s cubic-bezier(0.2, 0.8, 0.2, 1) both; }
.chf-reel { transform-origin: left; animation: cinematic-hero-with-film-grain-light-leaks-and-slow-zoom-reel 22s linear infinite; }
.chf-fly { animation: cinematic-hero-with-film-grain-light-leaks-and-slow-zoom-fly 40s linear infinite alternate; }
.chf-glow { animation: cinematic-hero-with-film-grain-light-leaks-and-slow-zoom-glow 5s ease-in-out infinite; }
.chf-root[data-paused="true"] [class*="chf-"] { animation-play-state: paused; }
@media (prefers-reduced-motion: reduce) {
.chf-push, .chf-grain, .chf-flicker, .chf-scratch, .chf-weave, .chf-bar, .chf-reel, .chf-fly, .chf-glow { animation: none; }
.chf-fade, .chf-leak-back, .chf-scratch { display: none; }
.chf-leak { animation: none; opacity: 0.55; transform: translateX(10%); }
}
`}</style>
{/* The drawn scene. Four layers push in at different rates, so the picture has depth as it zooms. */}
<div aria-hidden="true" className="chf-weave absolute inset-0">
<div className="chf-push absolute inset-0" style={{ ["--chf-to" as string]: 1.05 }}>
<div className="absolute inset-0" style={{ backgroundImage: "linear-gradient(to bottom, #130b2b 0%, #3b1556 9%, #8f2a5c 20%, #e2582a 30%, #fbbf24 36%, #fbbf24 100%)" }} />
<svg viewBox="0 0 1600 900" preserveAspectRatio="xMidYMid slice" className={svg}>
<defs>
<radialGradient id="chf-sun" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="#fff7d6" />
<stop offset="22%" stopColor="#fde68a" />
<stop offset="23%" stopColor="#fdba74" stopOpacity="0.55" />
<stop offset="100%" stopColor="#fb923c" stopOpacity="0" />
</radialGradient>
</defs>
{stars.map((s, i) => (
<circle key={i} cx={s.x} cy={s.y} r={s.s} fill="#fff" opacity={s.o * (1 - s.y / 200)} />
))}
<circle className="chf-glow" cx="800" cy="275" r="430" fill="url(#chf-sun)" />
<g transform="translate(0 -270)"><g className="chf-fly" fill="none" stroke="#1f0f2e" strokeWidth="2.5" strokeLinecap="round">
<path d="M470 330q9-9 18 0q9-9 18 0" />
<path d="M520 352q7-7 14 0q7-7 14 0" />
<path d="M436 362q6-6 12 0q6-6 12 0" />
</g></g>
</svg>
</div>
<div className="chf-push absolute inset-0" style={{ ["--chf-to" as string]: 1.1 }}>
<svg viewBox="0 0 1600 900" preserveAspectRatio="xMidYMid slice" className={svg}>
<path transform="translate(0 -270)" fill="#9d3a63" d="M0 600V512h130l22-34h196l24 40h160l20 22h150l26 60zM880 600l34-54h96l16-34h170l22-44h84l24 50h150l22 38h102v44z" />
</svg>
</div>
<div className="chf-push absolute inset-0" style={{ ["--chf-to" as string]: 1.16 }}>
<svg viewBox="0 0 1600 900" preserveAspectRatio="xMidYMid slice" className={svg}>
<g transform="translate(0 -270)">
<path fill="#4a1a45" d="M150 610l26-140h18l6-74h92l8 74h20l30 140zM420 610l12-104 6-76h20l6 76 14 104zM1130 610l34-158h22l10-76h214l12 76h26l40 158zM1500 610l14-70h60l26 70z" />
<ellipse cx="800" cy="596" rx="900" ry="18" fill="#fb923c" opacity="0.22" />
</g>
</svg>
</div>
<div className="chf-push absolute inset-0" style={{ ["--chf-to" as string]: 1.24 }}>
<svg viewBox="0 0 1600 900" preserveAspectRatio="xMidYMid slice" className={svg}>
<defs>
<linearGradient id="chf-ground" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#3a1438" />
<stop offset="30%" stopColor="#1d0b22" />
<stop offset="100%" stopColor="#07030b" />
</linearGradient>
<linearGradient id="chf-road" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#f59e0b" stopOpacity="0.8" />
<stop offset="10%" stopColor="#9a3412" stopOpacity="0.5" />
<stop offset="38%" stopColor="#1c0d22" stopOpacity="0.95" />
<stop offset="100%" stopColor="#0c0612" stopOpacity="1" />
</linearGradient>
</defs>
<g transform="translate(0 -270)">
<rect x="-200" y="588" width="2000" height="700" fill="url(#chf-ground)" />
<path d="M794 588h12L1242 1170H358z" fill="url(#chf-road)" />
{dashes.map((d, i) => (
<polygon key={i} points={d} fill="#fde68a" opacity={0.85 - i * 0.04} />
))}
<polyline points={poles.map((p) => `${p.x},${p.y - p.h}`).join(" ")} fill="none" stroke="#0a0510" strokeWidth="1.5" />
{poles.map((p, i) => (
<g key={i} stroke="#0a0510" strokeLinecap="round">
<line x1={p.x} y1={p.y} x2={p.x} y2={p.y - p.h} strokeWidth={p.w} />
<line x1={p.x - p.h * 0.11} y1={p.y - p.h * 0.92} x2={p.x + p.h * 0.11} y2={p.y - p.h * 0.92} strokeWidth={p.w} />
</g>
))}
{/* A camper heading for the horizon, tail lights on. */}
<g transform="translate(771 596)">
<rect x="0" y="0" width="26" height="20" rx="3" fill="#0a0510" />
<rect x="4" y="3" width="18" height="6" rx="1" fill="#fbbf24" opacity="0.5" />
<circle className="chf-glow" cx="4" cy="15" r="2.6" fill="#ff3b30" />
<circle className="chf-glow" cx="22" cy="15" r="2.6" fill="#ff3b30" />
<ellipse cx="13" cy="26" rx="22" ry="6" fill="#ff3b30" opacity="0.25" />
</g>
</g>
</svg>
</div>
</div>
{/* Film treatment: light leaks, grain, flicker, a scratch and a vignette. */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="chf-leak absolute -top-1/4 left-0 h-[150%] w-1/2 mix-blend-screen blur-2xl" style={{ backgroundImage: "radial-gradient(ellipse at center, rgba(255,140,40,0.85), rgba(255,60,80,0.45) 40%, rgba(255,60,80,0) 70%)" }} />
<div className="chf-leak-back absolute -top-1/4 right-0 h-[150%] w-1/3 mix-blend-screen blur-3xl [animation-delay:-5s]" style={{ backgroundImage: "radial-gradient(ellipse at center, rgba(255,214,102,0.7), rgba(255,120,60,0.3) 45%, rgba(255,120,60,0) 72%)" }} />
<div className="absolute inset-0" style={{ backgroundImage: "radial-gradient(ellipse 85% 80% at 50% 48%, rgba(0,0,0,0) 45%, rgba(0,0,0,0.78) 100%)" }} />
<div className="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/75 via-black/25 to-transparent" />
<div className="chf-grain absolute -inset-24 opacity-40 mix-blend-overlay" style={{ backgroundImage: grain }} />
<div className="chf-scratch absolute inset-y-0 left-1/3 w-px bg-white/70" />
<div className="chf-flicker absolute inset-0 bg-white" />
<div className="chf-fade absolute inset-0 bg-black" />
</div>
{/* Letterbox bars. */}
<div aria-hidden="true" className="chf-bar absolute inset-x-0 top-0 h-12 origin-top bg-black sm:h-16" />
<div aria-hidden="true" className="chf-bar absolute inset-x-0 bottom-0 h-12 origin-bottom bg-black sm:h-16" />
<div className="absolute inset-x-0 top-0 flex h-12 items-center justify-between px-5 text-xs font-medium uppercase tracking-[0.25em] text-white/80 sm:h-16 sm:px-10">
<span>{film.studio}</span>
<span className="flex items-center gap-2 font-mono tracking-normal">
<span className="chf-glow h-2 w-2 rounded-full bg-red-500" />
<span ref={clock} aria-hidden="true">00:01:24:08</span>
</span>
</div>
<div className="absolute inset-x-0 bottom-12 px-5 pb-8 sm:bottom-16 sm:px-10 sm:pb-12">
<div className="mx-auto max-w-6xl">
<motion.p {...rise(0.5)} className="text-xs font-medium uppercase tracking-[0.3em] text-amber-200">
{film.reel}
</motion.p>
<h1 className="mt-4 font-serif text-5xl leading-[1.02] tracking-tight sm:text-7xl lg:text-8xl">
{film.title.map((line, i) => (
<motion.span key={line} {...rise(0.65 + i * 0.18)} className={`block ${i === 1 ? "italic text-amber-100" : ""}`}>
{line}
</motion.span>
))}
</h1>
<motion.div {...rise(1.05)}>
<p className="mt-6 max-w-xl text-base leading-relaxed text-white/85 sm:text-lg">{film.line}</p>
<div className="mt-7 flex flex-col gap-3 sm:flex-row sm:items-center">
<a href="#" className={`inline-flex items-center justify-center gap-2.5 rounded-sm bg-amber-300 px-6 py-3.5 text-sm font-semibold text-stone-950 transition hover:bg-amber-200 ${ring}`}>
<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" className="h-4 w-4">
<path d="M6 4l10 6-10 6z" />
</svg>
{film.primary}
</a>
<a href="#" className={`inline-flex items-center justify-center rounded-sm border border-white/40 px-6 py-3.5 text-sm font-semibold text-white transition hover:bg-white/10 ${ring}`}>
{film.secondary}
</a>
</div>
</motion.div>
</div>
</div>
{/* The player strip inside the lower bar: a real pause control for all of the motion. */}
<div className="absolute inset-x-0 bottom-0 flex h-12 items-center gap-4 px-5 sm:h-16 sm:px-10">
<button
type="button"
onClick={() => setPlaying((p) => !p)}
aria-pressed={!playing}
aria-label={playing ? "Pause the background motion" : "Play the background motion"}
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-white/30 text-white transition hover:bg-white/15 ${ring}`}
>
<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" className="h-4 w-4">
{playing ? <path d="M5 4h3.5v12H5zM11.5 4H15v12h-3.5z" /> : <path d="M6 4l10 6-10 6z" />}
</svg>
</button>
<div aria-hidden="true" className="h-px flex-1 bg-white/25">
<div className="chf-reel h-px bg-amber-300" />
</div>
<span aria-hidden="true" className="hidden text-xs uppercase tracking-[0.25em] text-white/60 sm:block">24 fps · 2.39 : 1</span>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/cinematic-hero-with-film-grain-light-leaks-and-slow-zoom.json
A tiny inline SVG uses the `feTurbulence` filter to generate noise, and it is set as a repeating background on a layer larger than the hero. A keyframe with `steps(1)` jumps that layer to a new offset about eleven times a second. Because only `transform` changes, the noise is drawn once and the browser just moves it.
Each leak is a large blurred radial gradient in orange and red with `mix-blend-screen`, which only ever brightens what is underneath. A slow keyframe slides it across the frame while its opacity rises and falls, like light getting into a camera body.
The sky, the far mesas, the near buttes and the road are separate layers. Each scales toward the same point on the horizon but by a different amount, so near things grow faster than far things. That difference is what the eye reads as depth.
Yes. Replace the four scene layers with a single element that has your image as a background and the `chf-push` class. You lose the layered depth but keep the push-in, grain, leaks and bars.
No. Everything animates `transform` or `opacity`, there is no canvas and no per-frame script apart from a small text update for the timecode. All of it pauses when the hero scrolls out of view.
Related: Parallax Mountain Scene in a Scroll Frame, Gradient Mesh Background with Flowing Blobs and Grain, Dark Hero with Spotlight Sweep and Line Reveal.
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.