Paint Reveal
Two pictures in one frame. The pointer paints the second one in with a soft brush, and the stroke closes back behind it.
Brand & motion studio · Lisbon
Color that stays with you.
Identities, campaigns and the motion between them, for brands that want to be remembered.
import { PaintReveal } from "@/components/ui/paint-reveal";
const ink = (grade = "") =>
`https://images.unsplash.com/photo-1541701494587-cb58502866ab?auto=format&fit=crop&w=2000&h=1250&q=80${grade}`;
export function PaintRevealDemo() {
return (
<PaintReveal
src={ink("&sat=-100&con=10")}
alt="Clouds of ink drifting through water, in black and white"
revealSrc={ink()}
revealAlt="The same ink clouds in red, orange and electric blue"
className="h-[var(--demo-height,34rem)] w-full rounded-xl border border-zinc-200 bg-black text-white dark:border-zinc-800"
>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/75 via-transparent to-black/40" />
<div className="relative flex h-full flex-col justify-between p-6 sm:p-10">
<header className="flex items-center justify-between text-sm">
<a href="#" className="font-semibold tracking-tight">
Tinta
</a>
<nav className="flex gap-5 text-white/70 sm:gap-7">
<a href="#" className="transition-colors hover:text-white">
Work
</a>
<a href="#" className="hidden transition-colors hover:text-white sm:inline">
Studio
</a>
<a href="#" className="transition-colors hover:text-white">
Contact
</a>
</nav>
</header>
<div className="max-w-xl">
<p className="font-mono text-xs tracking-widest text-white/60 uppercase">
Brand & motion studio · Lisbon
</p>
<h1 className="mt-3 text-4xl font-semibold tracking-tight text-balance sm:text-6xl">
Color that stays with you.
</h1>
<p className="mt-4 max-w-sm text-pretty text-white/70">
Identities, campaigns and the motion between them, for brands that want to be remembered.
</p>
</div>
</div>
</PaintReveal>
);
}
Installation
Add the component on its own with the CLI, or copy the file in by hand.
The CLI writes components/ui/paint-reveal.tsx and installs clsx and tailwind-merge. It needs the @keyframeui registry in components.json; add it once per project.
npx shadcn@latest add @keyframeui/paint-revealInstall the dependencies
npm i clsx tailwind-mergeAdd the cn helper
Skip this if you already use shadcn/ui.
lib/utils.tsimport { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }Copy the source into your project
components/ui/paint-reveal.tsx"use client"; import * as React from "react"; import { cn } from "@/lib/utils"; /* ───────────────────────────────────────────────────────── * PAINT REVEAL STORYBOARD * * rest `src` fills the frame. `revealSrc` waits under it, * lined up pixel for pixel * enter the brush opens under the pointer: a soft circle of * `revealSrc` grows to `brushSize` * paint the brush trails the pointer by a few ms and leaves a * stroke behind. Quick strokes paint a little wider. * Where the pointer rests, the picture stays open * fade each part of the stroke closes back over `fadeMs`, * holding its width at first, then thinning to nothing, * oldest first, so the stroke tapers like a comet * leave the brush closes where the pointer left the frame * idle on touch screens (or everywhere, with autoPlay) the * brush drifts over the frame by itself while it's in * view. A finger drag takes over; the drift comes back * when the finger lifts * flip `revealed`, or the toggle, floods `revealSrc` across * the frame from the brush or the toggle. From then on * the brush paints `src` back. Flipping again mid-flood * pulls the flood back in * reduced no brush or drift: the toggle crossfades the pictures * ───────────────────────────────────────────────────────── */ const PAINT = { follow: 0.04, // s, time constant of the brush trailing the pointer drift: 0.2, // s, the same while it drifts by itself driftSpeed: 0.7, // how fast the drift wanders, relative to its base loop open: 0.09, // s, time constant of the brush opening and closing spacing: 0.2, // dab spacing along a stroke, as a fraction of the brush radius swell: 0.2, // a quick stroke paints up to this much wider swellSpeed: 2600, // px/s where the swell tops out flood: 950, // ms for a flip to cover the frame crossfade: 260, // ms for a flip with reduced motion resume: 1800, // ms after a finger lifts before the drift comes back resumeMouse: 700, // ms after the pointer leaves, with autoPlay={true} maxDpr: 2, maskScale: 0.5, // mask pixels per CSS pixel; the mask is soft, so it's drawn small bake: 600, // with fadeMs={Infinity}, older dabs are flattened into a bitmap past this count } as const; type Dab = { x: number; y: number; r: number; t: number }; type Box = { x0: number; y0: number; x1: number; y1: number }; type Options = { brushSize: number; softness: number; fadeMs: number; autoPlay: boolean | "touch"; objectPosition: string; reduced: boolean; }; const clamp01 = (t: number) => Math.min(1, Math.max(0, t)); const easeOut = (t: number) => 1 - Math.pow(1 - t, 3); /** Reads an object-position like "50% 30%", "left" or "right top" as 0–1 fractions. */ function parsePosition(value: string): [number, number] { const words: Record<string, number> = { left: 0, top: 0, center: 0.5, right: 1, bottom: 1 }; const read = (part = "center") => part in words ? words[part] : part.endsWith("%") ? clamp01(parseFloat(part) / 100) : 0.5; const [a, b] = value.trim().split(/\s+/); // "top", "bottom" and "top left" name the vertical position first. if (a === "top" || a === "bottom" || b === "left" || b === "right") return [read(b), read(a)]; return [read(a), read(b)]; } /** A slow loop that wanders over the frame, as 0–1 fractions. */ function driftAt(t: number): [number, number] { return [ 0.5 + 0.3 * Math.sin(t * 0.83) + 0.1 * Math.sin(t * 2.1 + 1.3), 0.5 + 0.24 * Math.sin(t * 1.17 + 0.6) + 0.1 * Math.sin(t * 2.7 + 2.1), ]; } /** * Everything that changes per frame lives here, outside React: the brush, the * stroke, the flood and both canvases. The component hands it the elements in * an effect and its options after each render. */ function createPainter(els: { root: HTMLDivElement; canvas: HTMLCanvasElement; base: HTMLImageElement; top: HTMLImageElement; revealed: boolean; }) { const { root, canvas, base, top } = els; const view = canvas.getContext("2d"); // The stroke's shape, drawn at half resolution and feathered, then stretched over the picture. const mask = document.createElement("canvas"); const mctx = mask.getContext("2d"); let bake: HTMLCanvasElement | null = null; let baked = false; let opts: Options = { brushSize: 180, softness: 0.5, fadeMs: 600, autoPlay: "touch", objectPosition: "50% 50%", reduced: false, }; let anchor: [number, number] = [0.5, 0.5]; let w = 0; let h = 0; let dpr = 1; let dabs: Dab[] = []; // The pointer in client px. `inside` while it's over the frame (or a finger is down); // `armed` once it has moved since the last flip. const pointer = { x: 0, y: 0, touch: false, inside: false, armed: false }; // The brush in local px. `open` runs 0–1; `r` is its radius right now. const brush = { x: 0, y: 0, open: 0, r: 0, speed: 0, carry: 0, live: false }; // 0: `src` is the base and the brush paints `revealSrc`. 1: the other way round. let side: 0 | 1 = els.revealed ? 1 : 0; let want = side; top.style.opacity = side === 1 ? "1" : "0"; // How far the other picture has flooded the frame, 0–1. const flood = { f: 0, from: 0, to: 0, start: 0, x: 0, y: 0, active: false }; const drift = { t: Math.random() * 40, on: false, resumeAt: 0 }; let inView = false; let canHover = true; let raf = 0; let last = 0; let dirty = true; let drawn: Box | null = null; // the part of the view canvas painted last frame const radius = () => opts.brushSize / 2; const painted = () => (side === 0 ? top : base); const ready = (img: HTMLImageElement) => img.complete && img.naturalWidth > 0; const finite = () => Number.isFinite(opts.fadeMs); /** Feathering in mask px. shadowBlur is about 2σ, so the edge fades over roughly twice this either side. */ const feather = () => Math.max(0.6, opts.softness * radius() * 0.5 * PAINT.maskScale); function resize() { w = root.clientWidth; h = root.clientHeight; dpr = Math.min(window.devicePixelRatio || 1, PAINT.maxDpr); canvas.width = Math.max(1, Math.round(w * dpr)); canvas.height = Math.max(1, Math.round(h * dpr)); const mw = Math.max(1, Math.ceil(w * PAINT.maskScale)); const mh = Math.max(1, Math.ceil(h * PAINT.maskScale)); if (bake && baked && (bake.width !== mw || bake.height !== mh)) { // Keep paint that's meant to stay, stretched to the new size. const copy = document.createElement("canvas"); copy.width = mw; copy.height = mh; copy.getContext("2d")?.drawImage(bake, 0, 0, mw, mh); bake = copy; } mask.width = mw; mask.height = mh; drawn = null; dirty = true; kick(); } /** The pointer in local px. Divides out any CSS transform on the frame. */ function local() { const rect = root.getBoundingClientRect(); return { x: ((pointer.x - rect.left) * w) / (rect.width || 1), y: ((pointer.y - rect.top) * h) / (rect.height || 1), }; } /** Moves the brush without painting the way there. If it's open, it leaves a dab that closes with the stroke. */ function jump(x: number, y: number, now: number) { if (brush.r > 1) dabs.push({ x: brush.x, y: brush.y, r: brush.r, t: now }); brush.x = x; brush.y = y; brush.open = 0; brush.r = 0; brush.speed = 0; brush.carry = 0; } /** Lays dabs every few px along the brush's path this frame, carrying the leftover distance over. */ function stamp(x0: number, y0: number, r0: number, now: number) { const dist = Math.hypot(brush.x - x0, brush.y - y0); const step = Math.max(2, radius() * PAINT.spacing); let d = step - brush.carry; for (; d <= dist; d += step) { const k = d / dist; const r = r0 + (brush.r - r0) * k; if (r > 0.5) dabs.push({ x: x0 + (brush.x - x0) * k, y: y0 + (brush.y - y0) * k, r, t: now }); } brush.carry = dist - (d - step); } function driftAllowed(now: number) { if (opts.reduced || !inView || pointer.inside || now < drift.resumeAt) return false; return opts.autoPlay === true || (opts.autoPlay === "touch" && !canHover); } /** Advances one frame. Returns whether anything is still moving. */ function update(now: number, dt: number) { let busy = false; // Where the brush is headed: the pointer, the drift, or nowhere (it closes). let target: { x: number; y: number } | null = null; let follow: number = PAINT.follow; if (pointer.inside) { if (pointer.armed && !opts.reduced) target = local(); drift.on = false; } else if (driftAllowed(now)) { drift.t += dt * PAINT.driftSpeed; const [fx, fy] = driftAt(drift.t); target = { x: fx * w, y: fy * h }; if (!drift.on) jump(target.x, target.y, now); drift.on = true; follow = PAINT.drift; busy = true; } else { drift.on = false; } brush.live = target !== null; const x0 = brush.x; const y0 = brush.y; const r0 = brush.r; let speed = 0; if (target) { const k = 1 - Math.exp(-dt / follow); brush.x += (target.x - brush.x) * k; brush.y += (target.y - brush.y) * k; speed = Math.hypot(brush.x - x0, brush.y - y0) / Math.max(dt, 1e-3); if (Math.hypot(target.x - brush.x, target.y - brush.y) > 0.25) busy = true; } brush.speed += (speed - brush.speed) * (1 - Math.exp(-dt / 0.08)); if (brush.speed < 1) brush.speed = 0; else busy = true; const openTo = brush.live ? 1 : 0; brush.open += (openTo - brush.open) * (1 - Math.exp(-dt / PAINT.open)); if (Math.abs(openTo - brush.open) < 0.002) brush.open = openTo; else busy = true; brush.r = radius() * brush.open * (1 + PAINT.swell * clamp01(brush.speed / PAINT.swellSpeed)); if (brush.x !== x0 || brush.y !== y0) stamp(x0, y0, r0, now); if (brush.x !== x0 || brush.y !== y0 || brush.r !== r0) dirty = true; // Drop the parts of the stroke that have closed. if (finite()) { const fade = Math.max(1, opts.fadeMs); let gone = 0; while (gone < dabs.length && now - dabs[gone].t >= fade) gone++; if (gone) dabs = dabs.slice(gone); if (gone || dabs.length) dirty = true; if (dabs.length) busy = true; } else if (dabs.length > PAINT.bake) { flatten(dabs.splice(0, dabs.length - 40)); } // Flood toward the side that's wanted, or back out of it. if (want !== side || flood.f > 0) { const to = want !== side ? 1 : 0; if (to !== flood.to || !flood.active) { flood.from = flood.f; flood.to = to; flood.start = now; flood.active = true; } const span = (opts.reduced ? PAINT.crossfade : PAINT.flood) * Math.abs(flood.to - flood.from); const p = span > 0 ? clamp01((now - flood.start) / span) : 1; flood.f = flood.from + (flood.to - flood.from) * easeOut(p); dirty = true; if (p < 1) busy = true; else { flood.active = false; flood.f = flood.to; if (flood.to === 1) swap(); } } return busy; } /** The flood covers the frame: the other picture becomes the base and the brush starts over. */ function swap() { side = side === 0 ? 1 : 0; top.style.opacity = side === 1 ? "1" : "0"; flood.f = flood.from = flood.to = 0; dabs = []; clearBake(); brush.open = brush.r = brush.carry = 0; // A brush resting where it was would show the old picture again; it waits for the pointer to move. pointer.armed = false; dirty = true; } function clearBake() { baked = false; bake?.getContext("2d")?.clearRect(0, 0, bake.width, bake.height); } function flatten(list: Dab[]) { bake ??= document.createElement("canvas"); if (bake.width !== mask.width || bake.height !== mask.height) { bake.width = mask.width; bake.height = mask.height; } const ctx = bake.getContext("2d"); if (ctx && fill(ctx, (add) => list.forEach((d) => add(d.x, d.y, d.r)))) baked = true; } /** * Fills circles (local px) into a mask as one shape, feathered by its * shadow. One fill means overlapping dabs never stack into darker spots, * so a stroke's edge is exactly as soft as a resting brush. Returns the * bounds it touched, in local px. */ function fill(ctx: CanvasRenderingContext2D, each: (add: (x: number, y: number, r: number) => void) => void) { const s = PAINT.maskScale; // The shape is drawn off the canvas and only its shadow lands on it. const away = (ctx.canvas.width + ctx.canvas.height) * 2; const box: Box = { x0: Infinity, y0: Infinity, x1: -Infinity, y1: -Infinity }; ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.globalCompositeOperation = "source-over"; ctx.shadowColor = "#000"; ctx.shadowBlur = feather(); ctx.shadowOffsetX = away; ctx.beginPath(); each((x, y, r) => { if (r < 0.25) return; ctx.moveTo((x + r) * s - away, y * s); ctx.arc(x * s - away, y * s, r * s, 0, Math.PI * 2); box.x0 = Math.min(box.x0, x - r); box.y0 = Math.min(box.y0, y - r); box.x1 = Math.max(box.x1, x + r); box.y1 = Math.max(box.y1, y + r); }); const any = box.x1 > box.x0; if (any) ctx.fill(); ctx.restore(); if (!any) return null; const pad = (feather() * 1.6) / s + 2; return { x0: box.x0 - pad, y0: box.y0 - pad, x1: box.x1 + pad, y1: box.y1 + pad }; } function shape(now: number) { if (!mctx) return null; mctx.setTransform(1, 0, 0, 1, 0, 0); mctx.clearRect(0, 0, mask.width, mask.height); const fade = Math.max(1, opts.fadeMs); let box = fill(mctx, (add) => { for (const d of dabs) { // Holds its width at first, then closes quickly: the stroke keeps its body and thins toward the tail. const k = finite() ? clamp01((now - d.t) / fade) : 0; add(d.x, d.y, d.r * (1 - k * k * (0.35 + 0.65 * k))); } add(brush.x, brush.y, brush.r); if (flood.f > 0 && !opts.reduced) { const far = Math.max( Math.hypot(flood.x, flood.y), Math.hypot(w - flood.x, flood.y), Math.hypot(flood.x, h - flood.y), Math.hypot(w - flood.x, h - flood.y), ); add(flood.x, flood.y, flood.f * (far + (feather() * 2) / PAINT.maskScale)); } }); if (baked && bake) { mctx.drawImage(bake, 0, 0); box = { x0: 0, y0: 0, x1: w, y1: h }; } return box; } function render(now: number) { if (!view) return; dirty = false; view.setTransform(1, 0, 0, 1, 0, 0); view.globalCompositeOperation = "source-over"; view.globalAlpha = 1; if (drawn) view.clearRect(drawn.x0, drawn.y0, drawn.x1 - drawn.x0, drawn.y1 - drawn.y0); drawn = null; const img = painted(); if (!ready(img) || !w || !h) return; const fading = opts.reduced && flood.f > 0; const box = fading ? { x0: 0, y0: 0, x1: w, y1: h } : shape(now); if (!box) return; // Only the part with paint in it is touched, in device px. const x0 = Math.max(0, Math.floor(box.x0 * dpr)); const y0 = Math.max(0, Math.floor(box.y0 * dpr)); const x1 = Math.min(canvas.width, Math.ceil(box.x1 * dpr)); const y1 = Math.min(canvas.height, Math.ceil(box.y1 * dpr)); if (x1 <= x0 || y1 <= y0) return; drawn = { x0, y0, x1, y1 }; view.save(); view.beginPath(); view.rect(x0, y0, x1 - x0, y1 - y0); view.clip(); if (fading) { view.globalAlpha = flood.f; view.fillRect(x0, y0, x1 - x0, y1 - y0); view.globalAlpha = 1; } else { view.imageSmoothingEnabled = true; const k = dpr / PAINT.maskScale; view.drawImage(mask, 0, 0, mask.width * k, mask.height * k); } // Keep the picture only where the mask is. view.globalCompositeOperation = "source-in"; view.imageSmoothingQuality = "high"; const iw = img.naturalWidth; const ih = img.naturalHeight; const scale = Math.max(canvas.width / iw, canvas.height / ih); // object-fit: cover, at objectPosition. view.drawImage( img, (canvas.width - iw * scale) * anchor[0], (canvas.height - ih * scale) * anchor[1], iw * scale, ih * scale, ); view.restore(); } function frame(now: number) { raf = 0; const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60; last = now; const busy = update(now, dt); if (dirty) render(now); if (busy) raf = requestAnimationFrame(frame); else last = 0; } function kick() { if (!raf) raf = requestAnimationFrame(frame); } // ── Input ── function enter(e: PointerEvent) { pointer.x = e.clientX; pointer.y = e.clientY; if (!pointer.inside || !pointer.armed) { const p = local(); jump(p.x, p.y, performance.now()); } pointer.inside = true; pointer.armed = true; kick(); } function onMove(e: PointerEvent) { // A finger paints only while it's down; a mouse or pen paints whenever it's over the frame. if (e.pointerType === "touch" ? !pointer.touch : false) return; enter(e); } function onDown(e: PointerEvent) { if (e.pointerType !== "touch") return; if (e.target instanceof Element && e.target.closest("[data-paint-reveal-toggle]")) return; pointer.touch = true; enter(e); } function onUp(e: PointerEvent) { if (e.pointerType !== "touch" || !pointer.touch) return; pointer.touch = false; pointer.inside = false; drift.resumeAt = performance.now() + PAINT.resume; kick(); } function onLeave(e: PointerEvent) { if (e.pointerType === "touch") return; pointer.inside = false; drift.resumeAt = performance.now() + PAINT.resumeMouse; kick(); } function onScroll() { // The page moved under a resting pointer. if (pointer.inside) kick(); } root.addEventListener("pointermove", onMove); root.addEventListener("pointerdown", onDown); root.addEventListener("pointerup", onUp); root.addEventListener("pointercancel", onUp); root.addEventListener("pointerleave", onLeave); window.addEventListener("scroll", onScroll, { capture: true, passive: true }); const hover = window.matchMedia("(hover: hover) and (pointer: fine)"); canHover = hover.matches; const onHover = () => { canHover = hover.matches; kick(); }; hover.addEventListener("change", onHover); const sized = new ResizeObserver(resize); sized.observe(root); const seen = new IntersectionObserver(([entry]) => { inView = entry.isIntersecting; kick(); }); seen.observe(root); const onLoad = (e: Event) => { // Decode now, so the first stroke doesn't stall on it. (e.target as HTMLImageElement).decode?.().catch(() => {}); dirty = true; kick(); }; base.addEventListener("load", onLoad); top.addEventListener("load", onLoad); return { set(next: Options) { if (finite() !== Number.isFinite(next.fadeMs) && baked) clearBake(); opts = next; anchor = parsePosition(next.objectPosition); dirty = true; kick(); }, /** Floods the frame with `revealSrc` (1) or `src` (0), from a point in local px, or the brush. */ flip(to: 0 | 1, from?: { x: number; y: number }) { if (to === want) return; if (!flood.active && flood.f === 0) { const origin = from ?? (brush.open > 0.2 ? brush : { x: w / 2, y: h / 2 }); flood.x = origin.x; flood.y = origin.y; } want = to; kick(); }, destroy() { cancelAnimationFrame(raf); root.removeEventListener("pointermove", onMove); root.removeEventListener("pointerdown", onDown); root.removeEventListener("pointerup", onUp); root.removeEventListener("pointercancel", onUp); root.removeEventListener("pointerleave", onLeave); window.removeEventListener("scroll", onScroll, { capture: true }); hover.removeEventListener("change", onHover); base.removeEventListener("load", onLoad); top.removeEventListener("load", onLoad); sized.disconnect(); seen.disconnect(); }, }; } type Painter = ReturnType<typeof createPainter>; const REDUCED = "(prefers-reduced-motion: reduce)"; const subscribeReduced = (onChange: () => void) => { const query = window.matchMedia(REDUCED); query.addEventListener("change", onChange); return () => query.removeEventListener("change", onChange); }; /** Reduced motion, read so the server and the first client render agree. */ function useReduced() { return React.useSyncExternalStore( subscribeReduced, () => window.matchMedia(REDUCED).matches, () => false, ); } /* ── PaintReveal ────────────────────────────────────────── */ export interface PaintRevealProps extends React.HTMLAttributes<HTMLDivElement> { /** The picture shown at rest. */ src: string; /** Alt text for `src`. */ alt: string; /** The picture the brush paints in. Give it the same size and framing as `src` so the two line up. */ revealSrc: string; /** Alt text for `revealSrc`. Leave it empty when it only restyles `src`. */ revealAlt?: string; /** Diameter of the brush, in px. */ brushSize?: number; /** Edge of the brush, from 0 (crisp) to 1 (very soft). */ softness?: number; /** How long a stroke takes to close back, in ms. `0` leaves no trail, `Infinity` keeps the paint until the next flip. */ fadeMs?: number; /** Let the brush drift by itself while the frame is in view: `"touch"` on touch screens only, `true` everywhere, `false` never. */ autoPlay?: boolean | "touch"; /** Floods `revealSrc` over the whole frame. Leave unset to let the toggle manage it. */ revealed?: boolean; /** Whether `revealSrc` starts out covering the frame. */ defaultRevealed?: boolean; /** Called when the toggle flips the pictures. */ onRevealedChange?: (revealed: boolean) => void; /** Show a two-way switch in the bottom-right corner. It's always shown with reduced motion, where it crossfades. */ toggle?: boolean; /** The switch's labels: `src` first, then `revealSrc`. */ labels?: [string, string]; /** Classes for the switch, e.g. to move it to another corner. */ toggleClassName?: string; /** Where both pictures are anchored when they're cropped, like CSS `object-position`. */ objectPosition?: string; } /** * Two pictures in one frame. Moving the pointer paints the second one in with * a soft brush, and the stroke closes back behind it. */ export function PaintReveal({ src, alt, revealSrc, revealAlt = "", brushSize = 180, softness = 0.5, fadeMs = 600, autoPlay = "touch", revealed, defaultRevealed = false, onRevealedChange, toggle = false, labels = ["Before", "After"], toggleClassName, objectPosition = "50% 50%", className, children, ...props }: PaintRevealProps) { const rootRef = React.useRef<HTMLDivElement>(null); const canvasRef = React.useRef<HTMLCanvasElement>(null); const baseRef = React.useRef<HTMLImageElement>(null); const topRef = React.useRef<HTMLImageElement>(null); const painterRef = React.useRef<Painter | null>(null); const originRef = React.useRef<{ x: number; y: number } | undefined>(undefined); const [own, setOwn] = React.useState(defaultRevealed); const shown = revealed ?? own; // The markup starts on this side; the painter draws every flip after it. const [initial] = React.useState(shown); const shownRef = React.useRef(shown); const reduced = useReduced(); const options: Options = { brushSize, softness: clamp01(softness), fadeMs, autoPlay, objectPosition, reduced, }; const optionsRef = React.useRef(options); React.useLayoutEffect(() => { optionsRef.current = options; painterRef.current?.set(options); }); React.useEffect(() => { const root = rootRef.current; const canvas = canvasRef.current; const base = baseRef.current; const top = topRef.current; if (!root || !canvas || !base || !top) return; const painter = createPainter({ root, canvas, base, top, revealed: shownRef.current }); painter.set(optionsRef.current); painterRef.current = painter; return () => { painter.destroy(); painterRef.current = null; }; }, []); React.useEffect(() => { shownRef.current = shown; painterRef.current?.flip(shown ? 1 : 0, originRef.current); originRef.current = undefined; }, [shown]); const choose = (next: boolean, event: React.MouseEvent<HTMLButtonElement>) => { if (next === shown) return; const root = rootRef.current; if (root) { // Flood from the button that was pressed. const frame = root.getBoundingClientRect(); const button = event.currentTarget.getBoundingClientRect(); originRef.current = { x: ((button.left + button.width / 2 - frame.left) * root.clientWidth) / (frame.width || 1), y: ((button.top + button.height / 2 - frame.top) * root.clientHeight) / (frame.height || 1), }; } if (revealed === undefined) setOwn(next); onRevealedChange?.(next); }; return ( <div ref={rootRef} className={cn("relative isolate touch-pan-y overflow-hidden", className)} {...props}> {/* eslint-disable-next-line @next/next/no-img-element -- a remote picture in an installable component */} <img ref={baseRef} src={src} alt={alt} draggable={false} className="pointer-events-none absolute inset-0 -z-10 size-full object-cover" style={{ objectPosition }} /> {/* eslint-disable-next-line @next/next/no-img-element -- a remote picture in an installable component */} <img ref={topRef} src={revealSrc} alt={revealAlt} draggable={false} className={cn( "pointer-events-none absolute inset-0 -z-10 size-full object-cover", initial ? "opacity-100" : "opacity-0", )} style={{ objectPosition }} /> <canvas ref={canvasRef} aria-hidden className="pointer-events-none absolute inset-0 -z-10 size-full" /> {children} {(toggle || reduced) && ( <div data-paint-reveal-toggle="" role="group" aria-label="Compare pictures" className={cn( "absolute right-3 bottom-3 z-10 grid grid-cols-2 rounded-full bg-black/45 p-1 text-xs font-medium text-white ring-1 ring-white/15 backdrop-blur-md", toggleClassName, )} > <span aria-hidden className={cn( "absolute inset-y-1 left-1 w-[calc(50%-0.25rem)] rounded-full bg-white shadow-sm transition-transform duration-300 ease-[cubic-bezier(0.2,0.8,0.2,1)] motion-reduce:transition-none", shown && "translate-x-full", )} /> {labels.map((label, i) => { const active = shown === (i === 1); return ( <button key={i} type="button" aria-pressed={active} onClick={(event) => choose(i === 1, event)} className={cn( "relative rounded-full px-3 py-1.5 whitespace-nowrap transition-colors duration-200 outline-none focus-visible:ring-2 focus-visible:ring-white/70", active ? "text-zinc-950" : "text-white/80 hover:text-white", )} > {label} </button> ); })} </div> )} </div> ); }Update the import paths
The file imports
cnfrom@/lib/utils. Change the path if your project uses a different alias.
Examples
Finished sections built with Paint Reveal. Each one installs whole, with the component included.
Photo Restore
An AI photo-restoration tool. With `fadeMs={Infinity}` the paint stays, so you restore the print stroke by stroke. "Restored" floods the rest in from the switch, and "Faded" floods it back and starts over.
"use client";
import * as React from "react";
import { PaintReveal } from "@/components/ui/paint-reveal";
const lake = (grade = "") =>
`https://images.unsplash.com/photo-1501785888041-af3ef285b470?auto=format&fit=crop&w=1600&h=1067&q=80${grade}`;
export function PaintRevealRestoreDemo() {
const [restored, setRestored] = React.useState(false);
return (
<figure className="w-full max-w-3xl rounded-2xl border border-zinc-200 bg-white p-2 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<PaintReveal
src={lake("&sepia=60&con=-50&sat=-70&blur=6&exp=6")}
alt="A faded, blurry print of a mountain lake with a rowing boat"
revealSrc={lake()}
revealAlt="The same lake restored: turquoise water, sharp peaks, a clear sky"
fadeMs={Infinity}
brushSize={110}
softness={0.35}
toggle
labels={["Faded", "Restored"]}
revealed={restored}
onRevealedChange={setRestored}
toggleClassName="top-3 bottom-auto"
className="aspect-[3/2] w-full rounded-xl"
/>
<figcaption className="flex flex-wrap items-center justify-between gap-x-6 gap-y-1 px-3 pt-3 pb-2 text-sm">
<span className="font-medium text-zinc-900 dark:text-zinc-100">Lago di Braies, 1974</span>
<span className="text-zinc-500" aria-live="polite">
{restored ? "Restored in 2.4 s · 4× detail" : "Paint over the print to restore it"}
</span>
</figcaption>
</figure>
);
}
About the Founder
A studio's about section. With `autoPlay`, the brush drifts over the portrait by itself until the pointer takes over, so the color keeps moving through it while you read.
About the founder
“Every brand guide I was handed was a PDF nobody opened. So we build the system into the tools people already use.”
Noor Haddad, founder of Mera Studio
Twelve years in brand teams at Spotify and Monzo, then a studio of six in Amsterdam. Mera designs identities that ship as code, type and templates on day one.
import { PaintReveal } from "@/components/ui/paint-reveal";
const portrait = (grade = "") =>
`https://images.unsplash.com/photo-1531746020798-e6953c6e8e04?auto=format&fit=crop&crop=faces&w=900&h=1125&q=80${grade}`;
export function PaintRevealFounderDemo() {
return (
<section className="grid w-full max-w-4xl items-center gap-8 sm:grid-cols-[minmax(0,5fr)_minmax(0,6fr)] sm:gap-12">
<PaintReveal
src={portrait("&sat=-100&con=8")}
alt="Portrait of Noor Haddad"
revealSrc={portrait()}
autoPlay
brushSize={150}
softness={0.75}
fadeMs={1100}
className="aspect-[4/5] w-full rounded-2xl"
/>
<div>
<p className="font-mono text-xs tracking-widest text-zinc-500 uppercase">About the founder</p>
<blockquote className="mt-4 text-2xl leading-snug font-medium tracking-tight text-balance text-zinc-900 sm:text-3xl dark:text-zinc-50">
“Every brand guide I was handed was a PDF nobody opened. So we build the system into the
tools people already use.”
</blockquote>
<div className="mt-6 flex items-center gap-3">
<span className="h-px w-8 bg-zinc-300 dark:bg-zinc-700" />
<p className="text-sm text-zinc-600 dark:text-zinc-400">
<span className="font-medium text-zinc-900 dark:text-zinc-100">Noor Haddad</span>,
founder of Mera Studio
</p>
</div>
<p className="mt-6 max-w-md text-sm leading-relaxed text-pretty text-zinc-600 dark:text-zinc-400">
Twelve years in brand teams at Spotify and Monzo, then a studio of six in Amsterdam. Mera
designs identities that ship as code, type and templates on day one.
</p>
</div>
</section>
);
}
Work Grid
A portfolio grid in grayscale, with a smaller brush on each card. Only the card under the pointer draws anything, and `autoPlay={false}` keeps the grid still on phones.
import { PaintReveal } from "@/components/ui/paint-reveal";
const projects = [
{ client: "Nordlys", kind: "Holiday homes", year: 2026, photo: "1600607687939-ce8a6c25118c" },
{ client: "Atelier Sund", kind: "Brand identity", year: 2025, photo: "1515886657613-9f3515b0c78f" },
{ client: "Kvarter", kind: "Office fit-out", year: 2025, photo: "1524758631624-e2822e304c36" },
{ client: "Fjell", kind: "Campaign", year: 2024, photo: "1506905925346-21bda4d32df4" },
];
const photo = (id: string, grade = "") =>
`https://images.unsplash.com/photo-${id}?auto=format&fit=crop&w=900&h=675&q=80${grade}`;
export function PaintRevealWorkDemo() {
return (
<section className="w-full max-w-4xl">
<div className="mb-6 flex items-end justify-between gap-6">
<h2 className="text-2xl font-semibold tracking-tight text-zinc-900 sm:text-3xl dark:text-zinc-50">
Selected work
</h2>
<a href="#" className="text-sm text-zinc-500 transition-colors hover:text-zinc-900 dark:hover:text-zinc-100">
All projects →
</a>
</div>
<ul className="grid gap-x-5 gap-y-8 sm:grid-cols-2">
{projects.map((project) => (
<li key={project.client}>
<a href="#" className="group block">
<PaintReveal
src={photo(project.photo, "&sat=-100&con=6")}
alt={`${project.client}: ${project.kind}`}
revealSrc={photo(project.photo)}
brushSize={130}
softness={0.6}
fadeMs={700}
autoPlay={false}
className="aspect-[4/3] w-full rounded-lg"
/>
<div className="mt-3 flex items-baseline justify-between gap-4 text-sm">
<span className="font-medium text-zinc-900 dark:text-zinc-100">{project.client}</span>
<span className="text-zinc-500 tabular-nums">
{project.kind} · {project.year}
</span>
</div>
</a>
</li>
))}
</ul>
</section>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
srcRequired | string | None | The picture shown at rest. |
altRequired | string | None | Alt text for src. |
revealSrcRequired | string | None | The picture the brush paints in. Give it the same size and framing as src so the two line up. Any URL works: it's drawn to a canvas but never read back, so it needs no CORS headers. |
revealAlt | string | "" | Alt text for revealSrc. Leave it empty when it only restyles src. |
brushSize | number | 180 | Diameter of the brush, in px. A quick stroke paints up to a fifth wider. |
softness | number | 0.5 | Edge of the brush, from 0 (crisp) to 1 (very soft). Strokes are drawn as one shape, so overlaps never build up into darker spots. |
fadeMs | number | 600 | How long a stroke takes to close back, in ms. It holds its width at first, then thins toward the tail. 0 leaves no trail, just a spotlight under the pointer; Infinity keeps the paint until the next flip. |
autoPlay | boolean | "touch" | "touch" | Let the brush drift over the frame by itself while it's in view. "touch" does it only on screens without hover, true also on desktop while the pointer is elsewhere, false never. A finger drag or the pointer takes over. |
revealed | boolean | None | Floods revealSrc over the whole frame, from the brush, the toggle or the center. After that the brush paints src back. Leave unset to let the toggle manage it. |
defaultRevealed | boolean | false | Whether revealSrc starts out covering the frame. |
onRevealedChange | (revealed: boolean) => void | None | Called when the toggle flips the pictures. |
toggle | boolean | false | Show a two-way switch in the bottom-right corner. It's always shown with reduced motion, where the brush is off and the switch crossfades. |
labels | [string, string] | ["Before", "After"] | The switch's labels: src first, then revealSrc. |
toggleClassName | string | None | Classes for the switch, e.g. top-3 bottom-auto to move it to the top-right corner. |
objectPosition | string | "50% 50%" | Where both pictures are anchored when they're cropped, like CSS object-position. Takes keywords and percentages. |
children | React.ReactNode | None | Content laid over the pictures, like a headline. It doesn't block the brush, and in-flow content can set the frame's height. |
className | string | None | Classes for the frame. Give it a size here (aspect-[3/2], h-[80svh]) unless its children set one. Touch screens can still scroll the page through it vertically; add touch-none to paint in every direction instead. |
srcRequiredType
stringThe picture shown at rest.
altRequiredType
stringAlt text for
src.revealSrcRequiredType
stringThe picture the brush paints in. Give it the same size and framing as
srcso the two line up. Any URL works: it's drawn to a canvas but never read back, so it needs no CORS headers.revealAltType
stringDefault""Alt text for
revealSrc. Leave it empty when it only restylessrc.brushSizeType
numberDefault180Diameter of the brush, in px. A quick stroke paints up to a fifth wider.
softnessType
numberDefault0.5Edge of the brush, from
0(crisp) to1(very soft). Strokes are drawn as one shape, so overlaps never build up into darker spots.fadeMsType
numberDefault600How long a stroke takes to close back, in ms. It holds its width at first, then thins toward the tail.
0leaves no trail, just a spotlight under the pointer;Infinitykeeps the paint until the next flip.autoPlayType
boolean | "touch"Default"touch"Let the brush drift over the frame by itself while it's in view.
"touch"does it only on screens without hover,truealso on desktop while the pointer is elsewhere,falsenever. A finger drag or the pointer takes over.revealedType
booleanFloods
revealSrcover the whole frame, from the brush, the toggle or the center. After that the brush paintssrcback. Leave unset to let the toggle manage it.defaultRevealedType
booleanDefaultfalseWhether
revealSrcstarts out covering the frame.onRevealedChangeType
(revealed: boolean) => voidCalled when the toggle flips the pictures.
toggleType
booleanDefaultfalseShow a two-way switch in the bottom-right corner. It's always shown with reduced motion, where the brush is off and the switch crossfades.
labelsType
[string, string]Default["Before", "After"]The switch's labels:
srcfirst, thenrevealSrc.toggleClassNameType
stringClasses for the switch, e.g.
top-3 bottom-autoto move it to the top-right corner.objectPositionType
stringDefault"50% 50%"Where both pictures are anchored when they're cropped, like CSS
object-position. Takes keywords and percentages.childrenType
React.ReactNodeContent laid over the pictures, like a headline. It doesn't block the brush, and in-flow content can set the frame's height.
classNameType
stringClasses for the frame. Give it a size here (
aspect-[3/2],h-[80svh]) unless its children set one. Touch screens can still scroll the page through it vertically; addtouch-noneto paint in every direction instead.