HeroesIntermediateApril 27, 2026
Hero 01
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.
View Full Demo →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
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(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 Hero01({
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>
);
}
styles.module.css
/* ── Hero section ── */
.hero {
position: relative;
overflow: hidden;
height: 100svh;
color: #ffffff;
}
/* ── Media layer ── */
.media {
position: absolute;
inset: 0;
}
.image,
.video,
.overlay {
position: absolute;
inset: 0;
object-fit: cover;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.4);
}
/* ── Content ── */
.content {
position: relative;
padding-top: 23vh;
height: 100%;
display: flex;
flex-direction: column;
padding-inline: 32px;
}
@media (max-width: 1024px) {
.content {
padding-inline: 20px;
}
}
.content h1 {
font-size: 90px;
font-weight: 400;
line-height: 1;
max-width: 16ch;
letter-spacing: -0.01em;
}
@media (max-width: 1024px) {
.content h1 {
font-size: 60px;
}
}
@media (max-width: 768px) {
.content h1 {
font-size: 40px;
max-width: none;
}
}
.bottom {
margin-top: auto;
display: flex;
justify-content: space-between;
align-items: flex-end;
padding-block: 32px;
font-size: 24px;
}
.bottom p {
max-width: 40ch;
line-height: 1.35;
}
.bottom span {
opacity: 0.5;
}
@media (max-width: 1024px) {
.bottom {
flex-direction: column;
align-items: flex-start;
gap: 16px;
font-size: 16px;
}
}
/* ── Text reveal (required for SplitText) ── */
:global([data-reveal-01]) {
visibility: hidden;
}
:global([data-reveal-01]) > * {
margin-bottom: -0.1em;
}
:global(.word-mask),
:global(.char-mask) {
vertical-align: top;
}
:global(.line-mask) > *,
:global(.word-mask) > *,
:global(.char-mask) > * {
padding-bottom: 0.1em;
will-change: transform;
}
Dependencies
gsap