Count Up
TextCounts a number up from zero to its value when it scrolls into view, with thousands separators, decimals, prefix and suffix.
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. The number is one registered integer printed through counter-set; the layer then translates off and ends hidden. It runs about two seconds, set by the tokens. once skips it for the rest of the session, on a reload and after every ClientRouter swap. Under reduced motion, or without @property, it never shows. Zero JavaScript.
The page is here.
The layer never takes the pointer; it counts, lifts, and is gone.
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 5.4 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.
Browser support
Chrome 85 · Firefox 128 · Safari 17.2; needs property, counter-set. Elsewhere: without @property the layer is never visible (the registered probe that reveals it does not exist) and the page shows at once.
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 CounterPreloader from '@moonarc/core/CounterPreloader';
// in your layout, first thing in <body>
---
<CounterPreloader once />
<!-- a colour of your own, and a key per site -->
<CounterPreloader id="studio" color="oklch(0.2 0.02 60)" textColor="oklch(0.95 0.02 80)" />Owns the file, no dependency. The registry item also installs the base tokens.
npx shadcn@latest add https://moonarc.dev/r/counter-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.
---
/**
* CounterPreloader — a count from 0 to 100 and a curtain that lifts, zero
* JS. A fixed layer that never stands in the way: `pointer-events: none`
* from the first frame, `aria-hidden`, and the page under it is rendered
* and usable throughout. The number is one registered integer animated
* 0 → 100 (literal keyframes) printed through `counter-set`; the bar reads
* the same integer; then the layer translates off the top and ends
* hidden. The whole thing lasts twice the slow token plus one duration —
* about two seconds — and is not configurable longer: a preloader is a
* promise, not a stage. `once` inlines 187 B that skip it for the rest of
* the session (sessionStorage), before paint, on a full load and after
* every ClientRouter swap (`data-astro-rerun`) — `transition:persist` was
* tried and does not do it: the router moves the node into the new page
* and a moved node restarts its CSS animations (measured, docs/phase-7e.md).
* Under reduced motion, and in an engine without @property, it never shows.
*/
import type { HTMLAttributes } from 'astro/types';
interface Props extends HTMLAttributes<'div'> {
/** Show once per session: an inline script, measured from its source string. */
once?: boolean;
/** Layer colour; default the ink token. */
color?: string;
/** Count colour; default the panel token. */
textColor?: string;
/** Element id and the once key. */
id?: string;
}
const { once = false, color, textColor, id = 'ma-preloader', class: className, style, ...rest } = Astro.props;
const vars = [color && `--ma-preloader-bg:${color}`, textColor && `--ma-preloader-fg:${textColor}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
/** Runs where it is rendered, before the next paint: a session that has seen the layer removes it; the first sight is recorded. */
const ONCE = `(function(){var s=document.currentScript,l=s.previousElementSibling,K='ma-preloader:'+l.id;try{if(sessionStorage.getItem(K)){l.remove();return}sessionStorage.setItem(K,'1')}catch(e){}})()`;
---
<div class:list={['ma-preloader', className]} id={id} aria-hidden="true" style={vars || undefined} {...rest}>
<span class="ma-preloader__bar"></span>
<span class="ma-preloader__count"><span class="ma-preloader__n"></span><span class="ma-preloader__unit">%</span></span>
</div>
{once && <script is:inline data-astro-rerun set:html={ONCE} />}
<style is:global>
@property --ma-preloader-n {
syntax: '<integer>';
inherits: true;
initial-value: 0;
}
/* the support probe: registered, never set — visible where @property exists, the fallback everywhere else */
@property --ma-preloader-ok {
syntax: 'visible';
inherits: false;
initial-value: visible;
}
@keyframes ma-preloader-count {
from {
--ma-preloader-n: 0;
}
to {
--ma-preloader-n: 100;
}
}
@keyframes ma-preloader-lift {
to {
translate: 0 -100%;
visibility: hidden;
}
}
@layer components {
:where(.ma-preloader) {
/* the count runs twice the slow token, then the lift takes one duration */
--ma-preloader-count: calc(var(--ma-duration-slow) * 2);
position: fixed;
inset: 0;
z-index: 9999;
display: grid;
align-content: end;
padding: clamp(1rem, 4vw, 3rem);
background: var(--ma-preloader-bg, var(--ma-ink));
color: var(--ma-preloader-fg, var(--ma-panel));
pointer-events: none;
visibility: var(--ma-preloader-ok, hidden);
animation:
ma-preloader-count var(--ma-preloader-count) var(--ma-ease-out) forwards,
ma-preloader-lift var(--ma-duration) var(--ma-ease-in-out) var(--ma-preloader-count) forwards;
}
:where(.ma-preloader__bar) {
position: absolute;
inset: auto 0 0 0;
block-size: 3px;
background: currentColor;
transform-origin: 0 50%;
scale: calc(var(--ma-preloader-n) / 100) 1;
opacity: 0.6;
}
:where(.ma-preloader__count) {
justify-self: end;
font-size: clamp(3rem, 12vw, 9rem);
font-weight: 600;
line-height: 1;
letter-spacing: -0.04em;
font-variant-numeric: tabular-nums;
}
:where(.ma-preloader__n) {
counter-set: ma-preloader var(--ma-preloader-n);
}
:where(.ma-preloader__n)::before {
content: counter(ma-preloader);
}
:where(.ma-preloader__unit) {
font-size: 0.4em;
vertical-align: 0.9em;
opacity: var(--ma-dim, 0.7);
}
@media (prefers-reduced-motion: reduce) {
:where(.ma-preloader) {
visibility: hidden;
animation: none;
}
}
}
</style>| Prop | Type | Default | Description |
|---|---|---|---|
once | boolean | false | Show once per session: an inline script of 187 B raw (measured from its source string) removes the layer before paint when sessionStorage remembers it: on a full load and, with data-astro-rerun, after every ClientRouter swap. |
color | string | none | Layer colour; default the ink token. |
textColor | string | none | Count colour; default the panel token. |
id | string | 'ma-preloader' | Element id and the once key. |
Never shown.
ClientRouterWithout once every page renders the layer again and it counts again, like any CSS animation. With once the inline script carries data-astro-rerun, runs after every swap and removes the layer before paint. transition:persist was measured and does not help: the router moves the kept node into the new page, and a moved node restarts its CSS animations.
Replaces: Preloader (Osmo) · Loader (Aceternity) · Studio-style loading count. See the migration table.
Counts a number up from zero to its value when it scrolls into view, with thousands separators, decimals, prefix and suffix.
A full-viewport wipe between pages: the next page opens through an iris from where you clicked, or through blinds, a shutter, doors, pixels, staggered columns, a diagonal, or a title panel that carries the next page's name.
A bar that fills to its value when it scrolls into view, with the number counting up beside it, or a ring with the number in the middle.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown