Skip to content

Repository files navigation

Intermission

Loaders, in three prints.

Six states. Three visual worlds. One API.

Most loader libraries give you one look and hope it suits your product. Intermission fixes the states and lets you swap the world. The state vocabulary is identical across every print, so changing worlds is a one-line change that never moves a pixel of layout.

Academy

Academy - the six states

Grid

Grid - the six states

Tape

Tape - the six states

Try them live → - switch prints, change speed, scrub through states.

npm install intermission

React is an optional peer dependency - the core works without it.


Quick start

import { Loader, IntermissionProvider } from "intermission/react";
import { tape } from "intermission/tape";

export default function App() {
  return (
    <IntermissionProvider print={tape}>
      <Loader state="thinking" />
      <Loader state="done" size={20} />
    </IntermissionProvider>
  );
}

Set the print once at the root and every loader below follows it. Or pass one per instance, with no provider at all:

import { Loader } from "intermission/react";
import { grid } from "intermission/grid";

<Loader state="searching" print={grid} size={48} />;

Next.js App Router

A Print is an object of drawing functions, and functions cannot be passed from a Server Component to a Client Component. So this fails:

// app/page.tsx - a Server Component by default
import { IntermissionProvider } from "intermission/react";
import { tape } from "intermission/tape";

<IntermissionProvider print={tape}>  {/* ✗ Functions cannot be passed… */}

Import the print inside a client boundary instead, so it never crosses:

// app/providers.tsx
"use client";

import { IntermissionProvider } from "intermission/react";
import { tape } from "intermission/tape";
import type { ReactNode } from "react";

export function Providers({ children }: { children: ReactNode }) {
  return <IntermissionProvider print={tape}>{children}</IntermissionProvider>;
}
// app/page.tsx - still a Server Component
import { Loader } from "intermission/react";
import { Providers } from "./providers";

export default function Home() {
  return (
    <Providers>
      <Loader state="thinking" />
    </Providers>
  );
}

<Loader> itself is fine in a Server Component - state, size and speed are all serializable. It is only print that has to stay client-side.

The same applies to print={grid} as a direct prop: pass it from a client component, or use the provider.


Recipes

Drive it from your own state

The state names are deliberately generic, so they can come straight off an existing machine without a mapping layer:

const [phase, setPhase] = useState<LoaderState>("listening");

// …later
setPhase(response.streaming ? "composing" : "done");

<Loader state={phase} />;

Inline with text

At small sizes, let the text do the announcing and hide the loader from screen readers with label={null}:

<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
  <Loader state="searching" size={16} label={null} />
  Searching your documents…
</span>

Two prints on one page

print overrides the provider per instance:

<IntermissionProvider print={academy}>
  <Loader state="working" />              {/* academy */}
  <Loader state="working" print={grid} /> {/* grid    */}
</IntermissionProvider>

Match the surface to the print

Each print declares the ground it was drawn for:

<div style={{ background: tape.ground }}>
  <Loader state="thinking" print={tape} />
</div>

Slow it down, or hold it still

<Loader state="thinking" speed={0.5} />  {/* half speed        */}
<Loader state="done" paused />           {/* freeze on a still */}

paused defaults to the user's prefers-reduced-motion setting, so passing it explicitly is an override in both directions.


The six states

Every print implements all six. Five loop; done plays once and holds.

State For
thinking Working something out, no visible progress
searching Looking through a corpus, scanning, retrieving
listening Waiting on input - a mic, a stream, a webhook
working A task in flight with a definite end
composing Assembling output - writing, drafting, building
done Finished. Settles once, then holds.

Because the vocabulary is fixed, state can be driven straight from your own machine without a mapping layer.


The three prints

Print Reference The rules it never breaks
Academy Wes Anderson Bilateral symmetry every frame · stepped timing, nothing eases · flat fill, no gradients · built for a cream ground
Grid TRON No curves - every ring is a polygon · additive glow only · trails decay behind the head · orange stays rare enough to mean something
Tape VHS Colour channels drift apart · scanlines over everything · rows occasionally slip · shapes stay plain, the medium carries them

The reference lives in the motion grammar, not in costume. That's what keeps these usable next to a paragraph of text instead of reading as a novelty.

Each print declares the ground it was designed against:

import { academy } from "intermission/academy";

academy.ground; // "#F2E8D5"
academy.palette; // ["#2B2724", "#8C3B3B", …]

Nothing enforces it - a print will draw on any background - but Academy on black or Grid on white will not look like the demo.


<Loader>

Prop Type Default
state LoaderState - Required. One of the six.
print Print from context Falls back to the nearest provider.
size number 64 Edge length in CSS pixels. Always square.
speed number 1 Time multiplier.
paused boolean reduced-motion Rest on a representative still.
label string | null the state name Announced to assistive tech. null hides it.
className string -
style CSSProperties -

Changing state, print, size or speed updates in place - the canvas is never torn down mid-animation.

Accessibility

By default a loader is role="img" with aria-label set to the state name. When adjacent text already says it, hide the loader instead of announcing it twice:

<span>
  <Loader state="thinking" size={20} label={null} />
  Thinking…
</span>

Without React

The core has no framework dependency.

import { mount } from "intermission";
import { academy } from "intermission/academy";

const handle = mount(canvas, {
  print: academy,
  state: "working",
  size: 64,
  speed: 1,
});

mount returns a handle. Every method updates in place:

Method
setState(state) Also restarts the clock, so done replays.
setPrint(print) Swap worlds without remounting.
setSize(size) Re-scales for device pixel ratio.
setSpeed(speed)
setPaused(paused)
destroy() Removes it from the shared animation loop.

mount also takes paused and maxDpr (default 2 - beyond that costs more than it shows).


Writing your own print

A print is six draw functions plus some metadata. The primitives the built-in three are made from are exported for exactly this:

import { TAU, stepq, polygon, polyPoint, hash, type Print } from "intermission";

const pulse = (c: CanvasRenderingContext2D, t: number, s: number) => {
  const r = s * 0.3 * (0.75 + 0.25 * Math.sin(t * 3));
  c.fillStyle = "#E0A526";
  c.beginPath();
  c.arc(s / 2, s / 2, r, 0, TAU);
  c.fill();
};

export const mine: Print = {
  name: "mine",
  title: "Mine",
  ground: "#111111",
  palette: ["#111111", "#E0A526"],
  settleDuration: 0.8,
  states: {
    thinking: pulse,
    searching: pulse,
    working: pulse,
    listening: pulse,
    composing: pulse,
    done: pulse,
  },
};

A draw function receives a context already scaled for device pixel ratio and already cleared. Draw in CSS pixels against a size × size box with the origin at its top left.

Two things worth knowing:

  • t restarts when the state changes, which is what lets done play once and hold rather than looping.
  • Anything drawn outside the box is clipped by the canvas. If you use shadowBlur, inset the artwork enough that the glow lands inside - otherwise it gets a hard square edge cut through it. npm run verify checks for this.

stepq is what gives Academy its snap; polyPoint is what runs Grid's light trail along a path without curves.


Size

Per entry point, minified and gzipped:

Entry Minified Gzipped
intermission (core) 2.3 kB 1.2 kB
intermission/react 2.5 kB 1.2 kB
intermission/academy 2.6 kB 1.2 kB
intermission/tape 3.4 kB 1.6 kB
intermission/grid 4.1 kB 1.7 kB

Prints are separate entry points and the package is marked sideEffects: false, so importing one never bundles the others. A React app using a single print ships roughly 3.6 kB gzipped.


Notes

  • One animation frame, shared. Twenty loaders on a page cost one requestAnimationFrame callback, not twenty.
  • Server-safe. The React entry ships "use client", so <Loader> can be used directly inside a Server Component. It renders a plain <canvas> on the server and starts animating on hydration. The one exception is the print prop, which has to be passed from a client boundary. See Next.js App Router.
  • Respects prefers-reduced-motion by resting on a representative still frame. Pass paused explicitly to override in either direction.
  • ESM and CommonJS, with types for both, verified against a real install on every CI run.
  • Requires the Canvas 2D API - every browser since roughly 2015.

Scripts

npm run build      # bundle to dist/ (ESM + CJS + types)
npm run typecheck  # tsc --noEmit
npm run verify     # render all 18 states headlessly; fail if any is blank,
                   # frozen, or drawing outside its box
npm run demo       # build the self-contained landing page to demo/dist

Releasing

npm version minor
git push --follow-tags

CI verifies the tag matches package.json, then publishes to npm with provenance and cuts the GitHub release.

Licence

MIT © Ben Howdle

About

Loaders, in three prints. Six states, three visual worlds, one API.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages