🚀

Update available

We just released a new resource or update, refresh the Vault to access the latest version.

Cancel

Refresh now

Harri

Profile Picture

Harri

Lemke

Shutter Scroll Transition

Documentation

Webflow

Code

Setup: External Scripts

HTML

Copy
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15/dist/ScrollTrigger.min.js"></script>

Step 1: Add HTML

HTML

Copy
<section class="shutter-scroll__section">
  <div class="shutter-scroll__bg">
    <img src="https://cdn.prod.website-files.com/69d679249da8e86094dd9f26/69d67bc9795efa71b0654c18_Solitary%20Journey%20through%20the%20Sinuous%20Canyon.avif" class="shutter-scroll__bg-img" />
  </div>
  <div class="shutter-scroll__content">
    <h1 class="shutter-scroll__h">Shutter<br />Scroll<br />Transition</h1>
  </div>
  <div data-rows="16" data-shutter-scroll-transition="" data-rows-tablet="10" data-rows-mobile="6" class="shutter-scroll-transition"></div>
</section>

Step 2: Add CSS

CSS

Copy
.shutter-scroll__section {
  color: #f2f2f2;
  justify-content: center;
  align-items: center;
  min-height: 100svh;
  display: flex;
  position: relative;
  overflow: hidden;
}

.shutter-scroll__bg {
  z-index: 0;
  background-color: #000;
  position: absolute;
  inset: 0%;
}

.shutter-scroll__bg-img {
  opacity: .8;
  object-fit: cover;
  width: 100%;
  height: 100%;
}

.shutter-scroll__content {
  z-index: 1;
  position: relative;
}

.shutter-scroll__h {
  text-align: center;
  letter-spacing: -.04em;
  max-width: 8em;
  margin-top: 0;
  margin-bottom: 0;
  font-family: Haffer XH, Arial, sans-serif;
  font-size: 6em;
  font-weight: 400;
  line-height: .95;
}

.shutter-scroll-transition {
  z-index: 10;
  pointer-events: none;
  color: #cecece;
  position: absolute;
  inset: auto 0% 0%;
}

[data-shutter-scroll-panel] {
  display: flex;
  flex-direction: column;
  width: 100%;
}
  
[data-shutter-scroll-row] {
  height: 3em;
  width: 100%;
  background-color: currentColor;
  backface-visibility: hidden;
  will-change: opacity;
}

[data-shutter-scroll-transition][data-shutter-height="1.5em"] [data-shutter-scroll-row] {
  height: 1.5em;
}

Step 2: Add Javascript

Step 3: Add Javascript

Javascript

Copy
function initShutterScrollTransition() {
  
  // Defaults — edit these to change fallbacks if no data-attribute is added
  const defaultRows = 6;
  const defaultMode = "cover";
  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;

  // Class names applied to generated elements
  const panelClass = "shutter-scroll-transition__panel";
  const rowClass = "shutter-scroll-transition__row";

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

  const instances = [];
  let mm = null;

  function getMode(wrapper) {
    return wrapper.dataset.mode === "reveal" ? "reveal" : defaultMode;
  }

  function getRows(wrapper) {
    const base = parseInt(wrapper.dataset.rows, 10) || defaultRows;

    if (window.matchMedia(breakpoints.mobile).matches) {
      return parseInt(wrapper.dataset.rowsMobile, 10) || base;
    }
    if (window.matchMedia(breakpoints.landscape).matches) {
      return parseInt(wrapper.dataset.rowsLandscape, 10) || base;
    }
    if (window.matchMedia(breakpoints.tablet).matches) {
      return parseInt(wrapper.dataset.rowsTablet, 10) || base;
    }
    return base;
  }

  function getScrollStart(wrapper, mode) {
    return wrapper.dataset.scrollStart || defaultScrollStart[mode];
  }

  function getScrollEnd(wrapper, mode) {
    return wrapper.dataset.scrollEnd || defaultScrollEnd[mode];
  }

  function createRow() {
    const row = document.createElement("div");
    row.classList.add(rowClass);
    row.setAttribute("data-shutter-scroll-row", "");
    return row;
  }

  function buildRows(wrapper, rows) {
    const panel = document.createElement("div");
    panel.classList.add(panelClass);
    panel.setAttribute("data-shutter-scroll-panel", "");

    const fragment = document.createDocumentFragment();
    for (let r = 0; r < rows; r++) {
      fragment.appendChild(createRow());
    }
    panel.appendChild(fragment);
    wrapper.appendChild(panel);

    return { panel };
  }

  function collectRows(panel) {
    return Array.from(panel.children);
  }

  function createAnimation(wrapper, rows, section, mode) {
    const tl = gsap.timeline({
      scrollTrigger: {
        trigger: section,
        start: getScrollStart(wrapper, mode),
        end: getScrollEnd(wrapper, mode),
        scrub: defaultScrub,
        invalidateOnRefresh: true,
      },
    });

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

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

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

    return tl;
  }

  function setupInstance(wrapper) {
    const section = wrapper.closest("section") || wrapper.parentElement;
    const rows = getRows(wrapper);
    const mode = getMode(wrapper);

    const { panel } = buildRows(wrapper, rows);
    const rowList = collectRows(panel);
    const tl = createAnimation(wrapper, rowList, section, mode);

    return { wrapper, tl };
  }

  function destroyInstance(instance) {
    if (instance.tl) {
      instance.tl.scrollTrigger?.kill();
      instance.tl.kill();
    }
    const panel = instance.wrapper.querySelector("[data-shutter-scroll-panel]");
    if (panel) panel.remove();
  }

  function buildAll() {
    const wrappers = document.querySelectorAll("[data-shutter-scroll-transition]");
    wrappers.forEach((wrapper) => {
      instances.push(setupInstance(wrapper));
    });
    ScrollTrigger.refresh();
  }

  function destroyAll() {
    instances.forEach(destroyInstance);
    instances.length = 0;
  }

  const wrappers = document.querySelectorAll("[data-shutter-scroll-transition]");
  if (!wrappers.length) return;

  mm = gsap.matchMedia();

  mm.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;

      buildAll();

      return () => {
        destroyAll();
      };
    }
  );
}

// Initialize Shutter Scroll Transition
document.addEventListener("DOMContentLoaded", () => {
  initShutterScrollTransition();
});

Implementation

The component generates a stack of full-width shutter rows inside a wrapper. As the user scrolls, rows scale vertically (cover mode) or collapse (reveal mode) in a bottom-to-top sequence.

Wrapper element

The component only needs a single div on your page. This is the wrapper, the script generates all shutter rows inside it. Add the data-shutter-scroll-transition attribute to this div. All configuration (rows, mode, scroll positions) is set as data attributes on this same element.

Place the wrapper inside a section with position: relative and overflow: hidden. No need to add children elements inside it, the rows are built automatically.

Shutter colour

The shutter background uses currentColor. Set the CSS color property on the wrapper to control the shutter colour. This should match the background of the adjacent section for a seamless transition.

Rows

Set the number of rows with data-rows. The default is 6 rows if the attribute is omitted.

Responsive rows

Override the row count at specific breakpoints. If a responsive override is not set, the base row count is used.

data-rows="8"
data-rows-tablet="6"
data-rows-landscape="5"
data-rows-mobile="4"
Copy

Mode: cover and reveal

The data-mode attribute controls the animation direction.

  • data-mode="cover" (default) Rows start collapsed at the bottom of the section and scale upward, covering the content as the user scrolls.
  • data-mode="reveal" Rows start fully expanded and collapse toward the top, revealing the content as the user scrolls in.

Custom scroll positions

Decide when the animation starts and ends within the scroll range using data-scroll-start and data-scroll-end. These accept any valid GSAP ScrollTrigger position string.

data-scroll-start="top center"
data-scroll-end="center top"
Copy

The defaults depend on mode. Cover mode starts at bottom bottom and ends at bottom top. Reveal mode starts at top bottom and ends at top center.

Shutter height

Each row has a fixed height set in CSS. Override the height using the data-shutter-height attribute on the wrapper. The matching CSS uses an attribute selector.

[data-shutter-scroll-transition][data-shutter-height="1.5em"] [data-shutter-scroll-row] {
  height: 1.5em;
}
Copy

Smaller heights increase the number of visible shutters within the same space. Larger heights create thicker bands.

Accessibility

The component uses gsap.matchMedia() to detect prefers-reduced-motion: reduce. When the user has reduced motion enabled, no rows are generated and no animation runs.

Cleanup

The component rebuilds automatically on breakpoint changes via gsap.matchMedia(). For single-page applications or dynamic page transitions, you can call initShutterScrollTransition() again after new content is loaded. Each call creates a fresh matchMedia context.

Performance note

Each instance generates one DOM element per row. Compared to the pixel version, this is significantly lighter. The animation itself is very lightweight. Keep row counts reasonable, especially when using very small shutter heights, to avoid unnecessary DOM growth.

Live preview

Osmo Robot AI

Copy context for AI

Beta

Webflow

HTML/CSS/JS

Save video

Copy share link

Resource details

  • Published

    April 8, 2026

  • Category

    Scroll Animations

  • Popularity

    707 visitors

  • Need help?

    Join Slack

Pixels
GSAP
Scrolltrigger
Effect
Reveal
Background

Original source

Ilja van EckIlja van Eck

Creator Credits

We always strive to credit creators as accurately as possible. While similar concepts might appear online, we aim to provide proper and respectful attribution.

s