Displaced Portrait
The About portrait with a cell-displacement treatment: a WebGL2 canvas over a normal Image, split into a grid of cells that fast cursor sweeps shear apart into offset, colour-fringed tiles before they settle back. When the card scrolls into view every cell is scrambled and relaxes into the photo — that is its entrance, replacing FadeIn.
Portrait

Sweep the cursor across it quickly — slow movement is gated out so the picture stays legible while you read. Reload the page to see the scramble-in again.
<DisplacedPortrait
src="/images/gabriel-hidalgo-about-page.png"
alt="Gabriel Hidalgo - UX/UI Product Designer"
className="aspect-[3/4] w-full"
priority
/>Small and circular

The mobile avatar on the home About module. rounded-full in className wins over the wrapper’s rounded-sm; the crop is pinned 15% from the top like the image; and grid: 18 keeps the cells about 5px so the scramble still reads at 96px.
<DisplacedPortrait
src="/images/gabriel-hidalgo-about-page.png"
alt="Gabriel Hidalgo - UX/UI Product Designer"
className="h-24 w-24 rounded-full"
imageClassName="object-[center_15%]"
effect={{ grid: 18 }}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
src | string | - | Image path, passed to next/image. |
alt | string | - | Alt text for the base image. The canvas is aria-hidden. |
className | string | - | Sizing and aspect classes for the wrapper, e.g. "aspect-[3/4] w-full". The wrapper is already relative, overflow-hidden and rounded-sm. |
imageClassName | string | 'object-top' | Object-position class for the base image, e.g. "object-[center_15%]" for the circle avatar. The canvas reads the computed object-position and crops to match, so there is no second prop to keep in sync. |
priority | boolean | false | Above the fold: eager-load the image (next/image priority). Otherwise it lazy-loads. |
effect | DisplacementOptions | { grid: 44, threshold: 500, scramble: 1.4, relaxation: 0.93 } | Engine tuning merged over the portrait defaults. Keys: grid, cellAspect, radius, strength, threshold, relaxation, shift, aberration, grain, grainSize, grainSpeed, scramble. |
Engine defaults
Canvas UI’s published defaults, from lib/displacement.ts. The portrait overrides four of them for a ~370px card: a coarser grid, a lower speed threshold so a natural sweep registers, a bigger scramble and slower relaxation so the reveal takes about a second and a half to settle.
grid50cellAspect1radius0.1strength0.1threshold1000relaxation0.9shift1aberration1.5grain0.1grainSize1grainSpeed1scramble1anchor0.50
Behaviour
- Entrance. The card is held at opacity 0 until it enters the viewport (the same
-100pxmargin FadeIn uses), then the cells are scrambled and relax into place. On /about the portrait is above the fold, so it plays on load; on the home page it plays when you reach the About section. - Hover. Mouse movement faster than the threshold pushes the cells it passes. Touch and pen are ignored so scrolling over the card on a tablet doesn’t shear it.
- Idle. The render loop stops as soon as the field settles and pauses off screen or in a background tab. The engine and its WebGL context are only created on that first intersection, so a copy that is
display: noneat the current breakpoint never allocates anything. - Settle coast. Once the field settles the loop keeps requesting frames for 3s without drawing before it goes idle. Ending the frame cadence abruptly while a FadeIn spring was still running beside the card made Chrome drop one frame of that element (the /about bio blinked once, ~2s after load). The canvas is also opaque, since the photo fully covers the image beneath it.
- Fallbacks. Reduced motion, no WebGL2, or a lost context: the canvas stays hidden and the card just fades in over the base Image, which is always rendered underneath and carries the alt text.
- Provenance. Port of Canvas UI’s Displacement (MIT + Commons Clause). The original captures DOM through Chrome’s experimental html-in-canvas API and shows a plain image everywhere else; ours uploads the photo as a texture directly, so it runs in every WebGL2 browser.