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 →

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)
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;
}
  • 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.