Guides
Custom Shaders

Custom Shaders

Every BOX mesh uses one ShaderMaterial generated by the Painter package. You can splice your own GLSL into three points of its fragment shader without replacing the material.

The ShaderHooks object

interface ShaderHooks {
  uniforms?: Record<string, any>;
  uvModifier?: string;
  colorModifier?: string;
}

Declared on the element as strict JSON (double quotes required):

<div
  data-mirage-shader='{
    "uniforms": { "uTime": 0, "uAmount": 0.05 },
    "uvModifier": "resultUv.x += sin(resultUv.y * 10.0 + uTime) * uAmount;",
    "colorModifier": "finalColor.rgb = mix(finalColor.rgb, vec3(1.0, 0.2, 0.4), 0.3);"
  }'
></div>

Where each hook lands

uniform vec2 uSize;
uniform vec4 uBgColor;
// …
#INJECT_DECLARATIONS        // ← your uniform declarations
 
void main() {
  vec2 p = (vUv - 0.5) * uMeshSize;
  // border-radius clamping…
 
  #INJECT_UV_MODIFIER       // ← uvModifier
 
  vec4 baseColor = vec4(uBgColor.rgb, uBgColor.a);
  // gradient layer…
 
  #INJECT_BASE_COLOR        // ← texture sampling
 
  // SDF box, border, shadow…
 
  #INJECT_COLOR_MODIFIER    // ← colorModifier
 
  float finalOpacity = finalColor.a * uOpacity;
  if (finalOpacity < 0.001) discard;
  gl_FragColor = vec4(finalColor.rgb, finalOpacity);
}

uvModifier

Runs before the texture is sampled. Mutate resultUv.

In scopeTypeMeaning
resultUvvec2The UV used for sampling — write to this
screenUvvec2Normalized screen position of this fragment
localUvvec2Element-local UV (non-traveler only)
pvec2Fragment position in pixels, centered on the mesh
vUvvec2Raw quad UV, 0…1
uSize, uMeshSizevec2Element size, mesh size (mesh includes shadow padding)

For a traveler, resultUv is initialized from screenUv. For a normal element it comes from localUv scaled by uTextureRepeat / uTextureOffset, which is how object-fit: cover behaviour is preserved.

colorModifier

Runs after the box, border and shadow are composited. Mutate finalColor.

In scopeTypeMeaning
finalColorvec4Composited RGBA — write to this
p, vUv, resultUvvec2Same as above
your uniforms—Declared in uniforms
⚠️

Do not assign to gl_FragColor here — it is written after your hook runs, from finalColor. Writing gl_FragColor directly is silently discarded.

Uniform types

Types are inferred from the JSON value:

JSON valueGLSL type
0.5float
[0.5, 1.0]vec2
[1, 0, 0]vec3
[1, 0, 0, 1]vec4
{ "type": "sampler2D", "value": null }that explicit type
<div data-mirage-shader='{
  "uniforms": {
    "uTime": 0,
    "uMouse": [0.5, 0.5],
    "uTint": [1.0, 0.4, 0.8]
  },
  "colorModifier": "finalColor.rgb *= uTint;"
}'></div>

Driving uniforms per frame

Never rewrite the attribute to animate — changing it changes the shader hash, which makes Mirage dispose and rebuild the mesh. Use updateUniforms():

const el = document.querySelector("#hero") as HTMLElement;
const start = performance.now();
 
mirage.getTracker().onRender.add(() => {
  mirage.updateUniforms(el, {
    uTime: (performance.now() - start) / 1000,
  });
});
 
window.addEventListener("pointermove", (e) => {
  mirage.updateUniforms(el, {
    uMouse: [e.clientX / window.innerWidth, 1 - e.clientY / window.innerHeight],
  });
});

Recipes

Scanlines

{
  "uniforms": { "uDensity": 400 },
  "colorModifier": "finalColor.rgb *= 0.85 + 0.15 * step(0.5, fract(vUv.y * uDensity));"
}

Animated gradient tint

{
  "uniforms": { "uTime": 0 },
  "colorModifier": "finalColor.rgb = mix(finalColor.rgb, vec3(0.4 + 0.6 * sin(uTime), 0.3, 0.9), 0.35);"
}

Barrel distortion on an image

{
  "uniforms": { "uStrength": 0.15 },
  "uvModifier": "vec2 c = resultUv - 0.5; resultUv = 0.5 + c * (1.0 + uStrength * dot(c, c));"
}

Dissolve

{
  "uniforms": { "uProgress": 0 },
  "colorModifier": "float n = fract(sin(dot(vUv, vec2(12.9898, 78.233))) * 43758.5453); if (n > uProgress) finalColor.a = 0.0;"
}

Gotchas

IssueCauseFix
Shader silently ignoredAttribute is not strict JSONUse double quotes for every key and string
Mesh flickers or resetsAttribute rewritten each frameUse updateUniforms() instead
uTexture is undefinedElement has no image and no hooksThe texture chunk is only injected when a texture or hooks exist
Effect looks offsetConfusing vUv with resultUvvUv is quad-local, resultUv is what samples the texture

Debugging

Read the compiled shader off the material. This reaches through private fields, so treat it as a debugging trick rather than API:

// after start() — internal access, may break between versions
const registry = (mirage as any)._engine.registry;
const mesh = registry.get(el);
console.log(mesh.material.fragmentShader);

WebGL compile errors appear in the browser console with a line number that maps into this generated source.


Mirage Engine — MIT Licensed © 2026 dltldn333