← 回總覽

使用 GSAP 和 ScrollTrigger 实现滚动触发的 SVG 遮罩过渡

📅 2026-03-11 22:37 Hiroki Watanabe 软件编程 17 分鐘 20544 字 評分: 85
GSAP SVG 遮罩 ScrollTrigger 前端开发 Web 动画
📌 一句话摘要 一份关于使用 GSAP 和 ScrollTrigger 实现复杂的滚动同步 SVG 遮罩过渡的技术指南,重点关注响应式逻辑和视觉精度。 📝 详细摘要 本文深入探讨了如何利用 SVG 遮罩和 GSAP 动画库创建高端 Web 过渡效果。文章详细介绍了四种不同的过渡效果:水平百叶窗、随机网格、垂直百叶窗和列随机网格。除了基础实现,作者还解释了关键的技术细节,例如使用 0-100 的归一化 SVG 坐标系来实现响应式缩放,通过 crispEdges 属性和数学偏移解决亚像素渲染伪影,以及利用 GSAP 的交错(stagger)和工具函数创建复杂的多层运动。指南涵盖了结构化的 HT

Title: SVG Mask Transitions on Scroll with GSAP and ScrollTrigger | BestBlogs.dev

URL Source: https://www.bestblogs.dev/article/349278bf

Published Time: 2026-03-11 14:37:52

Markdown Content: ![Image 1](https://tympanus.net/Tutorials/SVGMaskScrollTransition/ "SVG Mask Transitions on Scroll with GSAP and ScrollTrigger Demo")

Animations have a major impact on the impression a website gives. In this article, we introduce four transition effects that smoothly switch full-screen images.

Using GSAP, ScrollTrigger, and SVG masks, we’ll implement scroll-synchronized animations with grid and blind-style structures. Rather than simply changing images, the goal is to give intention to the scene transitions themselves, creating transitions that convey the site’s information and atmosphere more naturally. Common markup structure (HTML) ----------------------------------

Lets look at the foundation used in all four demos.

<section class="stage">
  <div class="layers">
    <svg class="layer" viewBox="0 0 100 100" preserveAspectRatio="none">
      <defs>
        <mask id="mask1" maskUnits="userSpaceOnUse">
          <rect x="0" y="0" width="100" height="100" fill="black"/>
          <g id="blinds1"></g>
        </mask>
      </defs>
      <image href="img/pic-1.jpg"
        x="0" y="0" width="100" height="100"
        preserveAspectRatio="xMidYMid slice"
        mask="url(#mask1)"/>
    </svg>
    <svg class="layer" viewBox="0 0 100 100" preserveAspectRatio="none">
      <defs>
        <mask id="mask2" maskUnits="userSpaceOnUse">
          <rect x="0" y="0" width="100" height="100" fill="black"/>
          <g id="blinds2"></g>
        </mask>
      </defs>
      <image href="img/pic-3.jpg"
        x="0" y="0" width="100" height="100"
        preserveAspectRatio="xMidYMid slice"
        mask="url(#mask2)"/>
    </svg>
    <svg class="layer" viewBox="0 0 100 100" preserveAspectRatio="none">
      <defs>
        <mask id="mask3" maskUnits="userSpaceOnUse">
          <rect x="0" y="0" width="100" height="100" fill="black"/>
          <g id="blinds3"></g>
        </mask>
      </defs>
      <image href="img/pic-2.jpg"
        x="0" y="0" width="100" height="100"
        preserveAspectRatio="xMidYMid slice"
        mask="url(#mask3)"/>
    </svg>
    <div class="progress-bar">
      <div class="segment">
        <div class="fill"></div>
      </div>
      <div class="segment">
        <div class="fill"></div>
      </div>
      <div class="segment">
        <div class="fill"></div>
      </div>
    </div>
    <div class="texts">
      <div class="txt">
        <h1>FIRST<br>
          IMAGE</h1>
        <h2>Section transition</h2>
        <span>Text Text Text Text Text Text Text Text Text Text Text Text</span></div>
      <div class="txt">
        <h1>SECOND<br>
          IMAGE</h1>
        <h2>Section transition</h2>
        <span>Text Text Text Text Text Text Text Text Text Text Text Text</span></div>
      <div class="txt">
        <h1>THIRD<br>
          IMAGE</h1>
        <h2>Section transition</h2>
        <span>Text Text Text Text Text Text Text Text Text Text Text Text</span></div>
    </div>
  </div>
</section>

Responsive SVG canvas

Set viewBox="0 0 100 100" on .layer (the SVG element) and manage internal coordinates using virtual units (0–100). This allows the mask calculation logic to remain consistent even when the screen size changes.

Defining the SVG Mask

Define a unique <mask> for each layer.

Base (black): Place a rectangle with fill="#000000" to set the initial state to “completely hidden.”

Dynamic parts (white): Prepare an empty <g id="blinds*"> where white rectangles generated by JavaScript will be inserted. Only the areas where this “white” overlaps become visible.

The image element as content

This is the main element that gets masked. It is linked to the mask above using the mask="url(#mask*)" attribute. preserveAspectRatio="xMidYMid slice": Recreates behavior similar to CSS object-fit: cover within SVG, preventing image distortion while covering the entire screen.

Text and progress bar

Elements that visually complement the animation’s state and atmosphere.

Core Logic (Shared JavaScript) ------------------------------

Smooth interaction (Lenis)

Converts standard scrolling into motion with inertia. When combined with scrub, it creates a smooth, responsive feel as the mask follows the scroll.

Dynamic layout calculation (updateLayout)

The width is dynamically calculated (vbWidth) based on the screen height (100). On resize, the existing timeline is kill()ed and rebuilt, ensuring the images continue to cover the full screen without distortion regardless of the screen ratio.

High-precision synchronization (ScrollTrigger)

The overall progress is synchronized with scrub: 2.0–2.5. Even after scrolling stops, the animation continues slightly, adding a subtle and pleasant trailing motion.

Gap prevention (Anti-aliasing)

In addition to using shape-rendering="crispEdges", small overlaps (about +0.01 to +0.1) are added depending on each effect’s calculation logic. This visually eliminates the tiny 1px gaps that can appear between segmented rectangles.

Animation Variations --------------------

This section explains the core of each demo: the JavaScript logic used to generate and animate the rectangles.

1. Horizontal Blinds

An effect where horizontal lines expand vertically, gradually filling the screen.

#### 1. Coordinate Calculation Logic (Placement)

Inside the createBlinds function, the screen height (vbHeight) is evenly divided by BLIND_COUNT (30 lines), and the center line (centerY) where each line should be placed is calculated.

const h = vbHeight / BLIND_COUNT; // Final height of a single blind slit
// ...inside loop...
// Calculate the center Y coordinate for each slit. 
// Subtracting from vbHeight flips the order, stacking them from bottom to top.
const centerY = vbHeight - (currentY + h / 2);

The key point here is that the lines are not simply placed from top to bottom. Instead, by subtracting from vbHeight, a reversed stacking calculation is performed so that the lines accumulate from the bottom upward. This process causes the blinds to be positioned starting from the bottom of the screen and moving toward the top.

Additionally, for each slit, two rectangles—rectTop and rectBottom—are placed on the exact same centerY, overlapping each other. Since their initial height is 0, nothing is visible at the start.

#### 2. Animation Mechanism

When the openBlinds function runs, the two overlapping rectangles begin moving in opposite vertical directions.

y: (i) => {
  const b = blinds[Math.floor(i / 2)];
  // Even index moves up to reveal, odd index stays at the center line
  return i % 2 === 0 ? b.y - b.h : b.y; 
},
height: (i) => {
  const b = blinds[Math.floor(i / 2)];
  // Expand height to full size with a tiny offset (0.01) to eliminate gaps
  return b.h + 0.01; 
}
rectTop (when i is even): while extending its height, its y coordinate moves upward by half of its own height (b.h). rectBottom (when i is odd): its y coordinate remains unchanged while its height extends downward from the center.

This contrasting motion—one moving upward while the other expands in place—creates the visual effect of opening both upward and downward from the center line.

#### 3. The “0.01” that eliminates gaps

The b.h + 0.01 in the code plays an important role.

In SVG rendering, even when shapes are mathematically placed exactly next to each other, browser calculations involving subpixels can sometimes produce tiny gaps of less than 1px. By intentionally increasing the size by 0.01 so adjacent elements overlap slightly, the fill remains seamless at any resolution.

#### 4. Staggered timing for visual effect stagger: { each: 0.02, from: "start" } Not all of the lines open at the same time. Instead, they react with a 0.02-second offset.

The important point here is that the animation targets are ordered like this: [top1, bottom1, top2, bottom2, ...]

As a result, the top and bottom pair of the same slit move almost simultaneously, then the animation proceeds to the next slit in sequence. In other words, the blinds open one by one from the bottom upward.

This subtle time offset creates a rhythmic, continuous _flap-flap-flap…_ unfolding motion rising from the bottom of the screen, adding a pleasing sense of response to the scroll interaction.

2. Random Grid

The screen is divided into a grid where panels open at different timings, creating a digital yet organic visual effect.

#### 1. Coordinate Calculation Logic (Placement)

Inside the createBlinds function, the number of columns (cols) is determined for each device based on the screen width, and the number of rows (rows) is then automatically calculated to match the screen’s aspect ratio.

// ------------------
// Grid configuration
// ------------------
const cols = getGridCols(); // 14 (PC) / 10 (Tablet) / 6 (SP)
const rows = GRID_ROWS || Math.round(cols * (vbHeight / vbWidth));

// ------------------ // Cell dimensions // ------------------ const cellW = vbWidth / cols; const cellH = vbHeight / rows;

The key point here is that the rows are not calculated simply from the screen ratio alone. Instead, the calculation uses the number of columns (cols) as the reference so that the cell sizes remain as even as possible both vertically and horizontally.

The formula cols * (vbHeight / vbWidth) adjusts the vertical division count by multiplying the horizontal division density by the screen’s aspect ratio. This keeps the grid balanced in both directions.

As a result, even when the number of columns changes depending on the device, the cells do not become excessively tall or wide, and their proportions stay close to square.

Using a double for loop, rectangles with the calculated cellW (width) and cellH (height) are laid out like tiles. In the initial state, opacity: 0 is applied, so everything starts completely hidden.

2. Animation Mechanism

In the openBlinds function, random motion is applied to all generated rectangles (cells) using GSAP utilities.

function openBlinds(cells) {
  // Shuffle cells to create a random reveal effect
  const shuffled = gsap.utils.shuffle([...cells]);

return gsap.timeline() .to(shuffled, { opacity: 1, duration: 1, ease: "power3.out", stagger: { each: 0.02 } }); }

After shuffling the panel order using gsap.utils.shuffle, the opacity is returned to 1 with a 0.02-second stagger. This creates a visually interesting effect where the grid itself remains orderly, but the way it appears is irregular.

#### 3. Device Optimization (Responsive)

In this effect, the grid density changes dynamically depending on the screen size.

PC: 14 columns

Mobile: 6 columns

When the screen is resized, updateLayout runs and redraws the grid using the optimal number of cells based on the latest screen size. This ensures the intended level of immersion is maintained on any device.

#### 4. Attention to Visual Quality

As with Horizontal Blinds, shape-rendering="crispEdges" is specified.

This suppresses edge blurring caused by anti-aliasing and forces the panel edges to render closer to pixel boundaries.

As a result, the boundaries between adjacent cells appear sharper, giving the entire grid a cleaner and more solid impression.

Because SVG rendering often involves scaling, subpixel calculations can sometimes cause slight blurring along edges. By specifying crispEdges, this visual “softness” is minimized, preventing small gaps where the background color might otherwise appear between cells.

3. Vertical Blinds

An effect where vertical strips expand horizontally, filling the screen from side to side. It creates a familiar yet refined transition, similar to curtains or blinds opening and closing.

#### 1. Coordinate Calculation Logic (Placement)

Inside the createBlinds function, the screen width (vbWidth) is evenly divided by BLIND_COUNT (12 lines) to calculate the width of each strip (w). Two rectangles (rectLeft, rectRight) are then placed overlapping each other on the center line (centerX) of each strip.

const w = vbWidth / BLIND_COUNT; // Final width of each vertical slit
// ...inside loop...
// Calculate the center X coordinate for each slit to expand from.
const centerX = currentX + w / 2;

Since the initial width is set to 0, nothing is visible inside the mask at the start. While the horizontal blinds divide and control the height, this version divides and controls the width.

#### Initial layer

In this version, isFirstLayer is used because the first image is intended to be visible from the beginning. In the other patterns, the animation starts from the first image as well, but in this version only the first layer is shown initially to support cases where the visual needs to be established before scrolling begins. This is not a structural requirement, but a choice made for expressive purposes.

#### 2. Animation Mechanism

With the openBlinds function, the two rectangles that overlap at the center expand their width while sliding outward to the left and right.

x: (i) => {
  const b = blinds[Math.floor(i / 2)];
  // Even index: moves left to reveal | Odd index: stays at the center line
  return i % 2 === 0 ? b.x - b.w : b.x; 
},
width: (i) => {
  const b = blinds[Math.floor(i / 2)];
  // Expand width to full size with a 0.05 offset to prevent hairline gaps
  return b.w + 0.05; 
}
Why Math.floor(i / 2) is used

In GSAP’s attr animation, the array is passed in the following order: [b0.left, b0.right, b1.left, b1.right, ...]

This means every two elements represent one set (one blind). By dividing the index i by 2 and rounding down, the code can correctly determine which blind (0th, 1st, etc.) the current rect belongs to. Expansion logic

An SVG rect normally grows to the right from its top-left origin. rectLeft (even index): By moving the x coordinate to b.x - b.w (to the left) while expanding its width, it visually appears to extend toward the left. rectRight (odd index): The x coordinate remains fixed at the center (b.x) while the width expands, naturally extending to the right.

By combining this movement and scaling, the animation creates a motion where a wiper-like shape expands outward from the center line to both sides.

#### 3. Gap prevention and layout robustness

By adding a small value in width: b.w + 0.05, each strip slightly overlaps with its neighbors. This prevents 1px gaps caused by subpixel calculations. Effect of crispEdges r.setAttribute("shape-rendering", "crispEdges"); suppresses anti-aliasing and prevents edge blurring. For designs dominated by straight lines—like blinds—this setting produces extremely sharp edges and reduces visual noise.

#### 4. Aspect-ratio handling logic

In updateLayout, vbWidth is recalculated dynamically according to the screen ratio.

Height reference: vbHeight is fixed at 100

Width calculation: (width / height) * 100

This calculates the relative width when the height is normalized to 100. With this method, the 12 strips remain evenly divided regardless of the window ratio, ensuring the screen is always completely filled in a responsive layout.

#### 5. Continuity through stagger stagger: { each: 0.02, from: "start" } is applied.

Because pairs (left, right) are arranged consecutively in the array, a slight timing offset occurs between each pair. As a result, each strip appears to open sequentially from the left side toward the right.

As scrolling progresses, the image is revealed smoothly from left to right, creating a pleasant visual rhythm.

4. Column Random Grid

The most intricate and dynamic effect, combining directionality and randomness within a grid structure. It delivers a richly layered visual experience, where a wave-like motion sweeps from left to right while irregular panel openings interweave throughout.

#### 1. Coordinate Calculation Logic (Placement)

The base grid generation is the same as in 02. Random Grid, but the structure is designed so that it can precisely track which column each cell belongs to for animation control.

// ------------------
// Grid configuration
// ------------------
const cols = getGridCols(); // Adaptive columns: 14 (PC) / 10 (Tablet) / 6 (SP)
// Calculate rows based on aspect ratio to keep cells as square as possible
const rows = GRID_ROWS || Math.round(cols * (vbHeight / vbWidth));

// ------------------ // Cell dimensions // ------------------ const cellW = vbWidth / cols; const cellH = vbHeight / rows;

// Generate all cells via nested loops and store in a flat array (row-major order) for (let y = 0; y < rows; y++) { for (let x = 0; x < cols; x++) { // Rectangle generation logic... } }

#### Intent behind the row calculation (aspect-ratio preservation logic) const rows = GRID_ROWS || Math.round(cols * (vbHeight / vbWidth));

This expression does more than simply calculate the number of rows automatically. By maintaining the relationship cols : rows ≒ vbWidth : vbHeight, it prevents each cell from becoming excessively tall or wide. In other words, this logic uses the number of columns as a reference, multiplies it by the viewBox aspect ratio, and generates cells that are as close to square as possible. This is a crucial design point for preserving both grid density and visual balance in responsive environments.

#### About the array structure (row-major flat array)

Because the cells are generated in the order for (let y...) { for (let x...) {, the cells array becomes a flat structure organized row by row (row-major order). [ row0-col0, row0-col1, ... row0-colN, row1-col0, row1-col1, ... ]

The later column-extraction logic is built on top of this structure.

#### 2. Animation Mechanism (Column-based control)

The core of this effect lies in the reconstruction of the array inside the openBlinds function. Rather than applying randomness across the entire grid, it reorders the elements while treating each column as a distinct group.

function openBlinds({ cells, rows, cols }) {
  const ordered = [];
  
  // Reconstruct the flat array into columns
  for (let x = 0; x < cols; x++) {
    const column = [];
    for (let y = 0; y < rows; y++) {
      // Extract cell index from the original row-major array
      const index = y * cols + x; 
      column.push(cells[index]);
    }
    
    // Randomize the order of cells WITHIN the specific column
    const shuffledColumn = gsap.utils.shuffle(column);
    // Push the randomized column back into the final ordered sequence
    ordered.push(...shuffledColumn);
  }
  
  // 'ordered' is now: [Column 0 (random), Column 1 (random), ...]
}

The final ordered array ends up with the following structure: “column 0 randomized group, column 1 randomized group, column 2 randomized group, …” Because GSAP’s stagger is applied in this array order, it creates a two-layered behavior: Macro view (overall): progresses like a wave from left to right, following the column order Micro view (detail): within each column, panels appear in a vertically randomized order

This control makes it possible to create a complex, organic rhythm in which the animation moves from left to right as a whole, while the panels within each column appear unpredictably from top to bottom.

#### 3. Robust gap prevention

As in the other demos, shape-rendering="crispEdges" is also used here. In a grid effect where many rectangles are drawn rapidly and irregularly, preventing 1px gaps caused by anti-aliasing is essential for maintaining the clean visual quality of the design.

Wrapping up -----------

The combination of SVG masks and GSAP (ScrollTrigger) elevates scene transitions on a website from a simple function into a compelling form of visual direction that draws users in.

Using the code created here as a foundation, try customizing the number of divisions, the order of appearance, and the easing to create your own original transition effects.

查看原文 → 發佈: 2026-03-11 22:37:52 收錄: 2026-03-12 00:01:10

🤖 問 AI

針對這篇文章提問,AI 會根據文章內容回答。按 Ctrl+Enter 送出。