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 →Preview

Source
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;
}
Dependencies
gsap