React
npm i stipple-gl react react-domReact is an optional peer dependency. The core has no dependencies at all — the React binding is a thin wrapper over the same engine, and it is a separate entry point so importing the core never pulls React in.
<Particles />
import { Particles } from 'stipple-gl/react';
export function Hero() {
return <Particles mode="background" count={4000} color="#5ec8f2" shape="/shield.svg" morph={1} />;
}The component renders a host <div> and manages the canvas, the engine, and teardown inside it. It accepts every core option as a prop, plus:
| prop | type | description |
|---|---|---|
shape | ShapeConfig | string | null | A shape object, a URL to fetch, or null to disperse. |
morph | number | Target morph, 0 to 1. |
paused | boolean | Stops the loop without tearing anything down. |
onInstance | (instance) => void | Receives the engine on mount and null on unmount. |
className | string | Applied to the host element. |
style | CSSProperties | Merged over the mode's default positioning. |
children | ReactNode | Rendered inside the host, above the canvas. |
Passing a string to shape fetches and parses the SVG, with the request cached across instances and cancelled if the prop changes mid-flight.
Driving the morph from state
function ConsentHero() {
const [accepted, setAccepted] = useState(false);
return (
<>
<Particles
mode="background"
shape={accepted ? '/check.svg' : '/shield.svg'}
morph={1}
color={accepted ? '#5ec8f2' : '#63748c'}
/>
<label>
<input type="checkbox" onChange={(e) => setAccepted(e.target.checked)} />I agree
</label>
</>
);
}In a container
mode="container" sizes the canvas to the host, so give the host a height:
<Particles
mode="container"
className="h-96 w-full overflow-hidden rounded-2xl bg-slate-950"
count={1600}
shape="/logo.svg"
morph={1}
/>Several containers on one page are fine — each scopes its own pointer listeners.
useStipple
When you want the canvas somewhere the component cannot go, or need imperative control:
import { useStipple } from 'stipple-gl/react';
function Custom() {
const { ref, instance, pulse } = useStipple({
count: 2400,
shape: '/star.svg',
morph: 1,
});
return (
<div
ref={ref}
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
pulse(e.clientX - rect.left, e.clientY - rect.top, 0.8);
}}
style={{ position: 'relative', height: 480 }}
/>
);
}instance is null on the first render and set once the engine mounts, so guard before using it.
useMorphOnScroll
Binds an engine to scroll position across a set of marked sections:
import { Particles, useMorphOnScroll } from 'stipple-gl/react';
import { useState } from 'react';
import type { Stipple } from 'stipple-gl';
const shapes = {
brain: '/shapes/brain.svg',
gear: '/shapes/gear.svg',
none: null,
};
function Page() {
const [instance, setInstance] = useState<Stipple | null>(null);
const active = useMorphOnScroll(instance, { shapes });
return (
<>
<Particles mode="background" onInstance={setInstance} />
<section data-stipple-shape="brain">…</section>
<section data-stipple-shape="gear">…</section>
<section data-stipple-shape="none">…</section>
<nav>Currently: {active}</nav>
</>
);
}See scroll.md for the underlying behaviour.
Notes on re-renders
The engine is created once per host element and per mode. Changing mode recreates it; changing anything else applies through setOptions without a rebuild.
Option and shape props are compared structurally rather than by identity, so writing them inline is fine — a re-render that changes no values reaches the engine as nothing at all:
// Rebuilt on every render, but only applied when a value actually differs.
<Particles major={{ size: 8 }} shape={{ paths }} />Functions are the exception. Two closures cannot be shown to be equivalent, so an inline callback always counts as new. That is harmless for onReady and onError, which are only stored — but assign re-samples the shape whenever it changes, so give it a stable reference:
// Outside the component, or wrapped in useCallback with an empty dependency list.
const byIndex: AssignFn = (points, count, spreadX, spreadY, outX, outY, outZ, depth) => {
/* ... */
};
<Particles assign={byIndex} />;count and minorCount are handled separately and only reallocate when the number actually changes.
StrictMode
The binding handles React 18+ StrictMode's double mount correctly — the engine is fully destroyed and rebuilt, with no leaked contexts or listeners.
Server rendering
The engine requires window and throws if constructed on the server. The component only creates it inside an effect, so it is safe in Next.js, Remix, and React Router without a dynamic import. Nothing renders until hydration.
stipple-gl/react ships a 'use client' directive, so in the Next.js App Router you can import it straight into a server component and it becomes a client boundary on its own:
// app/page.tsx — no 'use client' needed here, and no dynamic import
import { Particles } from 'stipple-gl/react';
export default function Page() {
return <Particles count={3500} />;
}Only the React entry carries the directive. Importing stipple-gl itself from a server component is still a mistake — it constructs nothing on import, but there is no reason to send it there.