Counter Preloader
LoadingA preloader that counts from 0 to 100 and then lifts like a curtain, on a fixed layer that never blocks the page: pointer-events none from the first frame, aria-hidden, the page rendered and usable beneath.
An opening that plays once as a page loads and then gets out of the way, in one of three cuts: words (two to four words in turn, then the layer lifts), stairs (the layer is columns that rise away one after another) or split (a word, then the layer parts in two). A fixed layer that never stands in the way: pointer-events: none from the first frame and aria-hidden, the page under it rendered and usable throughout, and the whole of it under two seconds, then hidden. CSS only; once inlines a few bytes that skip it for the rest of the session. With reduced motion it never shows.
Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
JavaScript of its own
0 B
CSS 6.7 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.
Browser support
every browser. Elsewhere: nothing to fall back from.
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
import Preloader from '@moonarc/core/Preloader';
---
<Preloader preset="words" words={['Design', 'Build', 'Ship']} once />Owns the file, no dependency. The registry item also installs the base tokens.
npx shadcn@latest add https://moonarc.dev/r/preloader.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.
---
/**
* Preloader — an opening that plays once as a page loads and then gets out of the way, in one of three cuts: `words`
* (two to four words in turn, then the layer lifts), `stairs` (the layer is columns that rise away one after another)
* or `split` (a word, then the layer parts in two). A fixed layer that never stands in the way: `pointer-events: none`
* from the first frame and `aria-hidden`, the page under it rendered and usable throughout, and the whole of it under
* two seconds, then hidden. CSS only. `once` inlines a few bytes that skip it for the rest of the session, as
* CounterPreloader's do. With reduced motion it never shows.
*/
import type { HTMLAttributes } from 'astro/types';
interface Props extends HTMLAttributes<'div'> {
preset?: 'words' | 'stairs' | 'split';
/** The words: two to four for `words`, the first one for the others' label; none for no label. */
words?: string[];
/** The stairs' columns, 3 to 8. */
columns?: number;
/** Show once per session: an inline script before paint. */
once?: boolean;
/** The layer's colour; default the ink token. */
color?: string;
/** The words' colour; default the panel token. */
textColor?: string;
/** Element id and the once key. */
id?: string;
}
const { preset = 'words', words = [], columns = 5, once = false, color, textColor, id = 'ma-opener', class: className, style, ...rest } = Astro.props;
if (preset === 'words' && (words.length < 2 || words.length > 4)) throw new Error(`Preloader: preset words takes two to four words (got ${words.length}).`);
if (columns < 3 || columns > 8) throw new Error(`Preloader: columns is ${columns}; stairs take 3 to 8.`);
const vars = [color && `--ma-opener-bg:${color}`, textColor && `--ma-opener-fg:${textColor}`, `--ma-opener-cols:${columns}`, `--ma-opener-words:${words.length}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
/** Runs where it is rendered, before the next paint: a session that has seen the opening removes it; the first sight is recorded. */
const ONCE = `(function(){var s=document.currentScript,l=s.previousElementSibling,K='ma-opener:'+l.id;try{if(sessionStorage.getItem(K)){l.remove();return}sessionStorage.setItem(K,'1')}catch(e){}})()`;
---
<div class:list={['ma-opener', className]} id={id} data-preset={preset} aria-hidden="true" style={vars} {...rest}>
{preset === 'stairs' ? (
Array.from({ length: columns }, (_, i) => <span class="ma-opener__step" style={`--i:${i}`} />)
) : preset === 'split' ? (
<>
<span class="ma-opener__half" />
<span class="ma-opener__half" />
</>
) : (
<span class="ma-opener__panel" />
)}
{words.length > 0 && (
<span class="ma-opener__words">
{(preset === 'words' ? words : words.slice(0, 1)).map((w, i) => <span class="ma-opener__word" style={`--i:${i}`}>{w}</span>)}
</span>
)}
</div>
{once && <script is:inline data-astro-rerun set:html={ONCE} />}
<style is:global>
@layer components {
:where(.ma-opener) { --ma-opener-bg: var(--ma-ink, #111); --ma-opener-fg: var(--ma-panel, #fff); --ma-opener-step: 300ms; position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; overflow: hidden; pointer-events: none; animation: ma-opener-gone 0s linear 1.9s forwards; }
:where(.ma-opener__panel), :where(.ma-opener__half), :where(.ma-opener__step) { position: absolute; background: var(--ma-opener-bg); }
:where(.ma-opener__words) { position: relative; display: grid; color: var(--ma-opener-fg); font-family: var(--ma-font-display, inherit); font-size: clamp(2rem, 1.2rem + 3vw, 3.5rem); font-weight: var(--ma-display-weight, 700); letter-spacing: -0.02em; }
:where(.ma-opener__word) { grid-area: 1 / 1; text-align: center; opacity: 0; }
/* words: each word in turn, then the panel lifts off the top */
:where(.ma-opener[data-preset='words'] .ma-opener__panel) { inset: 0; animation: ma-opener-lift 520ms var(--ma-ease-in-out, ease-in-out) calc(var(--ma-opener-words) * var(--ma-opener-step) + 120ms) forwards; }
:where(.ma-opener[data-preset='words'] .ma-opener__word) { animation: ma-opener-word var(--ma-opener-step) var(--ma-ease-out, ease-out) calc(var(--i) * var(--ma-opener-step)) both; }
:where(.ma-opener[data-preset='words'] .ma-opener__word:last-child) { animation-name: ma-opener-word-last; animation-duration: calc(var(--ma-opener-step) + 260ms); }
:where(.ma-opener[data-preset='words'] .ma-opener__words) { animation: ma-opener-lift 520ms var(--ma-ease-in-out, ease-in-out) calc(var(--ma-opener-words) * var(--ma-opener-step) + 120ms) forwards; }
/* stairs: columns side by side, rising away one after another */
:where(.ma-opener[data-preset='stairs'] .ma-opener__step) { inset-block: 0; inset-inline-start: calc(var(--i) * 100% / var(--ma-opener-cols)); inline-size: calc(100% / var(--ma-opener-cols) + 1px); animation: ma-opener-lift 560ms var(--ma-ease-in-out, ease-in-out) calc(520ms + var(--i) * 70ms) forwards; }
:where(.ma-opener[data-preset='stairs'] .ma-opener__word) { animation: ma-opener-flash 640ms var(--ma-ease-out, ease-out) both; }
/* split: a word, then the layer parts, the top half up and the bottom half down */
:where(.ma-opener[data-preset='split'] .ma-opener__half) { inset-inline: 0; block-size: 50.5%; animation: ma-opener-lift 620ms var(--ma-ease-in-out, ease-in-out) 700ms forwards; }
:where(.ma-opener[data-preset='split'] .ma-opener__half:first-child) { inset-block-start: 0; }
:where(.ma-opener[data-preset='split'] .ma-opener__half:nth-child(2)) { inset-block-end: 0; animation-name: ma-opener-drop; }
:where(.ma-opener[data-preset='split'] .ma-opener__word) { animation: ma-opener-flash 680ms var(--ma-ease-out, ease-out) both; }
@media (prefers-reduced-motion: reduce) {
:where(.ma-opener) { display: none; }
}
}
@keyframes ma-opener-gone { to { visibility: hidden; } }
@keyframes ma-opener-lift { to { translate: 0 -101%; } }
@keyframes ma-opener-drop { to { translate: 0 101%; } }
@keyframes ma-opener-word {
0% { opacity: 0; translate: 0 0.4em; }
30%, 70% { opacity: 1; translate: 0 0; }
100% { opacity: 0; translate: 0 -0.4em; }
}
@keyframes ma-opener-word-last {
0% { opacity: 0; translate: 0 0.4em; }
40%, 100% { opacity: 1; translate: 0 0; }
}
@keyframes ma-opener-flash {
0% { opacity: 0; }
35%, 70% { opacity: 1; }
100% { opacity: 0; }
}
</style>| Prop | Type | Default | Description |
|---|---|---|---|
preset | 'words' | 'stairs' | 'split' | 'words' | The cut. |
words | string[] | [] | Two to four for words; the first is the label of the other two cuts; none for no label. |
columns | number | 5 | The stairs' columns, 3 to 8. |
once | boolean | false | Show once per session: an inline script before paint. |
color | string | the ink token | The layer's colour. |
textColor | string | the panel token | The words' colour. |
id | string | 'ma-opener' | The element's id and the once key. |
It never shows.
ClientRouterThe once script reruns on every ClientRouter swap (data-astro-rerun) and removes the layer once seen.
Replaces: Preloaders (Skiper) · Text loader (Animata) · Intro patterns (Motion Menu). See the migration table.
A preloader that counts from 0 to 100 and then lifts like a curtain, on a fixed layer that never blocks the page: pointer-events none from the first frame, aria-hidden, the page rendered and usable beneath.
Animates a named region between pages: rise, zoom, wipe, scope, columns, iris, tiles, peel, fade, slide, or the browser's own shared-element morph.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown