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 →

A digital video production studio.

Bold, modern visuals.

scroll

A digital video production studio.

Bold, modern visuals.

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;
  }
}
  • gsap

May 11, 2026

HEROES

Hero About

Scroll-driven parallax hero where a full-bleed video shrinks and zooms out as text transitions from white-on-video to black-on-white. Three-column layout with staggered divider and description reveals in the final 30% of scroll. Separate stacked mobile layout with scroll-triggered entrances.

May 11, 2026

HEROES

Hero Home

Full-viewport video hero with inset rounded background on desktop, large fluid display typography stacked line-by-line, and a glassmorphic CTA card anchored bottom-right. Inspired by Wolverine Worldwide's homepage.

May 4, 2026

HEROES

Hero Brand

A full-viewport brand hero section with large statement headline, live clock, and CTA bar. Supports three color variants (default yellow, dark, sage green) and optional background image.