April 27, 2026/Intermediate/Heroes

Hero Slit Reveal

Full-viewport hero with a GSAP clip-path reveal on the background image, overlay fade-in, and SplitText line-by-line text entrance. The media layer expands from a narrow vertical slit to full screen while the image scales down then back up.

A design studio for ambitious brands.

We build websites where craft meets clarity, and every detail earns its place. Quiet design, sharp execution, work that lasts.

(scroll down)
"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(SplitText, ScrollTrigger);

function heroTimeline(scope) {
  const mediaWrapper = scope.querySelector("[data-hero-01-media]");
  const image = scope.querySelector(`.${styles.image}`) || scope.querySelector(`.${styles.video}`);
  const overlay = scope.querySelector("[data-hero-01-overlay]");

  if (!mediaWrapper || !image) return;

  gsap.set(mediaWrapper, {
    clipPath: "polygon(50% 20%, 50% 20%, 50% 80%, 50% 80%)",
  });
  gsap.set(image, { scale: 1 });
  gsap.set(overlay, { autoAlpha: 0 });

  gsap
    .timeline({ defaults: { ease: "power3.out" } })
    .to(mediaWrapper, {
      clipPath: "polygon(35% 20%, 65% 20%, 65% 80%, 35% 80%)",
      duration: 1.4,
      ease: "power2.inOut",
    })
    .to(
      image,
      { scale: 0.86, duration: 1.4, ease: "power2.inOut" },
      "<"
    )
    .to(
      mediaWrapper,
      {
        clipPath: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
        duration: 1.65,
        ease: "power4.inOut",
      },
      "=-.1"
    )
    .to(image, { scale: 1, duration: 1.65, ease: "power4.inOut" }, "<")
    .to(overlay, { autoAlpha: 1, duration: 1.7 }, "<+=.5");
}

function textReveal(scope, delay = 0) {
  const CONFIG = {
    lines: { duration: 1, stagger: 0.06, ease: "expo.out" },
    words: { duration: 1, stagger: 0.03, ease: "expo.out" },
    chars: { duration: 0.6, stagger: 0.01, ease: "expo.out" },
    scrollStart: "top 72%",
    scrubStart: "top 80%",
    scrubEnd: "top 20%",
    once: false,
    markers: false,
  };

  const allSplitEls = scope.querySelectorAll("[data-reveal-01]");
  const autoEls = [...allSplitEls].filter(
    (el) => !el.hasAttribute("data-manual")
  );

  gsap.set(autoEls, { visibility: "visible" });

  allSplitEls.forEach((el) => {
    const splitType = el.getAttribute("data-reveal-01");
    const c = CONFIG[splitType];
    if (!c) return;

    let type, mask, linesClass, wordsClass, charsClass;

    switch (splitType) {
      case "lines":
        type = "lines";
        mask = "lines";
        linesClass = "line";
        break;
      case "words":
        type = "words, lines";
        mask = "words";
        wordsClass = "word";
        linesClass = "line";
        break;
      case "chars":
        type = "chars, words, lines";
        mask = "chars";
        charsClass = "char";
        wordsClass = "word";
        linesClass = "line";
        break;
      default:
        return;
    }

    if (el.hasAttribute("data-manual")) {
      SplitText.create(el, {
        type,
        mask,
        autoSplit: true,
        ...(linesClass && { linesClass }),
        ...(wordsClass && { wordsClass }),
        ...(charsClass && { charsClass }),
      });
      return;
    }

    const scrollMode = el.getAttribute("data-scroll");
    const useScroll = el.hasAttribute("data-scroll");
    const useScrub = scrollMode === "scrub";

    SplitText.create(el, {
      type,
      mask,
      autoSplit: true,
      ...(linesClass && { linesClass }),
      ...(wordsClass && { wordsClass }),
      ...(charsClass && { charsClass }),
      onSplit(instance) {
        const durationValue = parseFloat(el.dataset.duration);
        const staggerValue = parseFloat(el.dataset.stagger);
        const delayValue = parseFloat(el.dataset.delay);
        const duration = Number.isNaN(durationValue) ? c.duration : durationValue;
        const stagger = Number.isNaN(staggerValue) ? c.stagger : staggerValue;
        const elDelay = Number.isNaN(delayValue) ? 0 : delayValue;
        const ease = el.dataset.ease || c.ease;

        const targets = instance[splitType];
        const once = el.hasAttribute("data-once")
          ? el.getAttribute("data-once") !== "false"
          : CONFIG.once;

        const tween = {
          yPercent: 110,
          duration,
          stagger,
          delay: useScroll ? elDelay : elDelay + delay,
          immediateRender: true,
          ease,
        };

        if (useScrub) {
          tween.scrollTrigger = {
            trigger: el,
            start: CONFIG.scrubStart,
            end: CONFIG.scrubEnd,
            scrub: true,
            markers: CONFIG.markers,
            ...(once && { onLeave: (self) => self.kill(false) }),
          };
        } else if (useScroll) {
          const start = scrollMode || CONFIG.scrollStart;
          tween.scrollTrigger = {
            trigger: el,
            start: `clamp(${start})`,
            markers: CONFIG.markers,
            ...(once
              ? { once: true }
              : { toggleActions: "play none none reverse" }),
          };
        }

        return gsap.from(targets, tween);
      },
    });
  });
}

export default function HeroSlitReveal({
  imageSrc,
  videoSrc,
  headline = "A design studio for ambitious brands.",
  body = "We build websites where craft meets clarity, and every detail earns its place. Quiet design, sharp execution, work that lasts.",
  scrollLabel = "(scroll down)",
}) {
  const sectionRef = useRef(null);

  useEffect(() => {
    const el = sectionRef.current;
    if (!el) return;

    const ctx = gsap.context(() => {
      heroTimeline(el);
      textReveal(el, 2.24);
    }, el);

    return () => ctx.revert();
  }, []);

  return (
    <section ref={sectionRef} className={styles.hero} data-hero-01>
      <div className={styles.media} data-hero-01-media aria-hidden="true">
        {videoSrc ? (
          <video
            className={styles.video}
            src={videoSrc}
            autoPlay
            muted
            loop
            playsInline
            preload="auto"
          />
        ) : (
          <img
            className={styles.image}
            src={imageSrc}
            alt=""
            loading="eager"
            decoding="async"
          />
        )}
        <div className={styles.overlay} data-hero-01-overlay />
      </div>

      <div className={styles.content}>
        <div className={styles.upper}>
          <div className={styles.titleWrap}>
            <h1 data-reveal-01="lines">{headline}</h1>
          </div>
        </div>
        <div className={styles.bottom}>
          <p data-reveal-01="lines">{body}</p>
          <span data-reveal-01="lines">{scrollLabel}</span>
        </div>
      </div>
    </section>
  );
}
  • gsap

Related Components

NEWTODAY
BeNorth Exo Ape Hero Comboheroes
1W AGO
BeNorth Sticker Heroheroes
1MO AGO
Intro Sectionheroes