Skip to content

Components / Loading

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. With reduced motion it never shows.

Live demo

words
stairs
split

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

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

Baseline · widely available

every browser. Elsewhere: nothing to fall back from.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import Preloader from '@moonarc/core/Preloader';
---
<Preloader preset="words" words={['Design', 'Build', 'Ship']} once />
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/preloader.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.

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

Props

PropTypeDefaultDescription
preset'words' | 'stairs' | 'split''words'The cut.
wordsstring[][]Two to four for words; the first is the label of the other two cuts; none for no label.
columnsnumber5The stairs' columns, 3 to 8.
oncebooleanfalseShow once per session: an inline script before paint.
colorstringthe ink tokenThe layer's colour.
textColorstringthe panel tokenThe words' colour.
idstring'ma-opener'The element's id and the once key.

Reduced motion

It never shows.

With ClientRouter

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

Why it is built this way

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.

newly · Chrome 85 · Firefox 128 · Safari 17.2load

page one

Hero.

page two

Hero.

Try the real swap →

Page Transition

Page transitions

Animates a named region between pages: rise, zoom, wipe, scope, columns, iris, tiles, peel, fade, slide, or the browser's own shared-element morph.

newly · Chrome 111 · Firefox 144 · Safari 18click

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown