# octoloader

> Pixelartová chobotnice, která plave, zatímco čekáš. Indikátor načítání bez jediné závislosti, pro React i čisté HTML. Místo spinneru se řídí tím, jak dlouho se čeká.

Sólový projekt — koncept, pixel art, TypeScript, dokumentace · 2026 · Open source (MIT) · na npm · v0.1.1

`TypeScript` `React 17+` `Custom Elements` `Shadow DOM` `SVG` `CSS steps()` `Zero dependencies` `npm`

- [npm — octoloader](https://www.npmjs.com/package/octoloader)
- [GitHub — octoloader](https://github.com/michalstrnadel/octoloader)
- [Primer — vzor pro načítání](https://primer.style/product/ui-patterns/loading/)

octoloader je indikátor načítání, ve kterém někdo bydlí. **Pět ručně nakreslených póz ramen, každá 30×24 pixelů, se přehrává jako osmisnímkové plavání.** Tělo se zvedne, když chobotnice ramena stáhne, a klesne, když je rozevře. K tomu mrknutí, pohled stranou a tři bublinky — každé na vlastních hodinách. Žádný canvas, žádný GIF, žádné závislosti. Sprite tvoří obyčejné SVG cesty, takže je ostrý v jakékoli velikosti a v monochromatické variantě přebírá `currentColor`.

Víc času než chobotnice zabralo chování. Řídí se [Primerovým vzorem pro načítání](https://primer.style/product/ui-patterns/loading/): **první vteřinu se neukáže vůbec nic**, od jedné do tří vteřin chobotnice plave bez udaného průběhu a potom nastoupí lišta průběhu. Vychází jako React komponenta i jako custom element se shadow rootem, má **5,2 kB po gzipu** a přístupnost je v ní promyšlená, ne dolepená na konec: sprite je skrytý, mluví za něj obal a předat `aria-label` je typová chyba s odkazem na správnou prop.

- **5,2 kB** — po gzipu, nula závislostí
- **8** — snímků z pěti nakreslených póz
- **6** — časovačů najednou, nikdy v zákrytu
- **3** — tóny v monochromu — se dvěma by to byla lebka

## spinner není stav načítání

Spinner uprostřed stránky sděluje jedinou věc: něco se děje. Neřekne, *co* se načítá, kam obsah dorazí, ani jak dlouho to asi potrvá. A když se objeví na 200 ms, což je doba, kterou většina requestů skutečně trvá, nepůsobí jako postup, ale jako probliknutí.

Indikátor je proto jen polovina balíčku. Druhou polovinou je **panel**: tenký rámeček, zaoblené rohy a vzduch kolem, roztažený přes celý kontejner, ne jen kolem chobotnice. Vymezuje oblast, kam obsah dorazí — tedy stejné místo, které zaplní prázdný stav, když čekání skončí naprázdno. Tvar je záměrně stejný jako `Empty` v shadcn nebo `Blankslate` v Primeru: jedna oblast by neměla měnit velikost a zaoblení podle toho, v jakém je zrovna stavu.

## indikátor podle délky čekání

Primer rozlišuje čtyři časová okna a propy jim odpovídají jedna ku jedné. Do vteřiny: nic neukazuj — `delay={1000}` vrací `null`, dokud neuplyne. Jedna až tři vteřiny: plavání bez průběhu. Tři až deset: lišta průběhu, pokud ho umíš spočítat. Přes deset: lišta průběhu, a přestaň blokovat stránku.

`delay` je prop, kterou se vyplatí zapojit všude. Právě ona dělá rozdíl mezi stránkou, která působí svižně, a stránkou, která bliká. Dvě věci ale za tebe neudělá. Nezarezervuje místo, které ještě nezabírá — dej slotu `min-height`. A neudrží se na obrazovce dost dlouho: loader, který zmizí 80 ms po tom, co se objevil, blikne stejně ošklivě jako ten ukázaný na 200 ms. Stav, který ho řídí, by ho proto měl nechat viditelný nejméně ~400 ms.

[lišta průběhu má 28 celých pixelů, takže se posouvá po viditelných krocích](https://michalstrnadel.com/case-studies/octoloader/progress.mp4)

## jak se hýbe

Pět póz ramen se přehrává tam a zpátky a vnitřní rameno jde o krok za vnějším. Právě tohle zpoždění dělá ze dvou mávajících končetin jednu postupující vlnu. Celé zvíře se při stažení ramen zvedne a při jejich rozevření klesne, stejně jako když se chobotnice odráží tryskem. Bez toho zdvihu by z osmi snímků zbyl jen listovací obrázek.

Obojí se dá snadno rozbít tak, že to vypadá spíš jako cuknutí než jako chyba, a proto obojí hlídají testy. **Pózy jsou nakreslené, ne dopočítané**: tweening mezi dvěma pózami, které dělí jediný pixel, se kvůli zaokrouhlování rozpadne a z pěti kroků zbydou tři kresby, zatímco tělo se hýbe dál. A **výška patří k póze, ne k číslu snímku**. Když stejná póza dostane cestou tam jinou výšku než cestou zpátky, smyčka zadrhává, i když každý snímek sám o sobě sedí.

`osm snímků z pěti póz`

```text
snímek   1     2     3     4     5     6     7     8
póza     0     1     2     3     4     3     2     1
zdvih    0px   0px   1px   1px   2px   1px   1px   0px

plavání   0,9 s               cyklus ramen a zdvih
mrknutí   4,6 s               oči zavřené na 115 ms
pohled    7,3 s               zorničky o pixel doleva, později doprava
bublinky  2,6 s 3,2 s 3,8 s   tři, stoupají podél levého boku
```

[osm snímků a dva pixely zdvihu, díky kterým chobotnice plave, a ne jen mává](https://michalstrnadel.com/case-studies/octoloader/frames.png)

## šestery hodiny, které se nikdy nesejdou

Plavání běží na 0,9 s, mrknutí na 4,6 s, pohled stranou na 7,3 s a tři bublinky na 2,6, 3,2 a 3,8 s. Všech šest se znovu sejde zhruba jednou za **138 dní** — a přesně o to jde: nikdo, kdo kouká na načítací obrazovku, neuvidí celý vzorec proběhnout dokola. Smyčka, kterou se dá naučit, přestává být postavou a stává se ukazatelem průběhu s obličejem.

Snímky leží vedle sebe v jednom dlouhém pásu, který se pomocí `steps()` posouvá pod viewBoxem vždy o jeden snímek. Nabízí se i jiná cesta — naskládat snímky na sebe a prolínat jim průhlednost — jenže ta stojí jednu animaci na každý snímek každého výrazu. Na stránce s několika loadery jde o stovky animací a projeví se to trhaným pohybem. Takhle je to osm animací bez ohledu na počet kreseb a žádný snímek nikdy nezůstane v půlce výměny. A protože se viewBox nehýbe, můžou oči ležet mimo pás: jedna sada víček a zorniček poslouží pro kterýkoli právě zobrazený snímek.

## monochrom má tři tóny, ne dva

Nabízí se udělat monochrom jednou barvou a oči z ní vyseknout. Na bílé stránce to vypadá správně, na černé se z toho stane lebka: tělo zesvětlá, vyseknuté oči ztmavnou spolu s pozadím a díváš se do prázdných důlků.

Proto `currentColor` přebírá jen tělo. Oči a přísavky mají barvu `--octo-paper`, zorničky `--octo-pupil`. Když zůstane oko světlejší než tělo a zornička tmavší než oko, čte se obličej stejně na světlém i tmavém podkladu a jeden sprite pokryje obojí. Lišta průběhu stojí na týchž třech tónech — rámeček v barvě inkoustu, dráha v barvě papíru, výplň v barvě zorničky. Stačí dva z nich sloučit a lišta přestane ukazovat hodnotu. Test hlídá, aby se všechny tři v obou paletách lišily, protože přesně tahle změna ve světlém režimu vypadá v pořádku a v tmavém se rozbije.

[tentýž sprite, jen jiná paleta — a oko v monochromu zůstává světlejší než tělo](https://michalstrnadel.com/case-studies/octoloader/variants.mp4)

## přístupnost a kde končí

Sprite je `aria-hidden`, takže se nic nečte dvakrát, a mluví za něj obal. Bez hodnoty průběhu má `role="status"`, s ní `role="progressbar"` a trojici `aria-value*`. V obou případech se jeho název bere z popisku, který lidé skutečně vidí, takže se vyslovený a viditelný název nemůžou rozejít. Pokud se lišit opravdu mají, použij `announce`.

Atributy, které si komponenta odvodí sama, v typu propů *vůbec nejsou* — nic se potichu nepřepisuje. Předat `aria-label` je proto chyba při kompilaci, která tě odkáže na `announce`. Dvě věci zůstávají na tom, kdo komponentu používá, a dokumentace to říká otevřeně, místo aby hádala: `aria-busy` patří na oblast, jejíž obsah se mění, ne na loader, a jakmile nový obsah dorazí, má se do něj přesunout fokus.

Při `prefers-reduced-motion` se plavání, mrknutí, pohled i bublinky zastaví a chobotnice jen jemně pulzuje na místě. Že se úplně nezastaví, je záměr — Primer chce, aby animace načítání byla nenápadná, ne aby zmizela, protože zamrzlý indikátor nejde odlišit od zaseknutého. Po obrazovce se ale už nic nepohybuje.

## jak chobotnici upravit

`src/sprite.ts` je samotná kresba — doslova obrázek z textu. Každý snímek je pole řetězců, jeden znak na pixel: `.` průhledná, `o` obrys, `b` tělo, `w` bělmo, `c` duhovka, `p` zornička, `g` přísavka. Změníš znak, změníš chobotnici. Renderer při importu spojí úseky stejných znaků do jedné SVG cesty pro každou barvu, takže mezi tebou a pixely nestojí žádný build.

Testy hlídají kresbu, nejen kód. Každý snímek musí být úplný obdélník, smí obsahovat jen znaky, které znají obě palety, a uvnitř siluety nesmí zůstat uzavřený pixel pozadí. To poslední zachytí zatoulanou dírku, která je při dvanáctinásobném zvětšení neviditelná a ve skutečné velikosti vypadá jako smítko. Další testy hlídají tvar animace: dva po sobě jdoucí snímky nesmí být stejné, ramena musí mávnout tam a zpátky, ne kroužit jedním směrem, a monochromatická paleta se musí rozložit na tři odlišné tóny.

## velikost a výkon

5,2 kB po gzipu pro React, 5,4 kB pro custom element. Žádné runtime závislosti; React je volitelný peer, takže import `octoloader/element` na stránce bez Reactu nepřitáhne nic navíc. Jeden stylesheet bez ohledu na počet loaderů — vykresluje se jako hoistovaný `<style>`, který React 19 přesune do `<head>` a odstraní duplicity. Komponenta je obalená v `memo`, takže loader uvnitř něčeho, co se překresluje při každém stisku klávesy, nenutí React znovu procházet jeho dvacítku SVG uzlů.

Žádný polyfill, žádná záložní cesta — není kam ustoupit. Bez animace zbude nehybná chobotnice, a ta pořád říká „čeká se“.

---

https://michalstrnadel.com/cs/business/case-studies/octoloader
