AnimationsIntermediateApril 9, 2026

Shutter Section

Scroll-driven venetian-blind effect using GSAP ScrollTrigger. Horizontal rows animate their scaleY to reveal or cover a full-viewport media section as the user scrolls. Supports image and video, two modes (reveal / cover), and responsive row counts.

View Full Demo →
Shutter demo
demo.jsx
import ShutterSection from "./index.jsx";
import styles from "./demo.module.css";

export default function ShutterSectionDemo() {
  return (
    <div className={styles.demo}>

      {/* ── Intro ─────────────────────────────────────────────────── */}
      <section className={styles.intro}>
        <p className={styles.label}>Shutter Section</p>
        <h1 className={styles.heading}>
          Scroll-driven<br />venetian blind
        </h1>
        <p className={styles.body}>
          Scroll down to see both modes — the section is first revealed,
          then covered, as you move through the page.
        </p>
      </section>

      {/* ── Mode: reveal ──────────────────────────────────────────── */}
      {/* Shutter starts closed (scaleY 1), dissolves open as section enters */}
      <ShutterSection
        mediaSrc="/demo-assets/card-sample-1.jpg"
        mediaAlt="Shutter reveal demo"
        mode="reveal"
        shutterColor="var(--color-bg)"
        rows={16}
        rowsTablet={10}
        rowsMobile={6}
      >
        <div className={styles.sectionContent}>
          <p className={styles.sectionLabel}>mode: reveal</p>
          <h2 className={styles.sectionHeading}>Revealed on scroll</h2>
        </div>
      </ShutterSection>

      {/* ── Bridge ────────────────────────────────────────────────── */}
      <section className={styles.bridge}>
        <p className={styles.bridgeLabel}>mode: cover</p>
        <p className={styles.bridgeBody}>
          The next section closes its shutter as it scrolls out of view.
        </p>
      </section>

      {/* ── Mode: cover ───────────────────────────────────────────── */}
      {/* Shutter starts open (scaleY 0), seals shut as section exits */}
      <ShutterSection
        mediaSrc="/demo-assets/card-sample-2.jpg"
        mediaAlt="Shutter cover demo"
        mode="cover"
        shutterColor="var(--color-bg)"
        rows={16}
        rowsTablet={10}
        rowsMobile={6}
      >
        <div className={styles.sectionContent}>
          <p className={styles.sectionLabel}>mode: cover</p>
          <h2 className={styles.sectionHeading}>Covered on scroll</h2>
        </div>
      </ShutterSection>

      {/* ── Outro ─────────────────────────────────────────────────── */}
      <section className={styles.outro}>
        <p className={styles.outroText}>End of demo</p>
      </section>

    </div>
  );
}
index.jsx
"use client";

import { useEffect, useRef } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Section from "@/components/primitives/Section";
import Media from "@/components/primitives/Media";
import styles from "./styles.module.css";

gsap.registerPlugin(ScrollTrigger);

export default function ShutterSection({
  mediaSrc,
  mediaType = "image",
  mediaAlt = "",
  children,
  rows = 16,
  rowsTablet = 10,
  rowsMobile = 6,
  shutterColor,
  mode = "cover",
  scrollStart,
  scrollEnd,
}) {
  const shutterRef = useRef(null);
  const mmRef = useRef(null);

  useEffect(() => {
    const wrapper = shutterRef.current;
    if (!wrapper) return;

    const defaultScrollStart = { cover: "bottom bottom", reveal: "top bottom" };
    const defaultScrollEnd   = { cover: "bottom top",    reveal: "top center"  };
    const defaultScrub             = 0.3;
    const defaultShutterDuration   = 0.1;
    const defaultStaggerAmount     = 0.01;

    const breakpoints = {
      mobile:    "(max-width: 478px)",
      landscape: "(max-width: 767px)",
      tablet:    "(max-width: 991px)",
    };

    function getRows() {
      if (window.matchMedia(breakpoints.mobile).matches)    return rowsMobile;
      if (window.matchMedia(breakpoints.tablet).matches)    return rowsTablet;
      return rows;
    }

    function buildRows() {
      const panel = document.createElement("div");
      panel.setAttribute("data-shutter-panel", "");
      panel.className = styles.panel;

      const count = getRows();
      for (let i = 0; i < count; i++) {
        const row = document.createElement("div");
        row.setAttribute("data-shutter-row", "");
        row.className = styles.row;
        panel.appendChild(row);
      }

      wrapper.appendChild(panel);
      return panel;
    }

    function destroyRows() {
      const panel = wrapper.querySelector("[data-shutter-panel]");
      if (panel) panel.remove();
    }

    function createAnimation(panel) {
      const rowEls  = Array.from(panel.children);
      const section = wrapper.closest("section") || wrapper.parentElement;

      const start = scrollStart || defaultScrollStart[mode];
      const end   = scrollEnd   || defaultScrollEnd[mode];

      const fromScale = mode === "cover" ? 0 : 1;
      const toScale   = mode === "cover" ? 1 : 0;
      const origin    = mode === "cover" ? "bottom center" : "top center";

      gsap.set(rowEls, { scaleY: fromScale, transformOrigin: origin });

      const tl = gsap.timeline({
        scrollTrigger: {
          trigger: section,
          start,
          end,
          scrub: defaultScrub,
          invalidateOnRefresh: true,
        },
      });

      tl.to(rowEls, {
        scaleY: toScale,
        duration: defaultShutterDuration,
        stagger: { each: defaultStaggerAmount, from: "end" },
        ease: "none",
      });

      return tl;
    }

    mmRef.current = gsap.matchMedia();

    mmRef.current.add(
      {
        isDesktop:    "(min-width: 992px)",
        isTablet:     "(min-width: 768px) and (max-width: 991px)",
        isLandscape:  "(min-width: 479px) and (max-width: 767px)",
        isMobile:     "(max-width: 478px)",
        reduceMotion: "(prefers-reduced-motion: reduce)",
      },
      (context) => {
        if (context.conditions.reduceMotion) return;

        const panel = buildRows();
        const tl    = createAnimation(panel);
        ScrollTrigger.refresh();

        return () => {
          tl?.scrollTrigger?.kill();
          tl?.kill();
          destroyRows();
        };
      }
    );

    return () => {
      mmRef.current?.revert();
    };
  }, [rows, rowsTablet, rowsMobile, mode, scrollStart, scrollEnd]);

  return (
    <Section variant="hero">
      <Media
        type={mediaType}
        src={mediaSrc}
        alt={mediaAlt}
        fill
        priority
      />
      <div className={styles.content}>
        {children}
      </div>
      <div
        ref={shutterRef}
        className={styles.shutter}
        style={shutterColor ? { "--shutter-color": shutterColor } : undefined}
      />
    </Section>
  );
}
demo.module.css
.demo {
  background: var(--color-bg);
}

/* ── Intro ───────────────────────────────────────────────────── */
.intro {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  min-height: 100vh;
  text-align: center;
  padding: var(--space-16);
}

.label {
  font-size: var(--text-xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--color-text-tertiary);
  margin-bottom: var(--space-6);
}

.heading {
  font-size: clamp(2.5rem, 6vw, 5rem);
  font-weight: var(--weight-bold);
  letter-spacing: -0.03em;
  line-height: var(--leading-tight);
  color: var(--color-text);
  margin-bottom: var(--space-6);
}

.body {
  font-size: var(--text-lg);
  color: var(--color-text-secondary);
  max-width: 480px;
  line-height: var(--leading-relaxed);
}

/* ── Section content (inside ShutterSection) ─────────────────── */
.sectionContent {
  text-align: center;
}

.sectionLabel {
  font-size: var(--text-xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.6);
  margin-bottom: var(--space-4);
}

.sectionHeading {
  font-size: clamp(2rem, 5vw, 4rem);
  font-weight: var(--weight-bold);
  letter-spacing: -0.03em;
  line-height: var(--leading-tight);
  color: #fff;
  text-shadow: 0 2px 24px rgba(0, 0, 0, 0.4);
}

/* ── Bridge ──────────────────────────────────────────────────── */
.bridge {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  min-height: 60vh;
  text-align: center;
  padding: var(--space-16);
  background: var(--color-bg);
}

.bridgeLabel {
  font-size: var(--text-xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--color-text-tertiary);
  margin-bottom: var(--space-4);
}

.bridgeBody {
  font-size: var(--text-lg);
  color: var(--color-text-secondary);
  max-width: 400px;
  line-height: var(--leading-relaxed);
}

/* ── Outro ───────────────────────────────────────────────────── */
.outro {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 40vh;
  background: var(--color-bg);
}

.outroText {
  font-size: var(--text-sm);
  color: var(--color-text-tertiary);
  letter-spacing: 0.05em;
}
styles.module.css
.content {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  width: 100%;
  padding-inline: var(--space-16);
  text-align: center;
}

.shutter {
  position: absolute;
  inset: auto 0 0;
  z-index: 10;
  pointer-events: none;
  color: var(--shutter-color, var(--color-bg));
}

.panel {
  display: flex;
  flex-direction: column;
  width: 100%;
}

.row {
  height: 3em;
  width: 100%;
  background-color: currentColor;
  backface-visibility: hidden;
  will-change: transform;
}
  • gsap

Apr 27, 2026

ANIMATIONS

Cursor Hover Label

A custom cursor label that follows the mouse and fades in when hovering trigger elements. Uses GSAP quickTo for smooth tracking. Trigger elements use data-cursor-label attributes to set label text. Inspired by portfolio/agency sites like Studio PIC.

Apr 27, 2026

ANIMATIONS

Section Transition 01

GSAP ScrollTrigger-based section transition system. Sibling sections opt into parallax, pin, or reveal modes via data attributes. Supports y offset, overlay opacity, and overlay color per section. Mobile strategy simplifies motion on smaller screens.

Apr 27, 2026

ANIMATIONS

Text Reveal 01

GSAP SplitText-based text reveal system. Text elements opt in with data-reveal-01 and split into lines, words, or characters. Supports load-time reveals, scroll-triggered reveals, scrubbed scroll reveals, and manual split-only mode for custom timelines. Per-element overrides for duration, stagger, delay, ease, and replay behavior.