loader-buttons
v0.1.0
Published
25 original loading button animations built with WebGL, Canvas, SVG and CSS — headless loaders, a styled button, and a React component. Zero runtime dependencies.
Maintainers
Readme
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.
Install
npm install loader-buttonsQuick start
React
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
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.
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
IntersectionObservermounts 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
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:
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
<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:
.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:
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)
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:
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
