Dual-Canvas Depth Split
Two stacked canvases render the same instanced particle system with opposite `uZSign`, and the page content is a DOM sibling between them. Particles with negative depth pass behind the text and positive ones pass in front. Because size, parallax rate, brightness and softness are one continuous ramp across the depth axis, the plane where the split happens cannot be seen — the effect is that the copy is embedded in the field, and it stays live, selectable text throughout.
Dual-Canvas Depth Split
Live preview
The real component, running in this page.
Install
Copies the source into your project. Nothing is imported from us at runtime.
npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/dual-canvas-z-split.jsonAlso installs webgl, prng, damp, use-raf, use-in-view, use-visibility, use-resize-observer, use-reduced-motion from this registry.
Prompt
The whole technique, written for an agent.
Build it with your agent
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
Source
10 files, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | Content rendered between the two particle layers. Ordinary DOM, fully interactive. |
| density | number | = 850 | Particles per megapixel of container area. The count is derived from this, not fixed. |
| maxCount | number | = 1600 | Population ceiling, and the size the shared buffers are allocated at. |
| split | number | = 0.38 | Fraction of the population placed in front of the content. |
| sizeRange | readonly [number, number] | = [3, 34] | Disc diameter in CSS pixels at the far and near ends of the depth ramp. |
| speed | number | = 1 | Multiplier on the whole drift, including the upward march. |
| wander | number | = 0.05 | Sinusoidal wander amplitude in field units, where 1 is the container. |
| opacity | number | = 0.6 | Overall alpha multiplier. The front layer takes 0.55 of it. |
| colorA | string | = '#8ea6c4' | First tint. Any value ogl's Color accepts. |
| colorB | string | = '#e6d9c4' | Second tint; each particle mixes between the two by a seeded amount. |
| background | string | = '#06080c' | Opaque clear colour of the back canvas. The front canvas is transparent. |
| seed | number | string | = 'dual-canvas-z-split' | Seeds the scatter. Same seed, same field on every reload and on the server. |
| className | string | — | Merged onto the host element via cn. |
How it works
The technique in order, not the code in order.
aDepthis the camera axis, not a distance: -1 is far behind the content plane, +1 is close to the viewer, 0 is the content plane itself. The SIGN chooses which canvas draws a particle; the magnitude drives everything else, which is why the two layers look like one field.Both layers share the same typed arrays, so the two contexts hold identical particles rather than two independent fields. Only the attribute descriptor objects are per-layer, because ogl writes the buffer handle and element count into the object it is given.
A particle on the wrong side of the plane is rejected in the VERTEX shader by writing
gl_Position = vec4(2.0, 2.0, 2.0, 1.0)— outside the frustum on every axis, so all four vertices are clipped before rasterisation and it costs no fragments. The vertex cost doubles across the two layers; the fragment cost, which is the only cost that mattered, does not.Near particles are bigger (
mix(minSize, maxSize, depth01)), drift further per second (0.35 + 0.65 * depth01), are brighter, and have a softer edge — a defocus that stops the front layer reading as dirt on the screen. Four cues from one number.The back canvas is opaque and blends additively (
ONE, ONE), which is order independent so nothing needs sorting. The front canvas sits over live text and must be transparent, which rules additive out — there is nothing on the GPU to add to — so it blends premultiplied source-over (ONE, ONE_MINUS_SRC_ALPHA). One fragment shader emits premultiplied colour and serves both.The population is derived from container area —
clamp(round(megapixels * density), 250, maxCount)— and applied withsetInstancedCounton both layers. Buffers are allocated once atmaxCount, so a resize never reallocates.Drift is a pure function of
elapsedand the per-particle seed, withfractwrapping the field vertically anduFadeBandfading alpha to zero at both ends so the wrap seam is never visible. No per-particle state, so a still frame is drawable from one number.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| split | = 0.38 | Fraction of the population placed in front of the content. Under a half on purpose: dust in front of body copy is a legibility cost, and at 0.5 the text reads as fogged rather than embedded. |
| FRONT_OPACITY | = 0.55 | Extra alpha scale on the front layer only. Depth already brightens near particles, and the front layer is entirely near particles, so without this the layer that overlaps the text is the brightest thing on screen. |
| DEPTH_FLOOR | = 0.05 | Dead zone around the content plane. A particle at exactly 0 is rejected by both layers — aDepth * uZSign <= 0.0 is false for neither sign — so it would silently never render. |
| sizeRange | = [3, 34] | Disc diameter at the far and near ends of the ramp. The span is what sells the depth; at 8-to-14 the field is flat, and above ~40 the near particles are large enough that their own softness becomes the dominant texture of the page. |
| parallax factor | = 0.35 + 0.65 * depth01 | Near particles travel about three times as far per second as far ones. A larger ratio makes the far layer look frozen; a smaller one and the field moves as a single sheet, which removes the only depth cue that survives in a static screenshot. |
| MAX_DPR | = 1.5 | Two layers of soft additive discs is a fill-rate problem and fill scales with the square of the pixel ratio. Dust has no detail a third sample per axis would resolve, so a 3x phone renders at 1.5x and pays a quarter of the fragments. |
| FADE_BAND | = 0.14 | Fraction of the field height the wrap fade occupies at each end. Larger than a single-layer field needs, because the near particles are up to 34px across and their radius alone is a few percent of a short container. |
| size jitter | = 0.7 .. 1.3 | Multiplier on top of the depth ramp. Without it the depth ordering is perfectly readable from size alone, which reads as a diagram of a depth field rather than a depth field. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
The rAF subscription is never made and both layers are drawn once at elapsed 0. That is a complete frame, not a collapsed one: the scatter, depths, sizes, tints and wrap fade are all per-particle seeds that already vary at t=0, so the still shows the same depth split, in front of and behind the same text, with only the drift absent. Resizing redraws the same still in both layers.
Per layer: geometry.remove() deletes the VAOs and every buffer, program.remove() deletes the shaders and the program, loseContext(gl) hands the context back through WEBGL_lose_context — ogl has no dispose() and nothing else releases a context — then the canvas backing store is zeroed and the node removed. Both layers are torn down in the same cleanup, so the component never holds one context after the other has gone.
Particles visibly pass both behind and in front of the text, and the text is still selectable with the mouse while they do. There is no horizontal line anywhere in the container where the field changes character. Near particles are larger, softer and move further per second than far ones. Links inside the content still respond to hover and click. Resize narrow and wide: discs stay circular and the population changes without a stutter. Two contexts per instance, both released on unmount — mount and unmount six instances and none of the earlier ones go black.
Pitfalls
- Trying to do this with one canvas. A canvas is a single stacking-context leaf, so DOM content cannot be interleaved with parts of it. Two contexts is not an inefficiency here, it is the requirement — and it is two of the roughly sixteen a Chromium renderer process allows, which is exactly why both are released on unmount.
- Making the front canvas additive. With
alpha: truethe destination is zero, so an additive particle over text is a flat colour patch: the layer stops looking like light and starts looking like a sticker. - Forgetting
pointer-events: noneon the front layer. It covers the content by construction, so every link and button underneath it stops responding, and nothing about the failure looks graphical. - Letting a particle sit at depth exactly 0.
aDepth * uZSign <= 0.0rejects it in BOTH layers, so it is uploaded, transformed and never seen — a silent leak of the population. - Rejecting with
discardin the fragment shader instead of parking the vertex.discardstill rasterises the quad and runs the shader per fragment; a clip-space position outside the frustum throws the primitive away after four vertex invocations. - Writing
gl_Position = vec4(2.0)for the rejection. w is then 2, the perspective divide brings it back to exactly 1.0 on every axis, and the vertex survives clipping at the frustum boundary — a row of specks in the corner. - Giving both layers the same attribute descriptor objects. ogl mutates them with a buffer handle belonging to the first context, and the second layer then renders from a buffer its context has never heard of.
- Assuming
renderer.setSizeleaves the CSS alone. ogl writes literal px into the canvas inline style, which beats any class, so the percentages must be re-asserted after every call in both layers.
Notes
- The content is a DOM sibling, not a texture. It stays selectable, focusable and available to a screen reader while particles pass on both sides of it — which is the entire reason to accept two contexts instead of drawing the text into the field.
- ogl rather than three: two contexts, two instanced meshes, no lights, no cameras and no scene graph.
renderer.renderis called without a camera, so ogl skips frustum culling and the matrix uniforms — the shader writes clip space itself. depth: falseon both contexts. The depth ORDER is expressed by which canvas a particle lands on, so a z-buffer would be memory plus a clear per frame for no result.- Sizes are authored in CSS pixels and scaled by the pixel ratio in the shader, so the field is identical across a dpr change and nothing rebuilds when one happens.
- GLSL ES 1.00 with no derivative functions — the anti-aliasing floor is
1.0 / sizePx, exact for a quad of that pixel size — so the same source compiles under a WebGL1 fallback context.