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á.
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í: 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.
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.
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í.
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
š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.
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“.