Loading cats
NewFour game-like loading loops with a black cat in each, all in plain CSS animation.
loading-cats/loading-cats.tsxThe live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- A playful wait of a few seconds that fills a screen or a panel: a game level, a first sync, an export.
- Empty or splash moments where a little character makes the wait feel shorter, and the brand can afford a joke.
- A wait with four rough milestones: drive the progress tile with
progressand its caption and corner cat follow. - Any size: every tile is drawn on its own 400 × 298 grid, so it looks the same from a thumbnail to a hero.
When not to use
- Small inline waits, in a button, a field or a table row. Use a plain spinner at text size.
- Exact progress people plan around, like an upload with minutes left. Show a bar with a number and the time.
- Serious or stressful moments (a payment, an error, a medical result): cats bouncing read as not taking it seriously.
- Waits under a second: no loop gets past its first beat, and the tiles flash. Show nothing, or a skeleton.
- Errors and partial results: the tiles only know “still loading”. Say what failed in words.
You can add this component with the shadcn CLI or by hand:
Into components/loading-cats of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/loading-cats.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { LoadingCats } from './loading-cats';export function Uploading({ sent, total }: { sent: number; total: number }) { return ( <LoadingCats animations={['progress']} progress={sent / total} labels={['Uploading', 'Halfway', 'Nearly there', 'Uploaded!']} /> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
Four grey tiles that wait with you. A cat bounces on a trampoline, the O of LOAD stretches into a progress bar and then drops out of the word, a pile of cats falls through a trapdoor and tumbles back in, and a bar fills in four steps while cats peek in from the corners. Every loop is plain CSS keyframes.
- Every tile is drawn on a 400 × 298 grid. A tile is a size container and --u is a 400th of its width, so positions, strokes, type and even the keyframes’ distances scale with the tile; one stylesheet draws it at 140 px or at 440 px.
- Each loop is a set of keyframes written frame by frame at 30 fps, as dense linear steps with eased spans in between: the bounce runs 1.8 s, the word 5.5 s, the pile 3 s and the progress bar 8.8 s, each with a negative delay that starts it mid-loop.
- The cats move with the individual translate, rotate and scale properties, each from its own animation, so a fall (ease-in), a tilt and a squash (scaled about the bottom of the body) keep their own timing on one element.
- The trampoline is a see-through pad drawn over the cat: it widens and sags into a bowl (border-radius with an elliptical bottom) as the cat sinks in, pinches to 199 units, then springs back past its width and settles. The dust is four discs that fly out from under the cat and shrink away.
- LOAD is three drawn capitals and a ring of the same 15.5-unit weight in a centred flex row. Only the O’s slot changes width: wide, it spreads the word into a pill (with an expo ease and an overshoot); at zero, the word closes to LAD while the ring falls out with an ease-in and later drops back in with an ease-out. A 30° slanted band, clipped by the pill’s inside, sweeps it three times.
- The trapdoor’s two leaves turn about hinges just past the tile’s sides, to 60° and back with a small overshoot and a dip as each cat lands. The cats fall through with gravity, wait off the tile, then drop back in one by one, stretched as they fall and squashed as they land.
- Each corner of the progress tile is the bottom-right one mirrored about the tile’s middle (rotate(180deg), scaleX(-1), scaleY(-1)), so one swing, round a point past the tile’s right side, serves all four. The fill is a whole pill slid in from the left, so a sliver of it reads as a crescent.
- With progress set, the progress tile leaves its loop: the fill, the caption and the corner cat follow the value with transitions. Without it, under reduced motion or with the loops paused, every tile falls back to its base styles, which are the stills.
- The cat is one SVG on a 150 × 104 grid (ears and tail first, so the body covers their roots), stretched to each box with preserveAspectRatio none; the pile’s loafing cats are just flatter boxes. Its colour is currentColor, its eyes and inner ears a token, so the grey cats and the night scheme cost nothing.
- Every colour is a light-dark() token on the root: by day near-black cats on light-grey tiles, at night light cats with dark eyes on charcoal tiles.
| Prop | Type | Default | Description |
|---|---|---|---|
animations | ('bounce' | 'word' | 'pile' | 'progress')[] | all four | Which tiles to show, in this order. One tile is drawn alone, up to 480 px wide. |
labels | [string, string, string, string] | ['Loading', 'Halfway', 'Almost', 'Done!'] | The progress tile’s captions: under half, from half, from three quarters, and done. |
progress | number | — | Drives the progress tile, from 0 to 1, instead of its loop; its caption and corner cat follow the value. |
label | string | 'Loading' | What the group of tiles is called for screen readers. |
onPausedChange | (paused: boolean) => void | — | Called when the reader pauses (true) or plays (false) the loops, by a click or the button. |
autoplay | boolean | true | Play the loops while the tiles are on screen. Without it every tile shows its still: the cat on its pad, the stretched pill, the full pile and the bar half way. |
colorScheme | 'light' | 'dark' | 'system' | — | Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one. |
className | string | — | Extra class for the root. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useState, type CSSProperties } from 'react';import { Cat, Letter, type Eyes } from './cat';import { useAutoplay } from './use-autoplay';import styles from './loading-cats.module.css';/* * Loading cats: four game-like loading loops in grey tiles, every one of them plain CSS * animation. * * - Bounce: a cat drops onto a pad that gives like a trampoline (it widens, sags and turns * see-through over the cat), springs off it in a puff of dust and sails out of the top. * - Word: the O of LOAD stretches into a pill and a shine sweeps it three times; it shrinks back, * drops out of the word (L AD, LAD) and falls back in from above. * - Pile: a heap of cats sits on a two-leaf trapdoor; the leaves swing down, the cats fall through, * the leaves swing shut, and the cats drop back in one by one, squashing as they land. * - Progress: a bar fills in four steps, each with its caption; at each step a cat swings in from * another corner, and at the last one, from all four. * * Every tile is drawn on a 400 × 298 grid: --u is one unit, a 400th of the tile's width (a * container query), so a tile looks the same at any size. The loops are keyframes timed frame/* Loading cats (loading-cats.tsx): four grey tiles in a 2 × 2 grid on a near-white stage, or at night light cats on charcoal tiles. Every tile is drawn on a 400 × 298 grid. A tile is a size container, and --u is a 400th of its width, so everything inside it (positions, strokes, type, the keyframes' distances) scales with the tile. Positions and boxes are set inline in units by the component; this file holds the looks and the motion. Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes the color-scheme it inherits from the page (light unless the page says otherwise), or the one its colorScheme prop sets (data-color-scheme). Motion. The keyframes at the end are timed frame by frame (30 fps) and written as dense linear steps with eased spans in between. They only run under prefers-reduced-motion: no-preference, and only while the grid's data-run is play (or pause, which freezes them where they are, off screen or in a hidden tab). With data-run still, every element falls back to its base style here, which is the tile's still. Memory. Each tile clips and contains its own layout and paint, so the pad's and the pill's width changes never lay out the page; the cats, puffs and leaves move by transforms. */.root { /* The stage, and the tiles on it. */ --page: #fdfdfd;// Copyright (c) 2026 Bruno Delorence. MIT License.import type { CSSProperties } from 'react';/* * The drawings: the cat, and the three letters the word tile spells around its O. * * The cat is a mochi of a body on a 150 × 104 grid (the body itself 120 wide, from x 4 to 124 and * y 18 to 102), two pointed ears, a hooked tail and a face. Ears and tail are drawn first, so the * body covers their roots. Its colour is the current colour; the eyes and the inside of the ears * take --cat-eye. It stretches to whatever box it is given (preserveAspectRatio none), so a pile * can hold flatter, loafing cats. */export type Eyes = 'open' | 'sleep' | 'star';/** A four-point sparkle with concave sides, centred on (x, y). */const star = (x: number, y: number, r: number) => `M${x} ${y - r}Q${x} ${y} ${x + r} ${y}Q${x} ${y} ${x} ${y + r}Q${x} ${y} ${x - r} ${y}Q${x} ${y} ${x} ${y - r}Z`;export function Cat({ eyes = 'open', round = false, className,'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden); const observer = new IntersectionObserver( ([entry]) => { onScreen = entry.isIntersecting;- The tiles are a group named by label. The bounce, word and pile tiles are images with a short description; the drawings inside them are aria-hidden.
- The progress tile is a progressbar named by the first caption. With progress set it reports its value (0–100) and a value text with the current caption; without it, it is an indeterminate progressbar.
- A Pause animations toggle button (aria-pressed) sits in the stage’s corner: it shows on hover and on keyboard focus, with a focus ring. Clicking or tapping any tile toggles the loops too, so the motion can always be stopped.
- The loops pause off screen and in a hidden tab. Under reduced motion they never play and the toggle is hidden: every tile shows its still, and a value-driven bar jumps without transitions.