# loader-buttons

25 original loading-button animations built with WebGL 2, Canvas, SVG and CSS —
as a headless loader runtime, a styled button, and a React component.

No runtime dependencies. No build step. Ships as plain ESM.

**[Live gallery →](https://loader-buttons.appllama.io/)**

## Install

```bash
npm install loader-buttons
```

## Quick start

### React

```jsx
import { LoaderButton } from "loader-buttons/react";
import "loader-buttons/styles.css";

function SaveButton({ isSaving, onSave }) {
  return (
    <LoaderButton
      design="mercury-relay"
      label="Saving"
      idleLabel="Save changes"
      loading={isSaving}
      onClick={onSave}
    />
  );
}
```

### Vanilla

```js
import { createLoaderButton } from "loader-buttons";
import "loader-buttons/styles.css";

const button = createLoaderButton(document.querySelector("#toolbar"), {
  design: "mercury-relay",
  label: "Saving",
  onClick: () => save(),
});

button.setLoading(false);
```

### Headless

Mount an animation into an element you already own — no styles, no markup
opinions, just the frame loop and lifecycle management.

```js
import { mountLoader } from "loader-buttons";

const loader = mountLoader(document.querySelector("#spinner"), {
  design: "voronoi-lantern",
  speed: 1.5,
});

loader.pause();
loader.seek(2.4);
loader.destroy();
```

## What the runtime handles for you

- **One frame loop for the whole page.** Every loader on a shared clock renders
  from a single `requestAnimationFrame`, throttled to a frame budget you choose.
- **Nothing runs off-screen.** An `IntersectionObserver` mounts loaders lazily
  and freezes them when they scroll away. The clock stops entirely when no
  loader needs it, so an idle page costs nothing.
- **One WebGL context, shared.** All 13 shader loaders draw through a single
  WebGL 2 context. Programs are freed when a loader stays off-screen, and each
  shader carries a hand-written Canvas 2D fallback for machines without WebGL 2.
- **Sharp on every display.** Device-pixel-ratio changes — a monitor swap, a
  browser zoom — trigger a re-rasterise, capped at a density you set.
- **Reduced motion, honoured.** Loaders freeze for visitors who ask for it.
- **Hidden tabs pause.** No frames are drawn in a background tab.

## Choosing a design

```js
import { designs, designsByKind, getDesign } from "loader-buttons";

designs.length;                 // 25
designs[0].id;                  // "fibonacci-breather"
designsByKind("webgl").length;  // 13
getDesign("mercury-relay").name; // "Mercury Relay"
```

Pass a `design` as an id string, a design object, or a numeric index.

| # | id | kind | # | id | kind |
|---|----|------|---|----|------|
| 01 | `fibonacci-breather` | webgl | 14 | `conic-eclipse` | dom |
| 02 | `glass-tide-lens` | webgl | 15 | `orbital-calligraphy` | svg |
| 03 | `binary-metaball-mitosis` | webgl | 16 | `mercury-relay` | svg |
| 04 | `curl-field-comet` | webgl | 17 | `noise-chrysalis` | svg |
| 05 | `living-reaction-seed` | webgl | 18 | `contour-choir` | svg |
| 06 | `chladni-whisper` | webgl | 19 | `shapeshifter-seal` | svg |
| 07 | `topology-courier` | webgl | 20 | `murmuration-turn` | canvas |
| 08 | `meridian-loom` | webgl | 21 | `frostwork-growth` | canvas |
| 09 | `voronoi-lantern` | webgl | 22 | `sandpile-bloom` | canvas |
| 10 | `event-horizon-pulse` | webgl | 23 | `elastic-cartography` | canvas |
| 11 | `prismatic-caustic-coin` | webgl | 24 | `mechanical-iris` | svg |
| 12 | `voxel-assembly-line` | webgl | 25 | `braille-flipwave` | dom |
| 13 | `strange-attractor-scribe` | webgl | | | |

Import one design on its own if you do not want the rest in your bundle:

```js
import mercuryRelay from "loader-buttons/designs/16-mercury-relay.js";
```

## Button options

Every option below works on the React component as a prop, on
`createLoaderButton` as an option, and on `button.update({ ... })` afterwards.

### Content and state

| Option | Type | Default | |
|---|---|---|---|
| `design` | id, object, or index | — | **Required.** Which animation to run |
| `label` | string | `"Working"` | Text shown while loading |
| `idleLabel` | string | falls back to `label` | Text shown when `loading` is `false` |
| `loading` | boolean | `true` | Collapses and freezes the animation when `false` |
| `disabled` | boolean | `false` | |
| `type` | `"button"`, `"submit"`, `"reset"` | `"button"` | |
| `ariaLabel` | string | design name when there is no label | |

### Appearance

| Option | Type | Default | |
|---|---|---|---|
| `size` | `"sm"`, `"md"`, `"lg"` | `"md"` | 114×42, 150×58, 186×72 |
| `variant` | `"glass"`, `"solid"`, `"outline"`, `"ghost"` | `"glass"` | |
| `theme` | `"light"`, `"dark"`, `"auto"` | `"light"` | `"auto"` follows `prefers-color-scheme` |
| `shape` | `"pill"`, `"rounded"`, `"square"` | `"pill"` | |
| `iconPosition` | `"start"`, `"end"` | `"start"` | |
| `fullWidth` | boolean | `false` | |
| `responsive` | boolean | `true` | Drops one size step below 690px |

### Chrome and motion

| Option | Type | Default | |
|---|---|---|---|
| `tilt` | boolean | `true` | 3D press tilt |
| `sheen` | boolean | `true` | Diagonal highlight sweep on hover |
| `rim` | boolean | `true` | Conic rim light |
| `press` | boolean | `true` | Scale down on press |
| `resetOnClick` | boolean | `true` | Restart the animation on click |
| `resetOnLoading` | boolean | `true` | Restart when `loading` flips back on |
| `replayDuration` | number | `140` | Milliseconds the press animation lasts |

### Design tokens

Each of these sets one CSS custom property. Numbers are read as pixels where a
length is expected.

`width` · `height` · `radius` · `slotSize` · `gap` · `paddingInline` ·
`fontFamily` · `fontSize` · `fontWeight` · `letterSpacing` · `lineHeight` ·
`ink` · `labelColor` · `surface` · `borderColor` · `borderWidth` · `blur` ·
`shadow` · `focusRing` · `pressScale` · `tiltAngle` · `ease` · `duration`

```jsx
<LoaderButton
  design="event-horizon-pulse"
  label="Rendering"
  shape="rounded"
  width={210}
  ink="#c0392b"
  labelColor="#c0392b"
  speed={1.6}
/>
```

`ink` is the colour the animation itself draws with.

### Playback

| Option | Type | Default | |
|---|---|---|---|
| `speed` | number | `1` | `0` freezes, `2` runs at double speed |
| `phaseOffset` | number | `0` | Seconds the animation starts and restarts from |
| `paused` | boolean | `false` | |
| `fps` | number | `30` | Frame budget, clamped to 1–120 |
| `clock` | clock | shared clock for this `fps` | Share one loop across components |
| `maxDpr` | number | `3` | Upper bound on rendered pixel density |
| `pauseOffscreen` | boolean | `true` | |
| `respectReducedMotion` | boolean | `true` | |
| `releaseWebGLOffscreen` | boolean | `true` | |
| `releaseDelay` | number | `1200` | Milliseconds off-screen before WebGL is freed |
| `rootMargin` | string | `"120px 0px"` | |
| `threshold` | number | `0.01` | |

### Callbacks

`onClick(event)` · `onError(error, { design, stage })` · `onFrame(time, delta)`

## Theming with CSS

Every visual decision is a `--lb-*` custom property. Override them anywhere in
the cascade:

```css
.lb-loader-button {
  --lb-width: 200px;
  --lb-radius: 12px;
  --lb-ink: #0f62fe;
  --lb-label-color: #0f62fe;
  --lb-surface: rgba(255, 255, 255, 0.7);
}
```

The stylesheet declares its own box model and never adds a global reset, so it
will not collide with your design system.

## API

### `createLoaderButton(target?, options)`

Returns an instance with `element`, `button`, `slot`, `labelElement`, `loader`,
`design`, `loading`, `disabled`, and `options`, plus:

`update(options)` · `setDesign` · `setLabel` · `setLoading` · `setDisabled` ·
`setVariant` · `setSize` · `setTheme` · `setSpeed` · `reset()` · `play()` ·
`pause()` · `destroy()`

Pass the options object alone to get a detached element you place yourself.
`options.mount` chooses `"append"` (default), `"prepend"`, or `"replace"`.

### `mountLoader(element, options)`

Returns a loader instance with `design`, `time`, `speed`, `paused`, `visible`,
`animating`, `reducedMotion`, `failed`, `destroyed`, and `clock`, plus:

`play()` · `pause()` · `toggle()` · `reset()` · `seek(seconds)` ·
`renderFrame()` · `setSpeed` · `setPhaseOffset` · `setMaxDpr` · `setDesign` ·
`refresh()` · `destroy()`

Every mutator returns the instance, so calls chain.

### `useLoader(elementRef, options)`

The React hook behind the component. Drive an animation inside markup you
render yourself:

```jsx
function Spinner() {
  const ref = useRef(null);
  const loader = useLoader(ref, { design: "chladni-whisper", speed: 0.8 });
  return <span ref={ref} style={{ width: 40, height: 40 }} />;
}
```

### `createClock(options)` / `getSharedClock(fps)`

```js
import { createClock } from "loader-buttons";

const clock = createClock({ fps: 60 });
clock.stop();  // freeze every loader on this clock at once
clock.start();
```

`getSharedClock(fps)` returns the process-wide clock for a frame budget, which
is what loaders use when you do not pass one.

## Authoring your own loader

A design is one object. Nothing else is required:

```js
export default {
  id: "my-loader",
  name: "My Loader",
  kind: "canvas",
  technique: "Short implementation label",
  label: "Working",
  mount(container) {
    return {
      render(timeSeconds, deltaSeconds) {},
      resize(widthCssPixels, heightCssPixels, devicePixelRatio) {},
      reset() {},
      destroy() {},
    };
  },
};
```

Pass it anywhere a design id is accepted. The runtime hands `mount` a
disposable host element of its own, so you may attach a shadow root to it.

Never start your own `requestAnimationFrame`, interval, or timeout — the clock
owns time, which is what makes off-screen pausing and deterministic seeking
work. `createCanvasVisual` and `createShaderVisual` are exported for Canvas 2D
and fragment-shader loaders respectively.

## Browser support

Chrome, Edge, Firefox, and Safari 16.4+. WebGL 2 loaders fall back to Canvas 2D
where WebGL 2 is unavailable. `mountLoader` and `createLoaderButton` throw
outside a browser; importing the package on a server is safe, so the React
component server-renders its markup and mounts the animation on the client.

## Repository

<https://github.com/Appllama/25-unique-loaders> — the package lives in
`packages/loader-buttons/`, alongside the gallery site that consumes it.

## License

Apache-2.0
