73 / Scenes
Displacement Image
Two images crossing by pushing their pixels through the same noise in opposite directions, with the first image also present as ordinary markup for anything that cannot run it.

Point at the image, or move focus onto it, to cross to the second one.
"use client"
import { DisplacementImage } from "mischief-ui/displacement-image"
export function DisplacementImageDemo() {
return (
<div className="w-full max-w-md">
<DisplacementImage
from="/demo/gallery/shift-button.png"
to="/demo/gallery/floating-deck.png"
alt="Shift Button crossing into Floating Deck"
className="aspect-[4/3]"
/>
<p className="text-muted-foreground mt-3 text-xs">
Point at the image, or move focus onto it, to cross to the second one.
</p>
</div>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/displacement-imageimport { DisplacementImage } from "mischief-ui/displacement-image"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"use client" /* eslint-disable @next/next/no-img-element -- This must work outside Next.js. */ import * as React from "react"import { RenderSurface, createQuadProgram, type QuadProgram,} from "@/registry/default/render-surface/render-surface"import { cn } from "@/lib/utils" export type DisplacementImageProps = React.HTMLAttributes<HTMLDivElement> & { from: stringUsage
export function Card() {
return (
<DisplacementImage
from="/covers/before.jpg"
to="/covers/after.jpg"
alt="The studio before and after the rebuild"
className="aspect-[4/3] rounded-xl"
/>
)
}There is always a picture
The first image is rendered as an ordinary img element underneath the canvas. If WebGL is unavailable, if the shader will not compile, or if the second image never loads, the reader sees a normal photograph rather than an empty grey box.
This is also what the reader sees before the textures have finished uploading, which removes the flash of nothing that these effects usually open with.
Two images, one box
Each image is fitted to the box the way object-fit cover would fit it, using its own aspect ratio measured after it loads. A portrait and a landscape photograph can therefore be crossed against each other without either being stretched.
Images from another origin need to permit it. The textures are requested anonymously, so a host that does not send the right header will refuse to be read and only the fallback will show.
Not only the pointer
The crossing follows focus as well as the pointer, so putting a link or a button inside one means a keyboard reader gets the same behaviour. When you would rather drive it from something else -- a scroll position, a carousel index -- pass active and the internal handling steps aside.
API
fromstringThe image shown at rest.tostringThe image crossed to.altstringDescribes the pair. Required, and used for both the fallback image and the canvas.intensitynumberHow far the pixels are pushed during the crossing, as a fraction of the box. Defaults to 0.35.durationnumberSeconds the crossing takes. Defaults to 0.7.activebooleanDrives the crossing yourself. Without it the crossing follows the pointer and focus.childrenReactNodeRendered above the image, for a caption or the link that covers it.Accessibility
The alt text describes the pair and is carried by both the fallback image and the canvas, so the picture is announced once whichever one is showing. The crossing responds to focus as well as hover. It is a transition between two images rather than a loop, so there is nothing running for a reader who is not interacting with it.