# Liquid Image (Moonarc)

Makes an image turn liquid under the cursor and settle back. A velocity field on the GPU (WebGL2 stable fluids, no dye) displaces where the picture is sampled, with a touch of chromatic split along the flow. The picture is a real <img>, which keeps alt, lazy loading, srcset and the LCP candidate, and is what shows without JavaScript or WebGL. The canvas above it takes over only while the fluid moves and hands back once it has settled. object-fit: cover and the image's object-position are matched in the shader. A cross-origin image without CORS, or one larger than the GPU takes as a texture (8192 px a side on many devices), stays a plain image. Every constant is a custom property re-read when the element's style changes.

- Pro block. Install: `npx shadcn@latest add @moonarc-pro/liquid-image` (license key required)
- Tier C · category pointer · trigger pointer
- Readout: `<LiquidImage strength={1} chroma={0.3}>`
- Browser support: widely (Chrome 56 · Firefox 51 · Safari 15); elsewhere: without WebGL2 or a colour-renderable float texture (EXT_color_buffer_float), for a cross-origin image served without CORS, or for an image past the GPU's texture size limit, the plain <img>
- Measured cost: 2.3 kB raw JS · 1.3 kB gzip · + runtime + canvas + gl + fluid, 13.1 kB raw in all (with dependencies 13.1 kB raw; CSS 4.7 kB raw)
- Page: https://moonarc.dev/components/liquid-image/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `src` | `string | ImageMetadata` | none | The image: a URL, or an astro:assets import (its width and height are used). Its first load starts the GPU work; a later srcset candidate replaces the texture. |
| `alt` | `string` | none | Alternative text, on the <img>. |
| `width / height / srcset / sizes / fetchpriority` | `img attributes` | none | Passed to the <img>. |
| `loading` | `'lazy' | 'eager'` | `'lazy'` | Loading of the <img>. Use eager (and fetchpriority="high") for an image above the fold. |
| `strength` | `number` | `1` | How far the picture moves with the fluid: velocity × a 40 ms look-back × strength. |
| `chroma` | `number` | `0.3` | Red and blue sampled apart along the flow, as a share of the displacement. 0 is none. |
| `radius` | `number` | `0.2` | Splat radius as a share of the box. |
| `curl` | `number` | `6` | Vorticity confinement: how much the liquid swirls. 0 is laminar. |
| `settle` | `number` | `1.5` | How fast the picture settles, per second. 3 is stiff, 0.8 lingers. |
| `crossorigin` | `'anonymous' | 'use-credentials'` | none | Passed to the <img>. A cross-origin image needs CORS to be uploaded to the GPU; without it the plain image stays. |

## Usage

```astro
<LiquidImage src="/poster.webp" alt="A poster: the word liquid over an orange disc in concentric rings" width={1440} height={720} class="aspect-[2/1] w-full" />
```

## Reduced motion

The <img> stays: the loop never runs and the canvas is hidden (by the media query, and by the page's own motion switch through the runtime).

## With ClientRouter

Bound through the shared runtime: the loop, observers and pointer listener are released before the swap and the WebGL context is lost on purpose after it; five round trips leave one context. Under transition:persist the canvas keeps its context and starts again on the next page.

## Craft

- The <img> is the component and the canvas is temporary. The canvas is shown on the first frame the fluid moves and hidden again once the field has settled, so an image nobody touches costs no GPU work and stays the browser's own resampling.
- Velocity only: no dye grid, no pressure beyond 12 iterations. The picture is sampled at uv − velocity × 40 ms, so it follows the flow and returns exactly when the flow stops.
- Settled means 4 / settle seconds after the last push, when under 2 % of it is left. Then the loop returns before any GL call. A slow frame simulates at most 1/30 s and damps the velocity for the rest, so the field decays on the clock on any device and is never handed back moving.
- object-fit: cover in the shader from the natural size and the box, placed by the <img>'s computed object-position (so object-top crops both the same way), with a mipmapped upload: the swap from <img> to canvas does not move a pixel.
- Chromatic split along the displacement, not a fixed offset: still parts of the picture stay clean.
- A cross-origin image without CORS throws on upload; the error is caught, the context lost at once, and the image stays an image.
- Pointer force per second from the events' own client deltas (lib/gl): a scroll under a still pointer never stirs it.

## Replaces

- LiquidImage / ImageTrail distortion (React Bits Pro)
- WebGL image hover distortion (Codrops)

## Source

Pro block. The source is served by the license-gated registry: `npx shadcn@latest add @moonarc-pro/liquid-image` with a key in components.json (https://moonarc.dev/account/). Related free primitives: image-trail, tilt.
