# react-pulsor
**Live demo → [react-pulsor.vercel.app](https://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 soft `breathe` to a
strobe `flash` to a double-thump `heartbeat`.
- **Color** — multi-stop gradients sampled in OKLab (sRGB interpolation
detours through gray; OKLab doesn't), mapped by spatial position or by
animation phase.
## Why it stays smooth
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.
## Install
```sh
npm i react-pulsor
```
## Usage
```tsx
import { PulseGrid, PulseBars, PulseDots } from "react-pulsor"
// A 4×4 ripple, aurora palette — the default grid.
// An equalizer: five upright bars breathing through a travelling wave.
// A striped loop — pill scanlines tracing a closed outline.
// The classic typing indicator, with a springy hop.
// A dashed-O spinner around a superellipse loop.
// Custom gradient stops (sampled in OKLab).
```
Or start from a curated recipe:
```tsx
import { PulseGrid, recipes } from "react-pulsor"
```
## Shared props
Every component accepts these, plus any `HTMLAttributes`
(`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 |
## How patterns work
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.
## Motion quality defaults
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. One
`easing` value still overrides both directions.
- **Follow-through.** In `fade-scale` the scale trails the opacity by a
beat (~6% of the period) — elements light up, then swell, instead of
moving in lockstep.
- **Feel.** `feel` sets 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`);
set `appearDelay={300}` to skip showing a loader at all for fast loads.
- **Reduced motion.** Under `prefers-reduced-motion` the 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.
## States & progress
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`.
```tsx
```
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.
```tsx
```
## Streaming intensity
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:
```tsx
const { intensity, ping } = useStreamIntensity({ decay: 900, floor: 0.35 })
// call ping() on every streamed chunk
```
## Accessibility
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}`).
## Recipes
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` | `` | Square rings expanding from the center, slow and glassy. |
| `typing` | `` | The classic three-dot bounce, tuned so the arc reads as a hop, not a wobble. |
| `comet` | `` | One bright head and a long gradient tail — phase coloring does the trailing. |
| `equalizer` | `` | Upright bars breathing through a travelling wave — audio-meter energy. |
| `galaxy` | `` | A spiral phase field — the wave coils out from the center. |
| `static` | `` | Seeded shuffle + strobe envelope: tuned-between-stations TV noise. |
| `glyph` | `` | An O drawn in dashes. Tune aspect and squareness until it matches your wordmark. |
| `monogram` | `` | A badge built from stripes — scanlines around a loop. Tune stroke and squareness to taste. |
| `heartbeat` | `` | One dot, a double-beat envelope. Lub-dub. |
| `ticker` | `` | A wide, low strip of cells — the wave runs left to right like a marquee. |
| `stadium` | `` | A squircle track — the wave does laps around rounded corners. |
| `kelp` | `` | Bars pivoting at the root as the wave passes — wind through grass. |
| `sunburst` | `` | Radial ticks flaring around a ring — a clock face running hot. |
| `drizzle` | `` | Random cells stretching and flickering — rain streaks on a window. |
| `metronome` | `` | Two dots trading places on a hard snap. Minimal, mechanical. |
| `breather` | `` | The whole block inhales together — a calm idle state, not a busy one. |
| `manuscript` | `` | Flat lines growing from the left margin, like text being drafted. |
| `ascent` | `` | A wide chevron fold climbing the strip — directional, insistent. |
| `louver` | `` | Flat slats flipping around their own axis — venetian blinds in a draft. |
| `orbit` | `` | A ring of dots the wave keeps lapping — spinner DNA, gradient body. |
| `beacon` | `` | Twelve small lights flashing fast around a tight ring — familiar shape, live color. |
## Development
```sh
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 typecheck
```
## License
MIT
---
## Guidance for coding agents
Mental model — every loader is four orthogonal dials:
1. **Geometry** — which component and its layout props (grid `rows`/`cols`, bars/dots `count` and `arrangement`).
2. **Pattern** — the phase field: who fires when (`pattern`, `waves`, `seed`).
3. **Envelope** — what one beat looks like (`animate`, `envelope`, `easing`, `period`, or the `feel` macro that sets all three).
4. **Color** — `palette` (preset name, any CSS color, or gradient stops) plus `colorBy` / `gradientAngle`.
Decision guide:
- "a spinner" → ``, or `recipes.orbit` / `recipes.beacon`.
- "typing indicator" → `recipes.typing`.
- "determinate progress" → any component with `progress={0..1}`.
- "loader that reacts to an AI token stream" → `useStreamIntensity()` feeding the `intensity` prop.
- "match my brand" → `palette` takes any CSS color or `[{ color, position }]` stops; `colorBy="linear"` lays one gradient across the whole figure.
- success/failure endings → `state="success"` / `state="error"`.
Common mistakes:
- `arrangement` defaults to `"line"`. Loop-only props (`ringSize`, `aspect`, `squareness`, `stroke`, `align`, and `length`/`thickness` on PulseDots) are silently ignored on a line — set `arrangement="loop"` first.
- Line-only props (`size`, `gap`, and `length`/`origin` on PulseBars) are ignored on a loop.
- `dim`, `restScale`, `progress` and `intensity` are all 0..1.
- No CSS import and no provider: components inject their one keyframe themselves.
- Spread a recipe onto the component matching its `element`: `recipes.typing.element` is `"dots"`, so ``.
## Recipe catalog (ready-to-paste JSX)
### sonar — Sonar
Square rings expanding from the center, slow and glassy.
```jsx
```
### typing — Typing
The classic three-dot bounce, tuned so the arc reads as a hop, not a wobble.
```jsx
```
### comet — Comet
One bright head and a long gradient tail — phase coloring does the trailing.
```jsx
```
### equalizer — Equalizer
Upright bars breathing through a travelling wave — audio-meter energy.
```jsx
```
### galaxy — Galaxy
A spiral phase field — the wave coils out from the center.
```jsx
```
### static — Static
Seeded shuffle + strobe envelope: tuned-between-stations TV noise.
```jsx
```
### glyph — Glyph
An O drawn in dashes. Tune aspect and squareness until it matches your wordmark.
```jsx
```
### monogram — Monogram
A badge built from stripes — scanlines around a loop. Tune stroke and squareness to taste.
```jsx
```
### heartbeat — Heartbeat
One dot, a double-beat envelope. Lub-dub.
```jsx
```
### ticker — Ticker
A wide, low strip of cells — the wave runs left to right like a marquee.
```jsx
```
### stadium — Stadium
A squircle track — the wave does laps around rounded corners.
```jsx
```
### kelp — Kelp
Bars pivoting at the root as the wave passes — wind through grass.
```jsx
```
### sunburst — Sunburst
Radial ticks flaring around a ring — a clock face running hot.
```jsx
```
### drizzle — Drizzle
Random cells stretching and flickering — rain streaks on a window.
```jsx
```
### metronome — Metronome
Two dots trading places on a hard snap. Minimal, mechanical.
```jsx
```
### breather — Breather
The whole block inhales together — a calm idle state, not a busy one.
```jsx
```
### manuscript — Manuscript
Flat lines growing from the left margin, like text being drafted.
```jsx
```
### ascent — Ascent
A wide chevron fold climbing the strip — directional, insistent.
```jsx
```
### louver — Louver
Flat slats flipping around their own axis — venetian blinds in a draft.
```jsx
```
### orbit — Orbit
A ring of dots the wave keeps lapping — spinner DNA, gradient body.
```jsx
```
### beacon — Beacon
Twelve small lights flashing fast around a tight ring — familiar shape, live color.
```jsx
```