Scroll Scrubbed Video Hero
Scroll Scrubbed Video Hero is a React and Tailwind CSS media component written for Uncode and yours to ship. Copy it and paste it into your project.
Uncode
media
What it is
Scroll Scrubbed Video Hero is a React and Tailwind CSS media component written for Uncode and yours to ship. Copy it and paste it into your project.
How to use it
Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.
Five free copies a month. No card.
No packages beyond the project baseline
Licence
This component was written for this catalogue. It is yours to ship in client work and in products you sell, under the terms of use.
Scroll Scrubbed Video Hero
"use client"
import { useEffect, useRef, useState } from "react"
// ─────────────────────────────────────────────────────────────
// THE CITY OPENS — locked scroll-scrub video hero
// The page cannot move while this is active — body is pinned
// with position:fixed (the same bulletproof technique modal
// libraries use; plain overflow:hidden alone isn't reliable
// across browsers). Wheel/touch input is captured and used
// purely to drive video.currentTime, forward and backward. Once
// the video reaches the end and the user keeps pushing forward,
// the page unlocks and continues normally — and re-locks if they
// scroll back up into it. No dependencies, system fonts only.
// ─────────────────────────────────────────────────────────────
export interface MetroHeroProps {
videoSrc?: string
title?: string
scrollHint?: string
tagline?: string
signature?: { name: string; url: string } | false
/** Total input distance (px) needed to scrub the full video. Tune to taste. */
scrubDistance?: number
className?: string
style?: React.CSSProperties
}
const DEFAULT_VIDEO = "https://raw.githubusercontent.com/gughigug/metro-hero-assets/main/Subway_doors_open_to_city_202608242331.mp4"
const DEFAULT_SIGNATURE = { name: "guglielmogiannattasio.exe", url: "https://www.guglielmogiannattasio.it" }
const SANS = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
const COL_BG = "#05070d"
const COL_TEXT = "#f2f4f8"
function clamp(v: number, min: number, max: number) {
return Math.min(max, Math.max(min, v))
}
export default function MetroHero({
videoSrc = DEFAULT_VIDEO,
title = "THE CITY OPENS",
scrollHint = "SCROLL",
tagline = "Every door in the city is already open.",
signature = DEFAULT_SIGNATURE,
scrubDistance = 3200,
className,
style,
}: MetroHeroProps) {
const sectionRef = useRef<HTMLDivElement>(null)
const videoRef = useRef<HTMLVideoElement>(null)
const titleRef = useRef<HTMLDivElement>(null)
const hintRef = useRef<HTMLDivElement>(null)
const taglineRef = useRef<HTMLDivElement>(null)
const progressBarRef = useRef<HTMLDivElement>(null)
const [ready, setReady] = useState(false)
useEffect(() => {
const video = videoRef.current
const section = sectionRef.current
if (!video || !section) return
const reduceMotion =
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
let duration = 0
let rafId = 0
let targetProgress = 0
let currentProgress = 0
let hasStartedScrolling = false
let isSeeking = false
let pendingTime: number | null = null
let locked = false
let lockedScrollY = 0
let touchStartY = 0
const onLoadedData = () => {
duration = video.duration || 0
setReady(true)
if (reduceMotion) {
video.currentTime = duration * 0.92
}
}
video.addEventListener("loadeddata", onLoadedData)
const onSeeked = () => {
isSeeking = false
if (pendingTime !== null) {
const t = pendingTime
pendingTime = null
isSeeking = true
video.currentTime = t
}
}
video.addEventListener("seeked", onSeeked)
function seekTo(t: number) {
if (isSeeking) {
pendingTime = t
return
}
isSeeking = true
video.currentTime = t
}
// Locked for the entire lifetime of this component — there is no
// release valve in either direction. Scroll input only ever moves
// the video back and forth; the page itself never moves.
function engageLock() {
if (locked || typeof document === "undefined") return
locked = true
lockedScrollY = window.scrollY
const b = document.body.style
b.position = "fixed"
b.top = `-${lockedScrollY}px`
b.left = "0"
b.right = "0"
b.width = "100%"
}
function releaseLock() {
if (!locked || typeof document === "undefined") return
locked = false
const y = lockedScrollY
const b = document.body.style
b.position = ""
b.top = ""
b.left = ""
b.right = ""
b.width = ""
window.scrollTo(0, y)
}
engageLock()
function addDelta(deltaY: number) {
const next = clamp(targetProgress + deltaY / scrubDistance, 0, 1)
targetProgress = next
if (targetProgress > 0.001) hasStartedScrolling = true
return true
}
const onWheel = (e: WheelEvent) => {
addDelta(e.deltaY)
e.preventDefault()
}
const onTouchStart = (e: TouchEvent) => {
touchStartY = e.touches[0]?.clientY ?? 0
}
const onTouchMove = (e: TouchEvent) => {
const y = e.touches[0]?.clientY ?? touchStartY
const deltaY = touchStartY - y
touchStartY = y
addDelta(deltaY)
e.preventDefault()
}
window.addEventListener("wheel", onWheel, { passive: false })
window.addEventListener("touchstart", onTouchStart, { passive: true })
window.addEventListener("touchmove", onTouchMove, { passive: false })
function frame() {
currentProgress += (targetProgress - currentProgress) * 0.18
if (duration > 0) {
seekTo(currentProgress * duration)
}
if (videoRef.current) {
const scale = 1 + currentProgress * 0.06
videoRef.current.style.transform = `scale(${scale})`
}
if (titleRef.current) {
const t = 1 - clamp(currentProgress / 0.35, 0, 1)
titleRef.current.style.opacity = String(t)
titleRef.current.style.transform = `translateY(${(1 - t) * -24}px) scale(${0.96 + t * 0.04})`
titleRef.current.style.filter = `blur(${(1 - t) * 10}px)`
}
if (hintRef.current) {
hintRef.current.style.opacity = hasStartedScrolling ? "0" : "1"
}
if (taglineRef.current) {
// Mirrors the title's blur-focus treatment, timed as the payoff
// once the reveal is nearly complete — not a background afterthought.
const t = clamp((currentProgress - 0.82) / 0.18, 0, 1)
taglineRef.current.style.opacity = String(t)
taglineRef.current.style.transform = `translateY(${(1 - t) * 20}px) scale(${0.97 + t * 0.03})`
taglineRef.current.style.filter = `blur(${(1 - t) * 8}px)`
}
if (progressBarRef.current) {
progressBarRef.current.style.transform = `scaleX(${currentProgress})`
}
rafId = requestAnimationFrame(frame)
}
if (!reduceMotion) {
rafId = requestAnimationFrame(frame)
}
return () => {
video.removeEventListener("loadeddata", onLoadedData)
video.removeEventListener("seeked", onSeeked)
window.removeEventListener("wheel", onWheel)
window.removeEventListener("touchstart", onTouchStart)
window.removeEventListener("touchmove", onTouchMove)
cancelAnimationFrame(rafId)
releaseLock()
}
}, [scrubDistance])
return (
<div
ref={sectionRef}
className={className}
style={{
position: "relative",
height: "100dvh",
width: "100%",
overflow: "hidden",
background: COL_BG,
...style,
}}
>
<video
ref={videoRef}
src={videoSrc}
muted
playsInline
preload="auto"
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
opacity: ready ? 1 : 0,
transformOrigin: "center center",
willChange: "transform",
transition: "opacity 0.6s ease",
}}
/>
<div
style={{
position: "absolute",
inset: 0,
background: "linear-gradient(180deg, rgba(5,7,13,0.35), rgba(5,7,13,0) 30%, rgba(5,7,13,0.15) 70%, rgba(5,7,13,0.55))",
pointerEvents: "none",
}}
/>
<div
ref={titleRef}
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "0 6%",
textAlign: "center",
pointerEvents: "none",
}}
>
<span
style={{
fontFamily: SANS,
fontWeight: 800,
fontSize: "clamp(30px, 7vw, 96px)",
lineHeight: 1,
letterSpacing: "-0.02em",
color: COL_TEXT,
textShadow: "0 4px 30px rgba(0,0,0,0.5)",
display: "inline-block",
willChange: "transform, filter, opacity",
}}
>
{title}
</span>
</div>
{tagline && (
<div
ref={taglineRef}
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "0 8%",
textAlign: "center",
opacity: 0,
pointerEvents: "none",
}}
>
<span
style={{
fontFamily: SANS,
fontWeight: 700,
fontSize: "clamp(20px, 3.4vw, 40px)",
lineHeight: 1.2,
letterSpacing: "-0.01em",
color: COL_TEXT,
textShadow: "0 4px 24px rgba(0,0,0,0.5)",
}}
>
{tagline}
</span>
</div>
)}
<div
ref={hintRef}
style={{
position: "absolute",
left: "50%",
bottom: "clamp(20px, 6vh, 48px)",
transform: "translateX(-50%)",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 8,
color: "rgba(240,244,248,0.75)",
fontFamily: SANS,
fontSize: "clamp(10px, 1.4vw, 12px)",
fontWeight: 600,
letterSpacing: "0.3em",
transition: "opacity 0.4s ease",
pointerEvents: "none",
}}
>
<span>{scrollHint}</span>
<svg width="14" height="18" viewBox="0 0 14 18" style={{ animation: "metro-hero-bounce 1.6s ease-in-out infinite" }}>
<style>{`
@keyframes metro-hero-bounce {
0%, 100% { transform: translateY(0); opacity: 0.5; }
50% { transform: translateY(5px); opacity: 1; }
}
`}</style>
<path d="M7 1 L7 17 M2 12 L7 17 L12 12" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
{/* Thin progress line — fills as the video advances. */}
<div
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: 2,
background: "rgba(255,255,255,0.12)",
}}
>
<div
ref={progressBarRef}
style={{
height: "100%",
width: "100%",
background: "linear-gradient(90deg, rgba(255,255,255,0.5), rgba(255,255,255,0.95))",
transform: "scaleX(0)",
transformOrigin: "left center",
}}
/>
</div>
{signature && (
<span
style={{
position: "absolute",
right: "clamp(12px, 2.5vw, 24px)",
bottom: "clamp(10px, 2vw, 18px)",
fontFamily: SANS,
fontWeight: 500,
fontSize: "clamp(11px, 1.4vw, 13px)",
letterSpacing: "0.01em",
color: "rgba(220,224,232,0.6)",
zIndex: 2,
}}
>
by{" "}
<a
href={signature.url}
target="_blank"
rel="noopener noreferrer"
style={{
color: "rgba(220,224,232,0.6)",
textDecoration: "none",
transition: "color 0.2s ease",
}}
onMouseEnter={(e: React.MouseEvent<HTMLAnchorElement>) => {
e.currentTarget.style.color = COL_TEXT
}}
onMouseLeave={(e: React.MouseEvent<HTMLAnchorElement>) => {
e.currentTarget.style.color = "rgba(220,224,232,0.6)"
}}
>
{signature.name}
</a>
</span>
)}
</div>
)
}