Skip to content
Skip to Content
DocsInteractive Blur Reveal
Interactive Blur Reveal

Interactive Blur Reveal

A frosted image becomes clear beneath a fluid cursor trail, with noise distortion and subtle grain.

Move over the image to reveal its detail.

Component preview

Install using CLI

npx shadcn@latest add "https://www.obsidianui.dev/r/interactive-blur-reveal.json"

Usage

Code
"use client"; import { InteractiveBlurReveal } from "@/components/block/interactive-blur-reveal"; export default function Demo() { return <InteractiveBlurReveal className="h-[400px] w-full rounded-lg" />; }

Install Manually

1

Install dependencies

npm install clsx tailwind-merge
2

Copy the source code

Copy into components/block/interactive-blur-reveal.jsx

"use client"; import { useEffect, useRef } from "react"; import { cn } from "@/lib/utils"; const MAX_TRAIL_POINTS = 240; const FULLSCREEN_TRIANGLE_VERTICES = new Float32Array([ -1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1, ]); const TEXTURE_UNIT_BASE = 0; const TEXTURE_UNIT_NOISE = 1; const DEFAULT_POINTER_POSITION = 0.5; const DEFAULT_FRAME_TIME_MS = 16.67; const MAX_FRAME_DELTA_MS = 64; const POINTER_LERP_FACTOR = 0.001; const POINTER_LEAVE_DURATION_MS = 180; const TRAIL_LIFETIME_MS = 650; const MIN_POINTER_DISTANCE_INSIDE = 0.0022; const MIN_POINTER_DISTANCE_LEAVING = 0.0013; const BLUR_REVEAL_VERT = /* glsl */ `#version 300 es in vec2 position; void main() { gl_Position = vec4(position, 0.0, 1.0); } `; const BLUR_REVEAL_FRAG = /* glsl */ `#version 300 es precision highp float; #define MAX_TRAIL_POINTS 240 uniform vec2 iResolution; uniform float iTime; uniform vec2 iTrail[MAX_TRAIL_POINTS]; uniform float iTrailAlpha[MAX_TRAIL_POINTS]; uniform int iTrailCount; uniform sampler2D iChannel0; uniform sampler2D iChannel1; out vec4 fragColor; vec2 distortUv(vec2 uv) { vec2 noiseUv = uv * 2.2; vec2 noiseOffset = texture(iChannel1, noiseUv).xy - 0.5; return uv + noiseOffset * 0.012; } vec4 blur21(sampler2D tex, vec2 uv, float radiusPx) { vec2 px = radiusPx / iResolution; vec4 color = vec4(0.0); color += texture(tex, uv) * 0.12; color += texture(tex, uv + px * vec2(1.0, 0.0)) * 0.08; color += texture(tex, uv + px * vec2(-1.0, 0.0)) * 0.08; color += texture(tex, uv + px * vec2(0.0, 1.0)) * 0.08; color += texture(tex, uv + px * vec2(0.0, -1.0)) * 0.08; color += texture(tex, uv + px * vec2(1.0, 1.0)) * 0.065; color += texture(tex, uv + px * vec2(-1.0, 1.0)) * 0.065; color += texture(tex, uv + px * vec2(1.0, -1.0)) * 0.065; color += texture(tex, uv + px * vec2(-1.0, -1.0)) * 0.065; color += texture(tex, uv + px * vec2(2.0, 0.0)) * 0.045; color += texture(tex, uv + px * vec2(-2.0, 0.0)) * 0.045; color += texture(tex, uv + px * vec2(0.0, 2.0)) * 0.045; color += texture(tex, uv + px * vec2(0.0, -2.0)) * 0.045; color += texture(tex, uv + px * vec2(3.0, 1.0)) * 0.025; color += texture(tex, uv + px * vec2(-3.0, 1.0)) * 0.025; color += texture(tex, uv + px * vec2(3.0, -1.0)) * 0.025; color += texture(tex, uv + px * vec2(-3.0, -1.0)) * 0.025; return color; } float sdSegment(vec2 point, vec2 start, vec2 end) { vec2 pointDelta = point - start; vec2 segmentDelta = end - start; float segmentProjection = clamp( dot(pointDelta, segmentDelta) / max(dot(segmentDelta, segmentDelta), 0.00001), 0.0, 1.0 ); return length(pointDelta - segmentDelta * segmentProjection); } float fluidTrailRevealMask(vec2 uv) { float aspect = iResolution.x / iResolution.y; vec2 point = vec2(uv.x * aspect, uv.y); float mask = 0.0; for (int i = 0; i < MAX_TRAIL_POINTS - 1; i++) { if (i >= iTrailCount - 1) { break; } vec2 start = vec2(iTrail[i].x * aspect, iTrail[i].y); vec2 end = vec2(iTrail[i + 1].x * aspect, iTrail[i + 1].y); float alpha = min(iTrailAlpha[i], iTrailAlpha[i + 1]); float distance = sdSegment(point, start, end); float noiseA = texture(iChannel1, uv * 4.0 + float(i) * 0.018).r; float noiseB = texture(iChannel1, uv * 10.0 + vec2(noiseA * 0.4, float(i) * 0.01)).r; float noiseC = texture(iChannel1, uv * 24.0 - float(i) * 0.006).r; float fluidNoise = noiseA * 0.45 + noiseB * 0.35 + noiseC * 0.20; float radius = 0.095 + (fluidNoise - 0.5) * 0.055; float softness = 0.09; float localMask = 1.0 - smoothstep(radius, radius + softness, distance); mask = max(mask, localMask * alpha); } float cloudNoise = texture(iChannel1, uv * 7.0).r; float fineNoise = texture(iChannel1, uv * 22.0).r; mask *= smoothstep(0.12, 0.95, mask + cloudNoise * 0.25 + fineNoise * 0.12); return clamp(mask, 0.0, 1.0); } vec3 filmGrain(vec2 uv) { // Layered grain keeps the frosted area from feeling digitally flat. vec2 coarseUv = uv * (iResolution.xy / 260.0) + vec2(iTime * 0.035, -iTime * 0.028); vec2 fineUv = uv * (iResolution.xy / 120.0) + vec2(-iTime * 0.055, iTime * 0.041); vec3 coarse = texture(iChannel1, coarseUv).rgb - 0.5; float fine = texture(iChannel1, fineUv).r - 0.5; vec3 chroma = vec3(coarse.r, coarse.g * 0.9, coarse.b * 1.1); return chroma * 0.95 + fine * 0.65; } void main() { vec2 screenUv = gl_FragCoord.xy / iResolution.xy; screenUv.y = 1.0 - screenUv.y; vec2 imageUv = screenUv; vec4 frostedImage = blur21(iChannel0, distortUv(imageUv), 42.0); vec4 clearImage = texture(iChannel0, imageUv); float revealMask = fluidTrailRevealMask(screenUv); float grain = texture(iChannel1, screenUv * iResolution.xy / 180.0).r; frostedImage.rgb = mix(frostedImage.rgb, vec3(0.70, 0.76, 0.78), 0.18); frostedImage.rgb += (grain - 0.5) * 0.045; frostedImage.rgb *= 0.96; vec4 mixed = mix(frostedImage, clearImage, revealMask); // Grain stays stronger in the frosted region so the reveal feels tactile. float grainAmount = mix(0.24, 0.12, revealMask); mixed.rgb += filmGrain(screenUv) * grainAmount; // A soft vignette keeps the edges from competing with the reveal path. vec2 vignetteDelta = screenUv - 0.5; float vignette = smoothstep(0.85, 0.25, dot(vignetteDelta, vignetteDelta) * 1.35); mixed.rgb *= mix(0.96, 1.0, vignette); fragColor = vec4(clamp(mixed.rgb, 0.0, 1.0), 1.0); } `; function createShader(gl, type, source) { const shader = gl.createShader(type); if (!shader) throw new Error("Shader allocation failed."); gl.shaderSource(shader, source); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { gl.deleteShader(shader); throw new Error("Shader compilation failed."); } return shader; } function createProgram(gl, vertexSource, fragmentSource) { let vertexShader; let fragmentShader; let program; try { vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexSource); fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentSource); program = gl.createProgram(); if (!program) throw new Error("Program allocation failed."); gl.attachShader(program, vertexShader); gl.attachShader(program, fragmentShader); gl.linkProgram(program); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { throw new Error("Program linking failed."); } return program; } catch (error) { if (program) gl.deleteProgram(program); throw error; } finally { if (vertexShader) gl.deleteShader(vertexShader); if (fragmentShader) gl.deleteShader(fragmentShader); } } function loadImage(source, signal) { return new Promise((resolve, reject) => { const image = source instanceof HTMLImageElement ? source : new Image(); const removeListeners = () => { image.removeEventListener("load", onLoad); image.removeEventListener("error", onError); signal.removeEventListener("abort", onAbort); }; const onLoad = () => { removeListeners(); resolve(image); }; const onError = () => { removeListeners(); reject(new Error("Image unavailable.")); }; const onAbort = () => { removeListeners(); reject(new Error("Image loading cancelled.")); }; if (signal.aborted) { onAbort(); return; } image.addEventListener("load", onLoad); image.addEventListener("error", onError); signal.addEventListener("abort", onAbort, { once: true }); if (!(source instanceof HTMLImageElement)) { image.crossOrigin = "anonymous"; image.src = source; } if (image.complete && image.naturalWidth > 0) onLoad(); }); } function createTexture(gl, image, unit, shouldRepeat = false) { const texture = gl.createTexture(); if (!texture) throw new Error("Texture allocation failed."); try { gl.activeTexture(gl.TEXTURE0 + unit); gl.bindTexture(gl.TEXTURE_2D, texture); gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, shouldRepeat ? gl.REPEAT : gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, shouldRepeat ? gl.REPEAT : gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); return texture; } catch (error) { gl.deleteTexture(texture); throw error; } } /** * @param {{ imageSrc?: string, noiseSrc?: string, iChannel0?: string | HTMLImageElement, iChannel1?: string | HTMLImageElement, className?: string, style?: import("react").CSSProperties, paused?: boolean, alt?: string }} props */ export function InteractiveBlurReveal({ imageSrc = "/cdn/effects/interactive-blur-reveal/interactive-blur-reveal-img01.webp?v=3", noiseSrc = "/cdn/effects/interactive-blur-reveal/interactive-blur-reveal-noise.png?v=3", iChannel0 = imageSrc, iChannel1 = noiseSrc, className, style, paused = false, alt = "ObsidianUI frosted image with a fluid cursor reveal", } = {}) { const canvasRef = useRef(null); const trailRef = useRef([]); const pointerRef = useRef({ isInside: false, targetX: DEFAULT_POINTER_POSITION, targetY: DEFAULT_POINTER_POSITION, x: DEFAULT_POINTER_POSITION, y: DEFAULT_POINTER_POSITION, lastTime: 0, isLeaving: false, leaveAt: 0, }); useEffect(() => { let isDisposed = false; let animationFrameId = 0; const controller = new AbortController(); const disposeResources = []; const motion = window.matchMedia("(prefers-reduced-motion: reduce)"); const isStatic = () => paused || motion.matches; trailRef.current = []; const cleanup = () => { if (isDisposed) return; isDisposed = true; controller.abort(); cancelAnimationFrame(animationFrameId); disposeResources.reverse().forEach((dispose) => dispose()); }; // ─── WebGL Setup ─────────────────────────────────────────────────────── async function init() { const canvas = canvasRef.current; if (!canvas) return; const gl = canvas.getContext("webgl2"); if (!gl) { return; } const program = createProgram(gl, BLUR_REVEAL_VERT, BLUR_REVEAL_FRAG); disposeResources.push(() => gl.deleteProgram(program)); const positionBuffer = gl.createBuffer(); if (!positionBuffer) throw new Error("Buffer allocation failed."); disposeResources.push(() => gl.deleteBuffer(positionBuffer)); const onContextLost = (event) => { event.preventDefault(); canvas.style.opacity = "0"; cleanup(); }; canvas.addEventListener("webglcontextlost", onContextLost); disposeResources.push(() => canvas.removeEventListener("webglcontextlost", onContextLost)); const positionLocation = gl.getAttribLocation(program, "position"); const resolutionLocation = gl.getUniformLocation(program, "iResolution"); const timeLocation = gl.getUniformLocation(program, "iTime"); const trailLocation = gl.getUniformLocation(program, "iTrail[0]"); const trailAlphaLocation = gl.getUniformLocation(program, "iTrailAlpha[0]"); const trailCountLocation = gl.getUniformLocation(program, "iTrailCount"); const channel0Location = gl.getUniformLocation(program, "iChannel0"); const channel1Location = gl.getUniformLocation(program, "iChannel1"); gl.useProgram(program); gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); gl.bufferData(gl.ARRAY_BUFFER, FULLSCREEN_TRIANGLE_VERTICES, gl.STATIC_DRAW); gl.enableVertexAttribArray(positionLocation); gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0); const [baseImage, noiseImage] = await Promise.all([ loadImage(iChannel0, controller.signal), loadImage(iChannel1, controller.signal), ]); if (isDisposed) return; const baseTexture = createTexture(gl, baseImage, TEXTURE_UNIT_BASE); disposeResources.push(() => gl.deleteTexture(baseTexture)); const noiseTexture = createTexture(gl, noiseImage, TEXTURE_UNIT_NOISE, true); disposeResources.push(() => gl.deleteTexture(noiseTexture)); gl.uniform1i(channel0Location, TEXTURE_UNIT_BASE); gl.uniform1i(channel1Location, TEXTURE_UNIT_NOISE); // ─── Canvas Resize function onResize() { const devicePixelRatio = Math.min(window.devicePixelRatio || 1, 2); const rect = canvas.getBoundingClientRect(); const nextWidth = Math.max(1, Math.floor(rect.width * devicePixelRatio)); const nextHeight = Math.max(1, Math.floor(rect.height * devicePixelRatio)); if (canvas.width === nextWidth && canvas.height === nextHeight) return; canvas.width = nextWidth; canvas.height = nextHeight; gl.viewport(0, 0, canvas.width, canvas.height); } // ─── Animation Loop const trailData = new Float32Array(MAX_TRAIL_POINTS * 2); const trailAlphaData = new Float32Array(MAX_TRAIL_POINTS); function render() { if (isDisposed) return; const now = performance.now(); gl.uniform1f(timeLocation, isStatic() ? 0 : now / 1000); const pointer = pointerRef.current; const frameDelta = pointer.lastTime ? Math.min(MAX_FRAME_DELTA_MS, now - pointer.lastTime) : DEFAULT_FRAME_TIME_MS; pointer.lastTime = now; // Exponential smoothing keeps the cursor feel consistent across frame rates. const smoothing = 1.0 - Math.pow(POINTER_LERP_FACTOR, frameDelta / 1000); pointer.x += (pointer.targetX - pointer.x) * smoothing; pointer.y += (pointer.targetY - pointer.y) * smoothing; const leavingAge = pointer.isLeaving ? now - pointer.leaveAt : 0; const isLeavingActive = pointer.isLeaving && leavingAge < POINTER_LEAVE_DURATION_MS; const pushStrength = isStatic() ? 0 : pointer.isInside ? 1 : isLeavingActive ? 1 - leavingAge / POINTER_LEAVE_DURATION_MS : 0; if (pushStrength > 0) { const trail = trailRef.current; const lastPoint = trail[trail.length - 1]; const deltaX = lastPoint ? pointer.x - lastPoint.x : 1; const deltaY = lastPoint ? pointer.y - lastPoint.y : 1; const distance = Math.hypot(deltaX, deltaY); const minPointerDistance = pointer.isInside ? MIN_POINTER_DISTANCE_INSIDE : MIN_POINTER_DISTANCE_LEAVING; if (!lastPoint || distance > minPointerDistance) { trail.push({ x: pointer.x, y: pointer.y, time: now, strength: pushStrength, }); } } let trail = trailRef.current.filter( (point) => now - point.time < TRAIL_LIFETIME_MS ); if (trail.length > MAX_TRAIL_POINTS) { trail = trail.slice(trail.length - MAX_TRAIL_POINTS); } trailRef.current = trail; trailData.fill(0); trailAlphaData.fill(0); trail.forEach((point, index) => { const age = now - point.time; const life = Math.max(0, 1 - age / TRAIL_LIFETIME_MS); // Smooth alpha easing avoids a visible cutoff at the end of the trail. const baseAlpha = life * life * (3 - 2 * life); trailData[index * 2] = point.x; trailData[index * 2 + 1] = point.y; trailAlphaData[index] = baseAlpha * (point.strength ?? 1); }); gl.clear(gl.COLOR_BUFFER_BIT); gl.uniform2f(resolutionLocation, canvas.width, canvas.height); gl.uniform2fv(trailLocation, trailData); gl.uniform1fv(trailAlphaLocation, trailAlphaData); gl.uniform1i(trailCountLocation, trail.length); gl.drawArrays(gl.TRIANGLES, 0, 6); canvas.style.opacity = "1"; if (!isStatic()) animationFrameId = requestAnimationFrame(render); } const observer = new ResizeObserver(() => { onResize(); if (isStatic()) render(); }); observer.observe(canvas.parentElement); disposeResources.push(() => observer.disconnect()); const onMotionChange = () => { cancelAnimationFrame(animationFrameId); trailRef.current = []; render(); }; motion.addEventListener("change", onMotionChange); disposeResources.push(() => motion.removeEventListener("change", onMotionChange)); onResize(); render(); } init().catch(() => { if (isDisposed) return; if (canvasRef.current) canvasRef.current.style.opacity = "0"; cleanup(); }); return cleanup; }, [iChannel0, iChannel1, paused]); function updatePointerFromEvent(event) { const canvas = canvasRef.current; if (!canvas) return; const rect = canvas.getBoundingClientRect(); const pointer = pointerRef.current; pointer.targetX = Math.max(0, Math.min(1, (event.clientX - rect.left) / Math.max(1, rect.width))); pointer.targetY = Math.max(0, Math.min(1, (event.clientY - rect.top) / Math.max(1, rect.height))); } function onPointerEnter(event) { const pointer = pointerRef.current; pointer.isInside = true; pointer.isLeaving = false; updatePointerFromEvent(event); } function onPointerMove(event) { updatePointerFromEvent(event); } function onPointerLeave() { const pointer = pointerRef.current; pointer.isInside = false; pointer.isLeaving = true; pointer.leaveAt = performance.now(); } return ( <div role="img" aria-label={alt} className={cn("relative h-[28rem] w-full overflow-hidden bg-black", className)} style={style} > <div aria-hidden="true" className="absolute inset-0 scale-110 bg-cover bg-center blur-xl" style={{ backgroundImage: typeof iChannel0 === "string" ? `url(${JSON.stringify(iChannel0)})` : undefined }} /> <canvas ref={canvasRef} aria-hidden="true" onPointerEnter={onPointerEnter} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave} onPointerCancel={onPointerLeave} className="absolute inset-0 block h-full w-full opacity-0" /> </div> ); }
3

Add the supporting file

Copy into lib/utils.ts

import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }

Preview behavior

Move over the image to reveal its detail. The effect stays inside its container and respects reduced motion preferences.

Last updated on