OB.log
← Back to blogOB.log
← Back to blogA 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.
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.
---
Before touching any code, keep this picture in mind:
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 holesuseMask — tells a material to only render inside those holesThat’s the whole trick.
---
Let’s start with the smallest piece: one circle that writes into the mask.
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.circleGeometry with a radius and some segments (64 is just for smoothness).At this point we have a way to “punch one circular hole” into the stencil.
---
Next step: create many dots and update their radius together.
We’ll build a CircleGridMask component whose only job is:
progress value between 0 and 1import { 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:
progress changes. It could come from scroll, a button, or a timeline.radius, based on progress.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.
---
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.
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....stencil onto the material.Now we can combine the pieces in a small wrapper component:
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:
---
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)2)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:
<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.
---
This article does not go deep into scroll libraries, timelines, or easing. The masking system only needs one simple thing:
A number between0and1that tells you how “open” the mask should be.
You can:
progress with a button clickAs long as you pass that progress down into CircleGridMask, the dot mask will respond.
---
Here are a few practical things that matter when you implement this:
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> ```
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.
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.
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.
---
To build the scroll‑reveal dot mask, we only need a few simple ideas:
<Mask> to draw circular holes (the dots).useMask(maskId) on your materials so they only render inside those holes.progress value.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.
Technologies covered