Move your pointer across the rose below. Parts of the image break into particles, then settle back into place. On a phone, press and drag. The page still scrolls.
Open the rose demo on its own.
We used this effect in the Heal.Abhi hero. The useful part was not adding more movement. It was keeping the image, text and buttons useful while the effect loaded, paused or failed.
The engine started with the Sculpture Hero source supplied to this project, credited to Guglielmo Giannattasio. We adapted its surrounding code for a plain JavaScript site. This post explains that integration. It does not claim that we wrote the original shader engine.
Keep a real image under the canvas
The first version of an animated hero can be deceptively simple: put a canvas on the page and draw into it.
That leaves a gap before the first frame. It can also leave an empty rectangle when the browser cannot create a WebGL 2 context.
Our wrapper starts with an ordinary image and an invisible canvas. The image gets its alt text. The canvas is decorative and hidden from assistive technology. We reveal the canvas only after the engine reports its first frame.
<div class="stage" data-state="static">
<img src="/rose.webp" alt="A coral rose with green leaves">
<canvas aria-hidden="true"></canvas>
</div>
.stage { position: relative; }
.stage canvas { position: absolute; inset: 0; opacity: 0; }
.stage[data-state="interactive"] canvas { opacity: 1; }
.stage[data-state="interactive"] img { opacity: 0; }
The actual demo also fixes the stage height and sizes the image before showing it. A stable box prevents the article from jumping when the artwork loads.
Give the image and particles the same rectangle
The engine samples the image into particle positions. It needs the rectangle where that image appears, in CSS pixels.
For the rose, the image should fit inside the stage with a little space around it:
const scale = Math.min(
stageWidth / image.naturalWidth,
stageHeight / image.naturalHeight
) * 0.94;
const width = image.naturalWidth * scale;
const height = image.naturalHeight * scale;
const rect = {
x: (stageWidth - width) / 2,
y: (stageHeight - height) / 2,
w: width,
h: height
};
engine.resize(stageWidth, stageHeight, rect);
Apply the same rectangle to the fallback image. If the two layouts disagree, the flower jumps when the canvas appears.
The production hero needs a different crop. A full-width illustrated scene fills its box on desktop. On mobile, the scene occupies the top band and the copy sits below it. Reusing a shader does not mean reusing one crop everywhere.
Use local pointer coordinates
The engine does not need the pointer position relative to the whole document. It needs the position within its stage:
const bounds = stage.getBoundingClientRect();
engine.pointer(
"move",
event.clientX - bounds.left,
event.clientY - bounds.top,
event.pointerType === "touch"
);
The wrapper handles pointer down, up, cancel and leave too. A cancelled touch must release the effect. Otherwise, the image can keep behaving as though a finger is still pressed.
On the rose canvas, touch-action: pan-y lets the browser keep vertical scrolling. The listeners are passive. This trades some gesture control for a page that remains usable on a phone.
The Heal.Abhi wrapper also ignores events from links, buttons and inputs. A decorative image should not make a nearby call button difficult to use.
Pause work that cannot be seen
We use IntersectionObserver to tell the engine when its stage leaves the viewport. The Page Visibility API handles hidden tabs.
Both conditions matter. An element can be inside the page’s viewport while its tab is hidden.
engine.setVisible(inViewport && !document.hidden);
The adapted engine caps the pixel ratio at 2, or 1.5 on its low-power path. It also limits the drawing buffer to roughly 4.5 million pixels. The wrapper uses a lower particle density for coarse-pointer devices.
These are limits in the code, not a claim that the effect is free to run. Test the device and page you plan to ship. A short demo cannot establish your site’s battery use or frame rate.
Reduced motion is a still image
The demo checks prefers-reduced-motion. When that setting is on, it keeps the rose still and does not start the engine.
There is also a visible pause button. A person should not have to change their operating-system settings to stop an effect in one article.
When the setting changes, we destroy the active engine and restore the image. We replace the old canvas before starting again because its WebGL context may have been deliberately released.
Clean up when the page goes away
An animation wrapper owns more than a canvas. It owns observers, pointer listeners, media-query listeners and GPU resources.
Our cleanup aborts the listeners, disconnects both observers and calls engine.destroy(). The engine stops its frame loop and deletes its WebGL buffers, textures, framebuffers and programs.
This is especially useful on a site that swaps pages without a full browser reload. Every visit should not leave another invisible animation running.
A prompt you can copy into your project
Use this with an image and a particle engine whose reuse terms you have checked. The prompt describes the integration contract, so you can adapt an existing implementation without pretending its API is universal.
Add an image-to-particles interaction to this project.
First inspect our framework, styles and page lifecycle.
Use the supplied rose image and the supplied particle engine.
Check the engine's source credit and reuse terms. Preserve them.
Keep a normal image visible before the first rendered frame.
Give it meaningful alt text. Hide the decorative canvas from
assistive technology. Keep the image if WebGL fails.
Use one image rectangle for the canvas and fallback image.
Recalculate it with ResizeObserver. Keep pointer coordinates
local to the stage. Handle pointer up, cancel and leave.
On phones, preserve vertical scrolling. Use passive listeners.
Do not intercept buttons, links or form controls.
Use a still image for prefers-reduced-motion. Add a visible
pause/resume button. Pause outside the viewport and in hidden
tabs. Destroy the engine, observers and listeners on teardown.
Use conservative particle density and a capped drawing buffer.
Avoid adding a framework or animation library just for this.
Test desktop, a narrow phone viewport, pause/resume,
reduced motion, WebGL failure and repeated navigation.
Report what you measured. Do not invent performance numbers.
The demo wrapper and adapted engine are readable in the browser. Keep the original credit and check the original component’s reuse terms before copying it into another project.
The rose was generated for this demonstration. The article cover follows this site’s separate code-generated cover system.