Live demo → react-pulsor.vercel.app
Composable React loading indicators. Grids, bars and dots — in lines or loops — driven by phase fields, motion envelopes and OKLab gradients, one compositor-only keyframe, zero dependencies, no CSS import.
Most loaders are one baked-in animation with a size knob. Pulsor instead gives you four orthogonal dials, and every combination is a different loader:
- Geometry — a cell grid (
PulseGrid), bars in a line or stacked into a striped loop (PulseBars), and dots in a row or riding a superellipse loop (PulseDots). - Pattern — the phase field: how the wave travels the figure. Sweeps, diagonals, ripples, spirals, chevrons, boustrophedon snakes, seeded sparkle, or everything in sync.
- Envelope — the intensity curve of one beat, ADSR-style
(
attack/hold/release), with presets from a softbreatheto a strobeflashto a double-thumpheartbeat. - Color — multi-stop gradients sampled in OKLab (sRGB interpolation detours through gray; OKLab doesn't), mapped by spatial position or by animation phase.
Every element shares a single opacity/transform keyframe; the pattern
only assigns each element a negative animation-delay (its phase in the
loop). The browser composites the whole thing off the main thread, so the
loader keeps ticking while your app is busy doing the work the user is
waiting for — exactly when a loader matters. Negative delays also mean the
animation mounts mid-flight: no awkward fill-in ramp on first paint.
Styles are injected once at runtime (useInsertionEffect, deduped by
config), so there is nothing to import and nothing to configure.
npm i react-pulsorimport { PulseGrid, PulseBars, PulseDots } from "react-pulsor"
// A 4×4 ripple, aurora palette — the default grid.
<PulseGrid />
// An equalizer: five upright bars breathing through a travelling wave.
<PulseBars envelope="breathe" waves={1.5} palette="lagoon" />
// A striped loop — pill scanlines tracing a closed outline.
<PulseBars arrangement="loop" ringSize={32} stroke={10} squareness={2.2} animate="fade" />
// The classic typing indicator, with a springy hop.
<PulseDots animate="bounce" easing="spring" period={800} />
// A dashed-O spinner around a superellipse loop.
<PulseDots arrangement="loop" count={12} length={7} thickness={3} aspect={0.88} squareness={2.6} />
// Custom gradient stops (sampled in OKLab).
<PulseGrid
palette={[
{ color: "#B6D3EF", position: 0 },
{ color: "#F888A0", position: 1 },
]}
pattern="spiral"
rows={5}
cols={5}
/>Or start from a curated recipe:
import { PulseGrid, recipes } from "react-pulsor"
<PulseGrid {...recipes.sonar.props} />Every component accepts these, plus any HTMLAttributes<HTMLSpanElement>
(className, style, …).
| Prop | Type | Default |
|---|---|---|
palette |
preset name | CSS color | GradientStop[] |
"aurora" |
colorBy |
"position" | "phase" | "linear" |
"position" |
gradientAngle |
number degrees — direction for colorBy="linear" |
45 |
animate |
"fade" | "scale" | "fade-scale" | "stretch" | "bounce" | "sway" | "flip" |
per component |
feel |
"calm" | "crisp" | "lively" | "urgent" — personality macro |
— |
envelope |
preset | { attack, hold?, release } | frames |
"pulse" |
easing |
"linear" | "smooth" | "snap" | "drift" | "spring" | any CSS timing fn |
auto (per envelope) |
period |
number — ms per sweep |
900 × feel |
waves |
number — simultaneous wavefronts |
1 |
dim |
number 0..1 — resting opacity |
per mode |
restScale |
number 0..1 — resting scale (scale/stretch) |
per mode |
amplitude |
number — px for bounce; degrees for sway / flip |
per mode |
seed |
number — deterministic sparkle shuffle |
7 |
state |
"loading" | "success" | "error" |
"loading" |
progress |
number 0..1 — determinate fill in pattern order |
— |
successColor |
string |
"#34D399" |
errorColor |
string |
"#F87171" |
intensity |
number 0..1 — live activity level |
1 |
appear |
boolean — 200ms fade/scale entrance on mount |
true |
appearDelay |
number ms — stay invisible before entering (anti-flash) |
0 |
label |
string — aria-label |
"Loading" |
respectReducedMotion |
boolean — freeze under prefers-reduced-motion |
true |
Palettes: aurora · ember · lagoon · ultraviolet · sherbet ·
glacier · moss · nocturne · mono. Exported as palettes; the OKLab
sampler is exported as sampleGradient(stops, t).
Bar-native motion: sway rotates each bar around its pivot (origin —
a bottom pivot reads as wind through grass, center as a metronome), and
flip turns elements around their own long axis in 3D, louver-style; on a
grid it becomes a tile-flip wave. Both are plain transforms, compositor-only.
Envelopes: pulse (sharp rise, long tail) · breathe (continuous
sine-like swell) · flash (strobe) · heartbeat (double thump). Custom
shapes are { attack, hold, release } as fractions of one period — the
remainder is rest — or an explicit [{ at, level }, …] frame list.
A rows × cols matrix of cells swept by the pattern.
| Prop | Type | Default |
|---|---|---|
pattern |
"ripple" | "sweep-up" | "sweep-down" | "sweep-left" | "sweep-right" | "diagonal" | "chevron" | "snake" | "spiral" | "sparkle" | "pulse" |
"ripple" |
rows |
number |
4 |
cols |
number |
4 |
cellSize |
number px |
6 |
gap |
number px |
3 |
radius |
number px |
1.5 |
Bars in a line (equalizer, drafting strip) or stacked into a striped
loop — parallel pills traced along a closed superellipse outline, each
reaching stroke px inward; the middle rows split in two around the
opening. aspect, squareness and stroke shape the silhouette — it can
match anything from a round badge to a typeface's O. Pair with
colorBy="linear" for one gradient flowing through every pill.
| Prop | Type | Default |
|---|---|---|
pattern |
"wave" | "wave-reverse" | "center" | "edges" | "alternate" | "sparkle" | "pulse" |
"wave" |
arrangement |
"line" | "loop" |
"line" |
count |
number — bars (stripes in a loop) |
5 line, 8 loop |
orientation |
"vertical" | "horizontal" — stripe direction |
"vertical" line, "horizontal" loop |
thickness |
number px |
4 line, 3 loop |
length |
number px (line only) |
18 |
gap |
number px (line only) |
3 |
radius |
number px |
2 |
origin |
"center" | "start" | "end" — fixed end for stretch (line only) |
"center" |
ringSize |
number px — loop height (loop only) |
28 |
aspect |
number — loop width ÷ height (loop only) |
1 |
squareness |
number — superellipse exponent (loop only) |
2 |
stroke |
number px — pill reach inward from the outline (loop only) |
ringSize * 0.3 |
A row of dots (the typing indicator and its relatives), or — with
arrangement="loop" — elements spaced uniformly by arc length around a
superellipse loop: dots when length === thickness, ticks when elongated.
align="tangent" traces the outline like a dashed O; align="radial"
points every tick at the center, clock-style.
| Prop | Type | Default |
|---|---|---|
pattern |
same as bars | "wave" |
arrangement |
"line" | "loop" |
"line" |
count |
number |
3 line, 8 loop |
size |
number px — dot diameter (line only) |
7 |
gap |
number px (line only) |
4 |
ringSize |
number px — loop height (loop only) |
28 |
aspect |
number — loop width ÷ height |
1 |
squareness |
number — 2 ellipse, 4 squircle, 1 diamond |
2 |
align |
"tangent" | "radial" (loop only) |
"tangent" |
length / thickness |
number px — loop element box |
6 / 6 |
radius |
number px |
half the element |
A pattern is a distance field d over the elements — cells at equal
distance fire together. Each element's delay is -(d / (max + 1)) × period,
and waves multiplies how many wavefronts travel the field at once. A few
of the fields:
- ripple — Chebyshev distance from the grid center (expanding rings)
- spiral — visit order of an outward rectangular spiral
- chevron —
(rows − 1 − row) + |col − center|, a fold climbing the grid - snake — boustrophedon path, row by row
- sparkle — a seeded Fisher–Yates shuffle (deterministic across SSR)
The ring/letter geometry is exported too: superellipsePoints(count, w, h, squareness) returns arc-length-uniform points with tangent/radial angles,
and stripedO(...) returns the scanline segments of a striped letter O.
Some of the craft is built in — nothing to configure:
- Directional easing. Without an explicit
easing, every envelope carries a curated rise/fall pair: rising segments decelerate into the peak, falling segments decay fast-then-slow like a light dying. Oneeasingvalue still overrides both directions. - Follow-through. In
fade-scalethe scale trails the opacity by a beat (~6% of the period) — elements light up, then swell, instead of moving in lockstep. - Feel.
feelsets envelope, easing and tempo together:calm(slow sinusoidal breathing),crisp(the standard pulse),lively(wind-up dip, overshooting springy peak),urgent(strobe + snap at speed). Explicit props always win. - Entrances. Loaders fade/scale in over 200ms on mount (
appear); setappearDelay={300}to skip showing a loader at all for fast loads. - Reduced motion. Under
prefers-reduced-motionthe loop becomes a very slow opacity breath — vestibular-safe, but still visibly alive — instead of a frozen frame.
Envelope frame levels may leave [0, 1]: slightly negative for
anticipation wind-ups, above 1 for overshoot.
Loaders need an exit. Set state="success" and a grid morphs into a ✓
(the stencil is rasterized for any rows × cols); state="error" settles
into a ✗ and shakes once. Bars and dots settle whole into the state
color, staggered along the pattern's order. Both one-shot animations respect
prefers-reduced-motion.
<PulseGrid rows={5} cols={5} state={done ? "success" : "loading"} />Pass progress (0..1) for a determinate loader: elements light up in
pattern order — a spiral grid fills by coiling, a ring fills like an arc,
bars fill like a meter.
<PulseBars count={12} progress={uploaded / total} />Made for AI products: feed intensity from a token stream and the loader
breathes with it — bright and full while chunks arrive, dimming toward idle
in the gaps. useStreamIntensity handles the decay:
const { intensity, ping } = useStreamIntensity({ decay: 900, floor: 0.35 })
// call ping() on every streamed chunk
<PulseDots intensity={intensity} />Each loader renders role="status" with a configurable label. Under
prefers-reduced-motion: reduce the animation freezes to a readable
mid-state (opt out with respectReducedMotion={false}).
A curated set of configurations ships as recipes. Each is
{ element, title, blurb, props }; spread the props onto the matching
component. The demo site's gallery is rendered straight from this export.
| Recipe | Component | Description |
|---|---|---|
sonar |
<PulseGrid /> |
Square rings expanding from the center, slow and glassy. |
typing |
<PulseDots /> |
The classic three-dot bounce, tuned so the arc reads as a hop, not a wobble. |
comet |
<PulseDots /> |
One bright head and a long gradient tail — phase coloring does the trailing. |
equalizer |
<PulseBars /> |
Upright bars breathing through a travelling wave — audio-meter energy. |
galaxy |
<PulseGrid /> |
A spiral phase field — the wave coils out from the center. |
static |
<PulseGrid /> |
Seeded shuffle + strobe envelope: tuned-between-stations TV noise. |
glyph |
<PulseDots /> |
An O drawn in dashes. Tune aspect and squareness until it matches your wordmark. |
monogram |
<PulseBars /> |
A badge built from stripes — scanlines around a loop. Tune stroke and squareness to taste. |
heartbeat |
<PulseDots /> |
One dot, a double-beat envelope. Lub-dub. |
ticker |
<PulseGrid /> |
A wide, low strip of cells — the wave runs left to right like a marquee. |
stadium |
<PulseDots /> |
A squircle track — the wave does laps around rounded corners. |
kelp |
<PulseBars /> |
Bars pivoting at the root as the wave passes — wind through grass. |
sunburst |
<PulseDots /> |
Radial ticks flaring around a ring — a clock face running hot. |
drizzle |
<PulseGrid /> |
Random cells stretching and flickering — rain streaks on a window. |
metronome |
<PulseDots /> |
Two dots trading places on a hard snap. Minimal, mechanical. |
breather |
<PulseGrid /> |
The whole block inhales together — a calm idle state, not a busy one. |
manuscript |
<PulseBars /> |
Flat lines growing from the left margin, like text being drafted. |
ascent |
<PulseGrid /> |
A wide chevron fold climbing the strip — directional, insistent. |
louver |
<PulseBars /> |
Flat slats flipping around their own axis — venetian blinds in a draft. |
orbit |
<PulseDots /> |
A ring of dots the wave keeps lapping — spinner DNA, gradient body. |
beacon |
<PulseDots /> |
Twelve small lights flashing fast around a tight ring — familiar shape, live color. |
Machine-readable docs live at
react-pulsor.vercel.app/llms.txt
(index) and
/llms-full.txt
(full reference with every recipe expanded to JSX). The npm package also
ships an AGENTS.md cheatsheet next to this README.
npm install # installs the library + demo site (npm workspaces)
npm run site # demo/playground at http://localhost:3030
npm run build # tsup → dist (ESM + CJS + d.ts)
npm run typecheckMIT