punktraster
v0.1.1
Published
Canvas-rendered dot raster preloader. Zero dependencies, one rAF for the whole page.
Maintainers
Readme
Configurable rows, columns, tile shape, and animation pattern. One requestAnimationFrame drives every instance on the page.
Punktraster is German for a dot raster, the screen that renders a continuous tone as a grid of dots. That is the whole component in one word.
- One
<canvas>per loader, not one node per tile. - Zero runtime dependencies. 3.0 kB min+gzip for the core, 0.3 kB for an adapter.
- Vanilla core, with React and Solid adapters over the same engine.
- 400 tiles cost about 0.12 ms per frame.
Install
npm install punktrasterESM only. React and Solid are optional peers; install whichever you use.
Use it
Vanilla
import { createGridLoader } from "punktraster";
const loader = createGridLoader(document.querySelector("canvas"), {
rows: 5,
cols: 5,
pattern: "helix",
});
loader.update({ speed: 2 });
loader.stop();
loader.destroy();The canvas is yours. The library sizes it, paints it, and sets its ARIA attributes, and it never creates or inserts DOM.
React
import { GridLoader } from "punktraster/react";
<GridLoader size={10} pattern="ripple" className="text-blue-500" />;Color defaults to the canvas's inherited currentColor, so a text color class is enough.
For a canvas you own, use the hook:
import { useGridLoader } from "punktraster/react";
function Spinner() {
const { ref, handle } = useGridLoader({ rows: 8, cols: 8 });
return <canvas ref={ref} />;
}Prop changes flow through update(), so the animation phase survives every re-render. An inline delay={(r, c) => ...} is a new function each render, so wrap it in useCallback or hoist it.
Solid
import { GridLoader } from "punktraster/solid";
<GridLoader size={10} pattern="sweep" class="text-blue-500" />;The ./solid entry ships preserved JSX under the solid export condition, so your Solid compiler emits the right output for the browser, the server, or hydration. This is why renderToString works.
Options
| Option | Default | Notes |
| ------------------- | -------------- | ------------------------------------------------------------------------------- |
| rows, cols | 5 | Any size. Above ~2500 tiles, see the limits below. |
| shape | "circle" | "circle" or "square". |
| radius | 0 | Corner radius in CSS px for squares, clamped to half the tile. |
| size | 5 | A 5x5 grid measures size * 3 CSS px, matching the loader this is modelled on. |
| tileSize | derived | Explicit tile edge in CSS px. Overrides size. |
| gap | 0.75 * tile | Explicit gap in CSS px. |
| speed | 1 | Multiplier. 1 is a 2600 ms cycle. |
| color | currentColor | Any CSS color. Omit to track the canvas's computed color. |
| pattern | "helix" | helix, ripple, sweep, random, download, or upload. |
| delay | none | (row, col, rows, cols) => number in 0..1. Overrides pattern. |
| label | "Loading" | Sets role="status". Pass null for aria-hidden. |
| autoplay | true | |
| reducedMotion | "auto" | "off" opts out of the reduced-motion collapse. |
| respectVisibility | true | Pause while scrolled out of view. |
createGridLoader returns { canvas, width, height, start, stop, update, render, destroy }. stop() freezes on the current frame and start() continues from the same phase. destroy() is idempotent.
Patterns
Each preset is a one-line function of a tile's position returning a normalized phase delay.
- helix phases by angle around the center via
Math.atan2. The brightness curve has two peaks half a cycle apart, so the wave reads as a double arm sweeping around. - ripple phases by distance from the center.
- sweep phases linearly across the diagonal.
- random phases by a stateless hash of the position, so it is identical across renders, instances, and reloads.
- download and upload phase along a chevron, so a V travels down or up the field and reads as a moving arrow. Give them more rows than columns for the clearest read.
Write your own with delay. Any finite return value is wrapped into 0..1, and non-finite values become 0.
How it stays cheap
- One shared ticker. Every live instance shares a single
requestAnimationFrame. 200 loaders schedule one frame, verified indemo/verify.html. - One baked sprite. The tile is drawn once to an
OffscreenCanvasand blitted per tile withdrawImage, so antialiasing is computed once instead ofrows * colstimes per frame. Instances with matching geometry and color share a single sprite. - A lookup table. The keyframe walk and its cubic-bezier easing are resolved once at import into a 512-entry
Float32Array. Per tile per frame the brightness is one add, one multiply, one truncate, one mask, and one array read. No allocation, no branch, noMathcall. - Absolute-time phase. Phase comes from the current timestamp, never from accumulated deltas, so jank cannot make it drift and a backgrounded tab resumes exactly where it should.
- It stops when it cannot be seen. Instances pause on
document.hiddenand, by default, while scrolled out of view.
Measured in Chrome, unminified build, one instance:
| Grid | Tiles | Per frame | | ----- | ----- | --------- | | 5x5 | 25 | 0.008 ms | | 12x12 | 144 | 0.044 ms | | 20x20 | 400 | 0.117 ms | | 50x50 | 2500 | 0.81 ms |
Limits worth knowing
- Every tile has its own alpha, so draw calls cannot be batched. One
drawImageper tile per frame is the floor. Past roughly 2500 tiles you are into multi-millisecond frames, and the answer there is WebGL rather than a cleverer 2D loop. - Sizing goes through props, not CSS. The library sets inline
widthandheight, and inline styles beat a stylesheet rule, so a.spinner { width: 40px }is ignored. - The footprint lands within one device pixel of the ideal. Tiles are placed on integer device pixels so each blit is 1:1 and nothing shimmers. A 5x5 at the default size is exactly 15 CSS px at integer device pixel ratios; at 1.5x the box has to round, because 22.5 device pixels does not exist.
currentColoris polled, not observed. An instance without an explicitcolorre-reads its computed color twice a second from inside its own tick, and immediately on aprefers-color-schemechange. A theme switch can therefore take up to 500 ms to reach the canvas. Passcolorto skip this entirely.- Many separate canvases cost compositor memory regardless of how cheap the JavaScript is. Hundreds of loaders on one page would be better served by one canvas drawing many grids, which this does not do.
Reduced motion
Under prefers-reduced-motion: reduce the grid collapses to a single slow pulse on a fixed 3 second cycle with one alpha for all tiles. It reacts live when the setting changes. Pass reducedMotion: "off" to opt out.
Development
Built on Vite+.
npm run build # vp pack: Rolldown build, .d.ts, publint, attw
npm test # vp test: 111 tests
npm run check # vp check: oxfmt + oxlint
npm run typecheck # tsc, two projects
npm run demo # vp dev on :4611, opens the landing page
npm run hero # re-render assets/hero.gif from the built libraryThe site is index.html plus three demo pages, all dependency-free. Every page imports dist relative to itself, so the deployed layout mirrors the repo and no paths get rewritten:
index.htmlis the landing page, with live rasters rather than a recording.demo/gallery.htmlis the one to open first. Twenty specimens side by side, each a live canvas with the exact options that produced it and a copy button, plus a color row and real placements: inside a button, inline with text, in list rows, as a typing indicator, and behind a blocking overlay.demo/index.htmlis the playground: every pattern and shape, live speed and size sliders, a theme toggle to watchcurrentColortrack, and a 500-instance panel with a frame rate readout.demo/verify.htmlprints the footprint and per-frame measurements, and is where the numbers above come from.
The test suite is mostly pure functions, so the wave, the patterns, and the geometry are pinned without a DOM. Two tests are load-bearing beyond their size: one asserts the main canvas builds zero paths during ticks, which locks in the sprite architecture, and one compiles the published Solid entry for both the browser and the server, which is the only proof that shipping preserved JSX works.
Releasing
.github/workflows/ci.yml runs check, typecheck, test, and build on every push and pull request. The build step also runs publint and attw, so packaging regressions fail CI rather than reaching npm.
Publishing is gated on a version tag, so nothing reaches the registry until you push one:
npm version patch # writes the version and the matching v* tag
git push --follow-tags.github/workflows/publish.yml then re-runs the whole suite, refuses to continue when the tag disagrees with package.json, and publishes with npm provenance.
The first release needs credentials
Trusted publishing cannot perform a package's first publish, because npm requires the package to exist before a trusted publisher can be attached to it. So the order is:
npm install -g npm@latest # npm 10 has no `trust` command
npm login
npm publish # claims the name and creates the packageThen attach the trusted publisher, either on npmjs.com under the package's Access settings, or from the CLI. npm trust needs npm 11.15 or later and account-level 2FA, and it reads the repository from package.json:
npm trust github punktraster --file publish.yml --allow-publishFrom then on every tag publishes keyless, and the NPM_TOKEN secret and its env block in the workflow can be deleted.
Trusted publishing also needs npm 11.5.1 or later. Node 22 still bundles npm 10, so the workflow upgrades npm before publishing; without that step OIDC fails with ENEEDAUTH.
The repository field must stay accurate at mono424/punktraster, since npm resolves the README's relative image paths and the provenance link through it.
The hero image is committed rather than built in CI, because rendering it needs Chrome. Re-run npm run hero when the default look changes.
License
MIT
