HeroesAdvancedApril 14, 2026
Video Strip Reveal
A pinned full-viewport hero with a video revealed through sliding horizontal strips. Text lines animate in on load, and scroll-driven parallax shifts the copy and video as the user scrolls. Two-column on desktop, overlaid text on mobile.
View Full Demo →Preview
A digital video production studio.
Bold, modern visuals.
scroll
A digital video production studio.
Bold, modern visuals.
Source
demo.jsx
import VideoStripReveal from "./index.jsx";
import { videoStripReveal } from "../demo-data.js";
import styles from "./demo.module.css";
export default function VideoStripRevealDemo() {
return (
<>
<VideoStripReveal {...videoStripReveal} />
<div className={styles.spacer} />
</>
);
}
index.jsx
"use client";
import { useEffect, useRef } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";
import styles from "./styles.module.css";
gsap.registerPlugin(ScrollTrigger, SplitText);
const STRIP_COUNT = 6;
export default function VideoStripReveal({
headings = [],
videoSrc,
scrollLabel = "scroll",
}) {
const wrapperRef = useRef(null);
const heroRef = useRef(null);
const copyDesktopRef = useRef(null);
const copyMobileRef = useRef(null);
const videoColRef = useRef(null);
const videoInnerRef = useRef(null);
const scrollTextRef = useRef(null);
const stripsRef = useRef([]);
useEffect(() => {
const ctx = gsap.context(() => {
const desktopH1 = copyDesktopRef.current?.querySelector("h1");
const mobileH1 = copyMobileRef.current?.querySelector("h1");
/* ---- Split text into lines ---- */
if (desktopH1) {
SplitText.create(desktopH1, {
type: "lines",
linesClass: styles.lineChild,
mask: "lines",
});
}
if (mobileH1) {
SplitText.create(mobileH1, {
type: "lines",
linesClass: styles.lineChild,
mask: "lines",
});
}
/* ---- Intro timeline ---- */
const tl = gsap.timeline({ defaults: { ease: "power4.out" } });
const allLines = heroRef.current.querySelectorAll(`.${styles.lineChild}`);
tl.from(allLines, {
yPercent: 100,
duration: 1.2,
stagger: 0.08,
});
tl.to(
stripsRef.current,
{
xPercent: -100,
duration: 1,
stagger: 0.05,
ease: "power3.inOut",
},
0.3
);
tl.from(
scrollTextRef.current,
{ yPercent: 100, duration: 0.8, ease: "power3.out" },
"-=0.4"
);
/* ---- Scroll-driven parallax ---- */
const scrollEnd = window.innerHeight;
ScrollTrigger.create({
trigger: wrapperRef.current,
start: "top top",
end: `+=${scrollEnd}`,
scrub: true,
onUpdate: (self) => {
const p = self.progress;
// Copy: translate up dramatically + slight scale
const copyY = p * -56;
const copyScale = 1 + p * 0.1;
if (copyDesktopRef.current) {
copyDesktopRef.current.style.transform = `translate(0%, ${copyY}%) translate3d(0px, 0px, 0px) scale(${copyScale}, ${copyScale})`;
}
if (copyMobileRef.current) {
copyMobileRef.current.style.transform = `translate(0%, ${copyY}%) translate3d(0px, 0px, 0px) scale(${copyScale}, ${copyScale})`;
}
// Video: shift down + scale down to 0.93
const vidY = p * 3.5;
const vidScale = 1 - p * 0.07;
if (videoColRef.current) {
videoColRef.current.style.transform = `translate(0%, ${vidY}%) translate3d(0px, 0px, 0px) scale(${vidScale}, ${vidScale})`;
}
if (videoInnerRef.current) {
const radius = p * 0.75;
videoInnerRef.current.style.borderRadius = `${radius}em`;
}
// Scroll text: push down to 100% + fade out
if (scrollTextRef.current) {
scrollTextRef.current.style.transform = `translate(0%, ${p * 100}%)`;
scrollTextRef.current.style.opacity = 1 - p;
}
},
});
}, wrapperRef);
return () => ctx.revert();
}, []);
return (
<div ref={wrapperRef} className={styles.wrapper}>
<div ref={heroRef} className={styles.hero}>
<div className={styles.layout}>
{/* Left column — desktop copy */}
<div className={styles.textCol}>
<div ref={copyDesktopRef} className={styles.copy}>
<h1 className={styles.heading}>
{headings.map((line, i) => (
<span key={i} className={styles.headingBlock}>
{line}
{i < headings.length - 1 && (
<>
<br />
<br />
</>
)}
</span>
))}
</h1>
</div>
</div>
{/* Right column — video */}
<div ref={videoColRef} className={styles.videoCol}>
<div ref={videoInnerRef} className={styles.videoInner}>
<video
src={videoSrc}
className={styles.video}
autoPlay
loop
muted
playsInline
/>
{/* Overlay strips */}
<div className={styles.stripOverlay}>
{Array.from({ length: STRIP_COUNT }).map((_, i) => (
<div
key={i}
ref={(el) => (stripsRef.current[i] = el)}
className={styles.strip}
/>
))}
</div>
</div>
{/* Scroll indicator */}
<div className={styles.scrollIndicator}>
<div className={styles.scrollOverflow}>
<p ref={scrollTextRef} className={styles.scrollText}>
{scrollLabel}
</p>
</div>
</div>
{/* Mobile copy overlay */}
<div ref={copyMobileRef} className={styles.mobileCopy}>
<h1 className={styles.heading}>
{headings.map((line, i) => (
<span key={i} className={styles.headingBlock}>
{line}
{i < headings.length - 1 && (
<>
<br />
<br />
</>
)}
</span>
))}
</h1>
</div>
</div>
</div>
</div>
</div>
);
}
demo.module.css
.spacer {
height: 100vh;
background: #fff;
position: relative;
}
styles.module.css
/*
The wrapper occupies 200vh in the document flow.
The hero is position:fixed behind it.
clip-path on the wrapper creates a viewport-sized window
that reveals the fixed hero — as you scroll past, the next
section naturally slides over the hero.
*/
.wrapper {
position: relative;
height: 200vh;
clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%);
}
.hero {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100dvh;
background: #000;
overflow: hidden;
}
/* ---- Layout ---- */
.layout {
position: relative;
z-index: 20;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
/* ---- Text column (desktop only) ---- */
.textCol {
display: none;
position: relative;
flex: 1;
border-right: 1px solid #262626;
}
.copy {
position: absolute;
bottom: 2rem;
left: 2rem;
will-change: transform;
}
/* ---- Heading ---- */
.heading {
color: #fff;
font-size: clamp(1.5rem, 3.5vw, 3rem);
font-weight: 400;
line-height: 1.2;
letter-spacing: -0.03em;
padding-right: 2rem;
}
.headingBlock {
display: inline-block;
}
/* ---- SplitText lines ---- */
.lineChild {
position: relative;
display: block;
text-align: start;
}
/* ---- Video column ---- */
.videoCol {
position: relative;
height: 100%;
flex: 1;
isolation: isolate;
will-change: transform;
}
.videoInner {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
border-radius: 0.125rem;
}
.video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* ---- Overlay strips ---- */
.stripOverlay {
position: absolute;
inset: 0;
z-index: 20;
pointer-events: none;
display: flex;
flex-direction: column;
}
.strip {
flex: 1;
background: #000;
}
/* ---- Scroll indicator ---- */
.scrollIndicator {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #fff;
mix-blend-mode: difference;
z-index: 30;
}
.scrollOverflow {
overflow: hidden;
}
.scrollText {
font-size: clamp(0.875rem, 1.5vw, 1.5rem);
color: #fff;
mix-blend-mode: difference;
will-change: transform, opacity;
}
/* ---- Mobile copy (overlaid on video) ---- */
.mobileCopy {
position: absolute;
bottom: 2rem;
left: 2rem;
z-index: 25;
will-change: transform;
}
/* ---- Desktop ---- */
@media (min-width: 1024px) {
.layout {
flex-direction: row;
}
.textCol {
display: block;
width: 30%;
flex: none;
}
.videoCol {
width: 70%;
flex: none;
}
.mobileCopy {
display: none;
}
}
Dependencies
gsap