April 27, 2026IntermediateHeroes
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.
Preview
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)Source
"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>
);
}
Dependencies
gsap