# 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 ```