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

Gabriel Hidalgo - UX/UI Product Designer

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.

tsx
<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

Gabriel Hidalgo - UX/UI Product Designer

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.

tsx
<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

PropTypeDefaultDescription
srcstring-Image path, passed to next/image.
altstring-Alt text for the base image. The canvas is aria-hidden.
classNamestring-Sizing and aspect classes for the wrapper, e.g. "aspect-[3/4] w-full". The wrapper is already relative, overflow-hidden and rounded-sm.
imageClassNamestring'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.
prioritybooleanfalseAbove the fold: eager-load the image (next/image priority). Otherwise it lazy-loads.
effectDisplacementOptions{ 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.

  • grid50
  • cellAspect1
  • radius0.1
  • strength0.1
  • threshold1000
  • relaxation0.9
  • shift1
  • aberration1.5
  • grain0.1
  • grainSize1
  • grainSpeed1
  • scramble1
  • anchor0.50

Behaviour

  • Entrance. The card is held at opacity 0 until it enters the viewport (the same -100px margin 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: none at 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.