# Counter Preloader (Moonarc)

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.

- Import: `import CounterPreloader from '@moonarc/core/CounterPreloader'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/counter-preloader.json`
- Tier A · category loading · trigger load
- Readout: `<CounterPreloader once>`
- Browser support: newly (Chrome 85 · Firefox 128 · Safari 17.2); elsewhere: without @property the layer is never visible (the registered probe that reveals it does not exist) and the page shows at once
- Measured cost: 0 B JS (CSS 5.4 kB raw)
- Page: https://moonarc.dev/components/counter-preloader/

## Props

| 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. |

## Usage

```astro
---
// 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)" />
```

## Reduced motion

Never shown.

## With ClientRouter

Without 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.

## Craft

- A preloader must never gate the page. The layer has pointer-events: none from its first frame and aria-hidden, the content beneath is rendered and focusable, and the whole run is bounded by the tokens: twice the slow duration for the count plus one duration for the lift, about two seconds. There is no duration prop on purpose.
- The number is one registered <integer> animated 0 → 100 with literal keyframes (Firefox does not interpolate a var() in a keyframe) and printed through counter-set, so it steps like a counter and the bar can read the same integer for its scale.
- The lift is a translate keyframe delayed by the count's duration, with visibility: hidden at its end and fill-mode forwards, so the layer is truly gone, not an invisible box over the page.
- Support is probed without a script: a registered custom property with initial-value visible that nothing ever sets. Where @property exists, visibility: var(--ma-preloader-ok, hidden) resolves to visible; where it does not, the fallback keeps the layer hidden. No flash of a stuck curtain in an old engine.
- once is inline and measured from its source string, like StickyBanner's remember: it has to run before the first paint of the page it is on, and it carries data-astro-rerun so the router runs it again after a swap. transition:persist was the plan; measured, the persisted node restarts its count on the next page, because moving a node restarts its CSS animations.

## Replaces

- Preloader (Osmo)
- Loader (Aceternity)
- Studio-style loading count

## Source

```astro
---
/**
 * 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>

```
