How to create organic, painterly transitions for images and buttons using mask-based thresholding.
Â
Intro
In modern UI design, the standard “
fade-in” or “slide ” can sometimes feel a bit clinical. Creative developers are increasingly turning to Organic Transitions effects that mimic real-world textures like ink, water or paint. One of the most visually striking examples is the Brush-Stroke Reveal.Â
Whether you’re building a high-end landing page or a stylized game UI, understanding the math behind this shader effect allows you to transform static assets into dynamic, tactile experiences.

The Core Concept: Thresholding
The secret isn’t in complex physics simulations; it’s in a technique called Thresholding. To pull this off, you need two assets:
The Source: Your image, button or texture.
The Mask: A graysclae image containing a hand-painted brush stroke.
The shader looks at the “brightness” (luminance) of each pixel in the mask. As we increase a
progress uniform from 0.0 to 1.0, the shader performs a comparison. If the pixel’s value in the mask is lower than our current progress, we show the image. If it’s higher, we keep it hidden.The GLSL Implementation
Static Code Snippets
Below is a production-ready Fragment Shader. This code can be used in WebGL, Three.JS or Unity Shader Graph (as a custom function)
precision mediump float; varying vec2 v_texCoord; uniform sampler2D u_texture; uniform sampler2D u_mask; uniform float u_progress; uniform float u_smoothness; void main() { // 1. Sample both the source image and the brush mask vec4 color = texture2D(u_texture, v_texCoord); vec4 mask = texture2D(u_mask, v_texCoord); // 2. Extract the intensity of the brush stroke (usually the Red channel) float maskVal = mask.r; // 3. The Logic: Smoothstep creates a soft transition edge // Based on u_progress, we determine the visibility float visibility = smoothstep(u_progress, u_progress + u_smoothness,maskVal); // 4. Output the final color with adjusted alpha gl_FragColor = vec4(color.rgb, color.a * (1.0 - visibility)); }
Breaking Down the Math
The heavy lifting is done by the smoothstep function. Unlike a simple if statement,
smoothstep creates a gradient transition between two values. This prevents the
"stairstepping" or pixelation often seen in low-resolution masks.
Â
By adjusting the u_smoothness uniform, you can control the
"wetness" of the brush. A high smoothness value makes it look like a soft
watercolor bleed, while a value of 0.001 makes it look like a sharp ink stroke.
Â
Why This Works for UX
Psychologically, organic reveals feel more “human”. “When a button reveals itself with a brush stroke, it signals to the user that the interface is responsive and crafted. it adds a layer of polish that distinguishes a generic template from a bespoke digital product.
Â
Implementation The Code to my Portfoliomumeyong.dev
- The Progressive Discard (Reveal Effect) Used for balloons, award cards, and doors. It progressively "eats away" at a sketch texture to reveal whatever is behind it (usually a pre-rendered "painted" version).
// uniform uniform float uProgress; // value from 0.0 to 1.0 // simple 2d noise funtions float revealRand(vec2 n) { return fract(sin(dot(n, vec2(12.9898, 4.1414))) * 43758.5453); } float revealNoise(vec2 p){ vec2 ip = floor(p); vec2 u = fract(p); u = u*u*(3.0-2.0*u); float res = mix ( mix(revealRand(ip), revealRand(ip+vec2(1.0,0.0)),u.x), mix(revealRand(ip+vec2(0.0,1.0)),revealRand(ip+vec2(1.0,1.0)),u.x),u.y); return res*res; // fragment shader logic (injected at alphatest_fragment) void main() { // ... standard texture smpling ... if (uProgress > 0.001) { // generate noise based on UVs float rn = revealNoise(vMapUv * 15.0) * 0.15; // create a mask value that increases from bottom to top (1.0 - vMapUv.y) // adding noise 'rn' gives it the "rough" brush-stroke edge float maskValue = (1.0 - vMapUv.y) + rn; // the threshold is scaled by 1.5 to ensure full reveal at uProgress = 1.0 float threshold = uProgress * 1.5; if (maskValue < threshold) discard; } }
- The Texture Blend (Paint Effect) Used when a single mesh needs to transition smoothly between a "sketch" texture and a "painted" texture without needing two separate meshes.
// Fragment Shader Logic (Injected at map_fragment) if (uProgress > 0.001) { vec4 paintedColor = texture2D(uMapPainted, vMapUv); float rn = paintNoise(vMapUv * 15.0) * 0.15; // Reveal from bottom to top with noisy edge float maskValue = (1.0 - vMapUv.y) + rn; float threshold = uProgress * 1.5; if (maskValue < threshold) { // Replace the current diffuse color with the painted version diffuseColor = vec4(paintedColor.rgb, 1.0); } }
Conclusion
- Coordinate Space: The reveal is calculated in UV space (vMapUv.y), which makes it look like it's being painted from the bottom of the image upwards.
- Noise Scale: A multiplier of 15.0 on the UVs determines the "granularity" of the brush strokes.
- Performance: It uses a lightweight 2D noise function instead of complex Perlin/Simplex noise to ensure it runs smoothly even on mobile devices.
Follow me on twitter for more awesome stuff like this @mumeyongdev
