# Preloader (Moonarc)

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.

- Import: `import Preloader from '@moonarc/core/Preloader'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/preloader.json`
- Tier A · category loading · trigger load
- Readout: `<Preloader preset="stairs" words={['Sitename']} />`
- Browser support: widely (every browser); elsewhere: nothing to fall back from
- Measured cost: 0 B JS (CSS 6.7 kB raw)
- Page: https://moonarc.dev/components/preloader/

## Props

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

## Usage

```astro
<Preloader preset="words" words={['Design', 'Build', 'Ship']} once />
```

## Reduced motion

It never shows.

## With ClientRouter

The once script reruns on every ClientRouter swap (data-astro-rerun) and removes the layer once seen.

## Craft

- The layer ends hidden by a zero-length animation at 1.9 s, so nothing waits on a script or on the page's load event.
- Words take turns on delays written from their index; the last stays until the layer lifts.
- Its class is ma-opener, apart from CounterPreloader's ma-preloader, so the two can sit on one catalogue page.

## Replaces

- Preloaders (Skiper)
- Text loader (Animata)
- Intro patterns (Motion Menu)

## Source

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

```
