OB.log

← Back to blog

OB.log

← Back to blog
[ INDEX ]

9 sections

Share

[ SHARE ]
Feb 25, 2026•8 min read•Frontend

How the Scroll-Reveal Dot Mask Works

A clear, step‑by‑step explanation of the dot mask effect: how circles, masks, and the stencil buffer work together to reveal content as you scroll.

View live demo↗View source code</>
reactthreejswebglanimation

What this article is about

This article is only about the dot mask technique itself — how the circles are drawn, how they act as a mask, and how they reveal whatever is behind them.

In my case, the dots reveal full‑screen watch photos, but the same technique works for any kind of content.

We’ll focus on this question:

How do I make a grid of circles that grow and reveal some content underneath (like watch images), using React Three Fiber and @react-three/drei?

If you’re a junior developer, you should be able to follow this step by step.

---

The mental model: paper, holes, and paint

Before touching any code, keep this picture in mind:

  • Imagine you have a black sheet of paper.
  • You punch out a bunch of circular holes in it.
  • Behind that paper you place a photo (or any content).
  • When you look from the front, you only see the photo through the holes.

In WebGL, the stencil buffer plays the role of that sheet of paper. We “punch holes” into it with our circles, and then we tell WebGL:

Only draw my content in the pixels where the stencil has been punched.

The nice thing is: @react-three/drei gives us two helpers that do the heavy lifting:

  • <Mask> — draws the holes
  • useMask — tells a material to only render inside those holes

That’s the whole trick.

---

Building a dot that writes into the mask

Let’s start with the smallest piece: one circle that writes into the mask.

tsx
import { Mask } from '@react-three/drei';

function MaskDot({ radius, position, maskId }: {
  radius: number;
  position: [number, number, number];
  maskId: number;
}) {
  // If the radius is extremely small, skip rendering for performance
  if (radius <= 0.001) return null;

  return (
    <Mask id={maskId} position={position}>
      <circleGeometry args={[radius, 64]} />
      <meshBasicMaterial />
    </Mask>
  );
}

Key points:

  • Mask component: this does not show watch image by itself. It writes into the stencil buffer (the “holes” the image will later show through).
  • id: circles that share the same maskId belong to the same mask group.
  • Geometry: we use a simple circleGeometry with a radius and some segments (64 is just for smoothness).
  • Very small radius: if the radius is basically zero, we skip rendering that dot.

At this point we have a way to “punch one circular hole” into the stencil.

---

Turning one dot into a grid of dots

Next step: create many dots and update their radius together.

We’ll build a CircleGridMask component whose only job is:

  • Place dots in a grid
  • Grow or shrink them based on a progress value between 0 and 1
tsx
import { useMemo } from 'react';
import { useThree } from '@react-three/fiber';
import MaskDot from './MaskDot';

function linearMap(val: number, toA: number, toB: number) {
  return ((val - 0) * (toB - toA)) / (1 - 0) + toA;
}

interface CircleGridMaskProps {
  progress: number;   // 0 → 1
  maskId: number;
  maskZ: number;
}

export default function CircleGridMask({ progress, maskId, maskZ }: CircleGridMaskProps) {
  const { viewport, size } = useThree((state) => state);

  const isMd = size.width >= 768;
  const cols = isMd
    ? Math.max(1, Math.round(viewport.width / 1.8)) + 1
    : Math.max(1, Math.round(viewport.height / 1.3));

  const rows = 4;
  const spacing = (isMd ? viewport.width : viewport.height) / cols;

  const anchorX = !isMd ? -viewport.width / 2 : -viewport.width / 2;
  const anchorY = !isMd ? -viewport.height / 2 : -viewport.height / 2;

  // You can choose any max radius that looks good for your scene
  const maxRadius = 3;

  const circles = useMemo(() => {
    const result = [];
    for (let x = 0; x < cols; x++) {
      for (let y = 0; y < rows; y++) {
        const cx = x * spacing;
        const cy = y * spacing;
        result.push(
          <MaskDot
            key={`${cx}-${cy}`}
            position={[cx, cy, maskZ]}
            radius={linearMap(progress, 0, maxRadius)}
            maskId={maskId}
          />
        );
      }
    }
    return result;
  }, [progress, maskZ, maskId, cols, rows, spacing, maxRadius]);

  return (
    <group position={[anchorX, anchorY, 0]}>
      {circles}
    </group>
  );
}

Important ideas here:

  • We don’t care how progress changes. It could come from scroll, a button, or a timeline.
  • All dots share the same radius, based on progress.
  • All dots share the same maskId, so together they form one big mask made of many circles.

When progress is 0, all radii are 0, so nothing is revealed. When progress is 1, the radius is at maxRadius, and the whole grid of circles is fully open.

---

Masking the content behind the dots

So far we only wrote into the stencil (the “holes”). Now we need to tell our content (for example, a watch photo) to respect that stencil.

For that we use the useMask hook from @react-three/drei. It returns some material props that hook into the same stencil buffer.

tsx
import { useThree } from '@react-three/fiber';
import { useMask, useTexture } from '@react-three/drei';
import { useMemo } from 'react';
import { PlaneGeometry } from 'three';

function StencilledPanel({ maskId, image, zPos }: {
  maskId: number;
  image: string; // e.g. '/watch1.jpg'
  zPos: number;
}) {
  const stencil = useMask(maskId);
  const texture = useTexture(image);
  const { viewport } = useThree((state) => state);

  const geometry = useMemo(
    () => new PlaneGeometry(viewport.width, viewport.height),
    [viewport.width, viewport.height]
  );

  return (
    <mesh geometry={geometry} position={[0, 0, zPos]}>
      <meshBasicMaterial map={texture} {...stencil} />
    </mesh>
  );
}

What matters here:

  • useMask(maskId) must receive the same maskId used by the dots.
  • We spread ...stencil onto the material.

Now we can combine the pieces in a small wrapper component:

tsx
function MaskedLayer({ maskId, progress, image, zPos }: {
  maskId: number;
  progress: number;
  image: string;
  zPos: number;
}) {
  return (
    <group>
      <CircleGridMask progress={progress} maskId={maskId} maskZ={zPos + 0.2} />
      <StencilledPanel maskId={maskId} image={image} zPos={zPos} />
    </group>
  );
}

This group:

  • Draws the dots into the stencil buffer.
  • Draws the watch image (or any content), but only inside the dots.

---

Multiple masks on top of each other

One powerful thing about this approach is that you can have multiple masked layers stacked in depth.

For example, imagine you have 3 layers, each showing a different watch photo:

  1. Watch photo A (mask id 1)
  2. Watch photo B (mask id 2)
  3. Watch photo C (mask id 3)

Each one can use its own CircleGridMask and StencilledPanel with a different maskId. As long as you use a unique ID for each layer, the masks do not interfere with each other.

Conceptually:

tsx
<MaskedLayer maskId={1} progress={progressA} image="/watch1.jpg" zPos={0.0} />
<MaskedLayer maskId={2} progress={progressB} image="/watch2.jpg" zPos={0.1} />
<MaskedLayer maskId={3} progress={progressC} image="/watch3.jpg" zPos={0.2} />

Each MaskedLayer has its own set of circles and its own panel, but everything shares the same canvas and camera.

---

Where the progress comes from (high level)

This article does not go deep into scroll libraries, timelines, or easing. The masking system only needs one simple thing:

A number between 0 and 1 that tells you how “open” the mask should be.

You can:

  • Animate progress with a button click
  • Tie it to a timeline
  • Connect it to scroll with whatever library you like

As long as you pass that progress down into CircleGridMask, the dot mask will respond.

---

Important details and gotchas

Here are a few practical things that matter when you implement this:

  • Enable the stencil buffer on the canvas

In React Three Fiber, you need to turn it on in the Canvas component:

```tsx <Canvas gl={{ stencil: true, antialias: true }} / ...other props /> {/ your scene here /} </Canvas> ```

  • Order of rendering

The Mask components need to run before the materials that use useMask(maskId). Grouping them like we did (CircleGridMask before StencilledPanel in the same component) makes this straightforward.

  • Very small radii

When the radius is almost zero, you can safely skip rendering that dot. It saves work and avoids rendering lots of tiny meshes that the user cannot see anyway.

  • One mask ID per layer

If you stack multiple masked layers, give each one a unique maskId. Reusing the same ID will mix all the circles together and they will all cut into the same content.

---

Recap

To build the scroll‑reveal dot mask, we only need a few simple ideas:

  • Treat the stencil buffer like a sheet of paper with holes.
  • Use <Mask> to draw circular holes (the dots).
  • Use useMask(maskId) on your materials so they only render inside those holes.
  • Group many dots into a grid and control their radius with a progress value.
  • Optionally stack several masked layers, each with its own maskId.

Everything else — what content you reveal, how you animate progress, how you style the page — is up to you.

If you understand these pieces, you can plug the dot mask into any project and reveal whatever you like behind it.

Written by

Beaj Ousama

Beaj Ousama

Software Engineer

Fullstack engineer building fast, polished web products.

Technologies covered

ReactjsThreejs
[ Back to Blog ]