octoloader
A pixel-art octopus that swims while you wait. A zero-dependency loading indicator for React and plain HTML that follows a loading pattern instead of just spinning.
octoloader is a loading indicator with a character in it: five hand-drawn arm poses, 30×24 pixels each, played as an eight-frame swim; a body that lifts and settles as the arms draw in; a blink, a glance and three bubbles — each on a clock of its own. No canvas, no GIF, no dependencies. The sprite is plain SVG paths, so it stays crisp at any size and follows currentColor in the monochrome variant.
The part that took longer than the octopus was the behaviour. It follows Primer's loading pattern: nothing at all for the first second, an indeterminate swim from one to three seconds, a determinate bar after that. It ships as a React component and as a custom element with a shadow root, weighs 5.2 kB gzipped, and its accessibility is worked out rather than bolted on — the sprite is hidden, the wrapper does the talking, and passing aria-label is a type error that points you to the right prop.
a spinner is not a loading state
A spinner floating in the middle of a page says one thing: something is happening. It doesn't say what's waiting, where the content will land, or how long it's likely to take. And shown for the 200 ms most requests actually take, it reads as a glitch, not as progress.
So the indicator is only half the package. The other half is a panel: a hairline border, a radius and room to breathe, spanning the container instead of hugging the octopus. It marks out the region where the content will land — the same slot an empty state fills when the wait turns up nothing. It's deliberately the same shape as shadcn's Empty and Primer's Blankslate, because one region shouldn't change size and radius depending on which state it's in.
matching the indicator to the wait
Primer defines four windows, and the props map straight onto them. Under a second: show nothing — delay={1000} renders null until it elapses. One to three seconds: the indeterminate swim. Three to ten: a determinate bar, if you can compute progress. Over ten: determinate, and stop blocking the page.
delay is the prop worth wiring up everywhere; it's the difference between a page that feels quick and one that flickers. There are two things it can't do for you. It can't reserve the space it isn't occupying yet — give the slot a min-height. And it can't keep itself on screen: a loader unmounted 80 ms after it appeared flashes just as badly as one shown for 200 ms, so whatever state owns it should hold it for at least ~400 ms.
how it moves
Five arm poses play out and back, with the inner arm running one step behind the outer — that lag is what turns two waving limbs into one travelling wave. The whole animal rises as its arms draw in and settles as they spread, the way an octopus jets. Take the lift away and the eight frames are just a flipbook.
Both halves are easy to break in ways that look like a twitch rather than a bug, so both are pinned by tests. The poses are drawn, not interpolated: tweening between two poses a pixel apart collapses on rounding, and five steps become three drawings while the body keeps moving. And height belongs to the pose, not to the frame number: give the same pose two different heights on the way out and the way back, and the loop stutters even though every frame is correct on its own.
frame 1 2 3 4 5 6 7 8
pose 0 1 2 3 4 3 2 1
lift 0px 0px 1px 1px 2px 1px 1px 0px
swim 0.9 s the arm cycle and the lift
blink 4.6 s eyes shut for 115 ms
glance 7.3 s pupils one pixel left, later one right
bubbles 2.6 s 3.2 s 3.8 s three, drifting up past its left side
six clocks that never line up
The swim runs on 0.9 s, the blink on 4.6 s, the glance on 7.3 s, and the three bubbles on 2.6, 3.2 and 3.8 s. All six come back into step roughly once every 138 days — and that's the point: nobody watching a loading screen ever sees the whole pattern come round. A loop you can learn stops being a character and becomes a progress bar with a face.
The frames sit side by side in one long strip that slides past the viewBox one frame at a time, driven by steps(). The obvious alternative — stacking the frames and cross-fading their opacity — costs one animation per frame per expression, which on a page with several loaders runs into the hundreds and shows up as dropped frames. This way it's eight animations, however many drawings there are, and a frame is never caught mid-swap. And because the viewBox never moves, the eyes can live outside the strip: one set of eyelids and pupils covers whichever frame is showing.
the monochrome variant is three tones, not two
The obvious monochrome is one ink with the eyes punched out. It looks right on a white page and turns into a skull on a black one: the body goes light, the punched-out eyes go dark with the background, and you're looking at sockets.
So only the body follows currentColor. The eyes and suckers sit on --octo-paper, the pupils on --octo-pupil. Keeping the eye lighter than the body and the pupil darker than the eye makes the face read the same way on either background, so one sprite covers both. The determinate bar is built from the same three tones — frame in ink, track in paper, fill in pupil — and if any two merge, the bar stops showing its value. A test keeps all three distinct in both palettes, because this is exactly the change that looks fine in light mode and breaks in dark.
accessibility, and where the boundary is
The sprite is aria-hidden, so nothing is announced twice; the wrapper does the talking. Without a progress value it's role="status"; with one it's role="progressbar" with the aria-value* trio. Either way it takes its name from the label people can actually see, so the spoken name and the visible one can't drift apart — pass announce when they genuinely should differ.
Attributes the component works out for itself are removed from the prop type rather than silently overwritten, so passing aria-label is a compile error that points you to announce. Two things stay on the consumer's side of the boundary, and the docs say so instead of guessing: aria-busy belongs on the region whose content is being replaced, not on the loader, and focus should move into the new content once it lands.
Under prefers-reduced-motion, the swim, blink, glance and bubbles all stop and the octopus gently pulses in place. It keeps moving on purpose — Primer asks for loading animation to stay subtle, not to stop, because a frozen indicator is indistinguishable from a hung one — but nothing travels across the screen.
editing the octopus
src/sprite.ts is the drawing, and it's literally a picture in text: each frame is an array of strings, one character per pixel. . transparent, o outline, b body, w eye white, c iris, p pupil, g sucker. Change a character and you change the octopus — the renderer collapses runs of the same character into one SVG path per colour at import time, so there's no build step between you and the pixels.
The test suite guards the drawing, not just the code. Every frame has to be a full rectangle, use only characters both palettes know, and contain no background pixel sealed inside the silhouette — that last one catches the stray hole that's invisible at 12× and reads as dirt at 1×. Other tests hold the shape of the animation: no two adjacent frames may be identical, the arms have to sweep out and back rather than loop one way, and the monochrome palette has to resolve to three distinct tones.
footprint
5.2 kB gzipped for the React entry, 5.4 kB for the custom element. No runtime dependencies; React is an optional peer, so importing octoloader/element on a page without React pulls in nothing extra. One stylesheet, no matter how many loaders — it's rendered as a hoisted <style> that React 19 lifts into <head> and de-duplicates. The component is wrapped in memo, so a loader inside something that re-renders on every keystroke doesn't re-reconcile its twenty-odd SVG nodes.
There's no polyfill and no fallback path, because there's nothing to fall back to: without the animation you have a still octopus, which still says waiting.