Intro Sequence

The page-load reveal — children rise and fade in on a stagger, once.

free vanilla 8.5KB revealloadstaggertransition
Runs once on load. Replay it.

Options

Option Type Default Notes
selector string ":scope > *" Which descendants to reveal, relative to the target. Defaults to its direct children.
stagger number 90 Gap between one item starting and the next, in ms.
distance number 18 How far each item rises from, in px.
maxDelay number 600 Ceiling on the accumulated delay in ms, so a long list does not trail in.

Install

  1. Copy intro-sequence.js and intro-sequence.css into your project. That is the whole component — it imports nothing.
  2. Include tokens.css once, anywhere on the page. Every component reads its colour, spacing and motion from it.
  3. Link the two files, then call createIntroSequence on a <div>.
<link rel="stylesheet" href="tokens.css" />
<link rel="stylesheet" href="intro-sequence.css" />

<script defer src="intro-sequence.js"></script>
const instance = VC.createIntroSequence(
  document.querySelector('#target'),
  {}
);

// Later, on teardown:
instance.destroy();

The bundle is a classic script, so it works over file:// with no server and no build step, and puts createIntroSequence on window.VC. If you would rather have the ES module, it is packages/registry/intro-sequence/ in the repo — same source, four files once _utils/ comes with it.

intro-sequence.js

17.2KB · 563 lines

Raw
View source
/**
 * GENERATED — run pnpm build:components — do not edit.
 *
 * Source of truth: packages/registry/intro-sequence/intro-sequence.js
 *
 * Flattened from ES modules to one classic script by scripts/lab-bundle.js —
 * byte for byte the build the Noir Portfolio template ships, so the preview
 * you are watching is the file the copy button hands you.
 *
 * Exposes: window.VC.createIntroSequence
 */
(function () {
  'use strict';

  /* ── packages/registry/_utils/dom.js ───────────────────────────────── */
  /**
   * DOM construction and listener helpers.
   *
   * Zero dependencies. Every import specifier in this package must start with
   * '.' or '/' — see scripts/check-isolation.js.
   */
  
  const SVG_NS = 'http://www.w3.org/2000/svg';
  
  /**
   * @typedef {string | number | boolean | null | undefined} AttrValue
   */
  
  /**
   * Apply attributes to an element.
   *
   * - `class` and `className` both set the class attribute.
   * - `style` takes a CSS string.
   * - `data-*` and `aria-*` pass straight through.
   * - `null`, `undefined` and `false` skip the attribute entirely, so callers can
   *   pass conditionals inline.
   * - `true` sets a valueless attribute.
   *
   * @param {Element} node
   * @param {Record<string, AttrValue>} attrs
   * @returns {void}
   */
  function applyAttrs(node, attrs) {
    for (const [key, value] of Object.entries(attrs)) {
      if (value === null || value === undefined || value === false) continue;
      const name = key === 'className' ? 'class' : key;
      node.setAttribute(name, value === true ? '' : String(value));
    }
  }
  
  /**
   * Append children, coercing strings to text nodes.
   *
   * @param {Element} node
   * @param {Array<Node | string>} children
   * @returns {void}
   */
  function appendChildren(node, children) {
    for (const child of children) {
      node.append(child);
    }
  }
  
  /**
   * Create an HTML element.
   *
   * @template {keyof HTMLElementTagNameMap} K
   * @param {K} tag
   * @param {Record<string, AttrValue>} [attrs]
   * @param {Array<Node | string>} [children]
   * @returns {HTMLElementTagNameMap[K]}
   *
   * @example
   * const card = el('article', { class: 'vc-card', 'aria-live': 'polite' }, [
   *   el('h3', null, ['Title'])
   * ]);
   */
  function el(tag, attrs, children) {
    const node = document.createElement(tag);
    if (attrs) applyAttrs(node, attrs);
    if (children) appendChildren(node, children);
    return node;
  }
  
  /**
   * Create an SVG element. Needed because `document.createElement` produces an
   * HTML element that renders as nothing inside an `<svg>`.
   *
   * @template {keyof SVGElementTagNameMap} K
   * @param {K} tag
   * @param {Record<string, AttrValue>} [attrs]
   * @param {Array<Node | string>} [children]
   * @returns {SVGElementTagNameMap[K]}
   */
  function svgEl(tag, attrs, children) {
    const node = /** @type {SVGElementTagNameMap[K]} */ (
      document.createElementNS(SVG_NS, tag)
    );
    if (attrs) applyAttrs(node, attrs);
    if (children) appendChildren(node, children);
    return node;
  }
  
  /**
   * Bind a listener and get its own teardown back.
   *
   * Returning the cleanup rather than exposing `off()` is what makes `destroy()`
   * auditable: a component collects these in an array and the array length is
   * the number of things it has to undo.
   *
   * The returned function is idempotent — calling it twice is harmless.
   *
   * @template {Event} [E=Event]
   * @param {EventTarget} target
   * @param {string} type
   * @param {(event: E) => void} handler
   * @param {AddEventListenerOptions | boolean} [options]
   * @returns {() => void} cleanup
   *
   * @example
   * const cleanups = [
   *   on(window, 'pointermove', onMove, { passive: true }),
   *   on(el, 'pointerleave', onLeave)
   * ];
   * // destroy: cleanups.forEach((off) => off());
   */
  function on(target, type, handler, options) {
    const listener = /** @type {EventListener} */ (/** @type {unknown} */ (handler));
    target.addEventListener(type, listener, options);
  
    let removed = false;
    return () => {
      if (removed) return;
      removed = true;
      target.removeEventListener(type, listener, options);
    };
  }

  /* ── packages/registry/_utils/motion.js ────────────────────────────── */
  /**
   * prefers-reduced-motion, as a live value.
   *
   * Reading the media query once at module load is wrong: the user can change the
   * setting while the page is open, and a component that cached the old answer
   * keeps animating. Every animated component gates on `prefersReducedMotion()`
   * and subscribes to `onReducedMotionChange()`.
   *
   * The contract: reduced motion means static but complete. Never blank.
   *
   * Zero dependencies.
   */
  
  const QUERY = '(prefers-reduced-motion: reduce)';
  
  /** @type {MediaQueryList | null} */
  let query = null;
  
  /**
   * Lazily resolve the MediaQueryList. Returns null where matchMedia is missing,
   * which is the SSR pass and some test environments.
   *
   * @returns {MediaQueryList | null}
   */
  function mediaQuery() {
    if (query) return query;
    if (typeof matchMedia !== 'function') return null;
    query = matchMedia(QUERY);
    return query;
  }
  
  /**
   * Whether the user has asked for reduced motion, right now.
   *
   * Defaults to `false` when the query is unavailable — animation is the design
   * intent, and a server render has no user preference to read.
   *
   * @returns {boolean}
   */
  function prefersReducedMotion() {
    return mediaQuery()?.matches ?? false;
  }
  
  /**
   * Subscribe to changes in the preference.
   *
   * @param {(reduced: boolean) => void} handler
   * @returns {() => void} unsubscribe
   *
   * @example
   * let reduced = prefersReducedMotion();
   * const stopWatching = onReducedMotionChange((next) => {
   *   reduced = next;
   *   if (reduced) settleToFinalState();
   * });
   * // destroy: stopWatching();
   */
  function onReducedMotionChange(handler) {
    const media = mediaQuery();
    if (!media) return () => {};
  
    /** @param {MediaQueryListEvent} event */
    const listener = (event) => handler(event.matches);
    media.addEventListener('change', listener);
  
    let removed = false;
    return () => {
      if (removed) return;
      removed = true;
      media.removeEventListener('change', listener);
    };
  }

  /* ── packages/registry/_utils/raf.js ───────────────────────────────── */
  /**
   * One requestAnimationFrame loop for the whole page.
   *
   * Twelve components each starting a private rAF means twelve callbacks the
   * browser schedules separately, and twelve places a leak can hide. Components
   * register here and get a deregister function back; the loop starts on the
   * first subscriber and stops on the last.
   *
   * Zero dependencies.
   */
  
  /**
   * @callback FrameCallback
   * @param {number} time Timestamp from rAF, in ms since page load.
   * @param {number} delta Ms since the previous frame. 0 on the first frame.
   * @returns {void}
   */
  
  /** @type {Set<FrameCallback>} */
  const callbacks = new Set();
  
  /** @type {number} Active rAF handle, or 0 when stopped. */
  let handle = 0;
  
  /** @type {number} Timestamp of the previous frame, or 0 before the first. */
  let previous = 0;
  
  /**
   * @param {number} time
   * @returns {void}
   */
  function tick(time) {
    handle = requestAnimationFrame(tick);
  
    const delta = previous === 0 ? 0 : time - previous;
    previous = time;
  
    // Iterate a copy: a callback may deregister itself, or another, mid-frame.
    for (const callback of [...callbacks]) {
      callback(time, delta);
    }
  }
  
  /**
   * @returns {void}
   */
  function start() {
    if (handle !== 0) return;
    if (typeof requestAnimationFrame !== 'function') return;
    previous = 0;
    handle = requestAnimationFrame(tick);
  }
  
  /**
   * @returns {void}
   */
  function stop() {
    if (handle === 0) return;
    cancelAnimationFrame(handle);
    handle = 0;
    previous = 0;
  }
  
  /**
   * Run a callback on every frame until the returned function is called.
   *
   * The returned function is idempotent, so a `destroy()` that runs twice is
   * harmless.
   *
   * @param {FrameCallback} callback
   * @returns {() => void} deregister
   *
   * @example
   * const stopFrame = onFrame((time, delta) => {
   *   x += velocity * delta;
   * });
   * // destroy: stopFrame();
   */
  function onFrame(callback) {
    callbacks.add(callback);
    start();
  
    let done = false;
    return () => {
      if (done) return;
      done = true;
      callbacks.delete(callback);
      if (callbacks.size === 0) stop();
    };
  }
  
  /**
   * Number of live subscribers. Exists so the lab harness and the lifecycle
   * checklist can assert that `destroy()` actually let go of the loop.
   *
   * @returns {number}
   */
  function frameSubscriberCount() {
    return callbacks.size;
  }

  /* ── packages/registry/intro-sequence/intro-sequence.js ────────────── */
  /**
   * Intro Sequence
   *
   * The page-load reveal: children rise and fade in on a stagger, once, and then
   * the component gets out of the way entirely.
   *
   * @see NOTES.md
   */
  
  
  
  
  
  const BASE = 'vc-intro-sequence';
  const ITEM = `${BASE}__item`;
  const PENDING = `${BASE}__item--pending`;
  
  /** Safety margin on the fallback timer, in ms. */
  const GRACE = 120;
  
  /**
   * Targets with a live instance. A second mount would hide already-hidden
   * children and double the delays, so it fails loudly instead.
   *
   * @type {WeakSet<HTMLElement>}
   */
  const mounted = new WeakSet();
  
  /**
   * @typedef {object} IntroSequenceOptions
   * @property {string} [selector] Which descendants to reveal, relative to the
   *   target. Defaults to its direct children.
   * @property {number} [stagger] Gap between one item starting and the next, ms.
   * @property {number} [distance] How far each item rises from, px.
   * @property {number} [maxDelay] Ceiling on the accumulated delay, ms. Keeps a
   *   long list from trailing in for several seconds.
   */
  
  /**
   * @typedef {object} IntroSequenceInstance
   * @property {() => void} destroy
   */
  
  /**
   * @param {HTMLElement} target
   * @param {IntroSequenceOptions} [options]
   * @returns {IntroSequenceInstance}
   */
  function createIntroSequence(target, options = {}) {
    if (mounted.has(target)) {
      throw new Error(
        'createIntroSequence: this element already has an instance — destroy it first'
      );
    }
  
    const {
      selector = ':scope > *',
      stagger = 90,
      distance = 18,
      maxDelay = 600
    } = options;
  
    /** @type {Array<() => void>} */
    const cleanups = [];
  
    mounted.add(target);
    target.classList.add(BASE);
  
    cleanups.push(() => {
      target.classList.remove(BASE);
      mounted.delete(target);
    });
  
    /** @type {HTMLElement[]} */
    const items = [...target.querySelectorAll(selector)].filter(
      (node) => node instanceof HTMLElement
    );
  
    /**
     * Put every item back exactly as it was found. Called on completion and on
     * destroy, and safe to call twice.
     *
     * @returns {void}
     */
    function strip() {
      for (const item of items) {
        /* Cancel anything still in flight. Removing the class that declared the
           transition is not enough — Chrome runs an already-started transition to
           completion regardless, so a half-faded item would keep fading after
           destroy() and one still inside its delay would begin after it.
           Only opacity and transform are touched: any animation the consumer put
           on this element is theirs, not ours. */
        for (const animation of item.getAnimations?.() ?? []) {
          const property = /** @type {{ transitionProperty?: string }} */ (animation)
            .transitionProperty;
          if (property === 'opacity' || property === 'transform') animation.cancel();
        }
  
        item.classList.remove(ITEM, PENDING);
        item.style.removeProperty('--vc-is-delay');
        item.style.removeProperty('--vc-is-distance');
        item.style.removeProperty('transition');
        if (item.getAttribute('style') === '') item.removeAttribute('style');
      }
    }
  
    /* ── Nothing to do ─────────────────────────────────────────────────────
       No children, or a visitor who asked for no motion. Either way the page is
       already in its final state, which is the whole requirement: static, but
       complete. Nothing is hidden, so nothing can fail to un-hide. */
  
    if (items.length === 0 || prefersReducedMotion()) {
      return {
        destroy() {
          while (cleanups.length > 0) cleanups.pop()?.();
        }
      };
    }
  
    /* ── Hide, synchronously ───────────────────────────────────────────────
       This runs before returning so a blocking script placed after the markup
       hides the items before the first paint, with no flash of the final state.
  
       transition: none while the hidden state is applied, because create() may
       also be called long after paint — without it, the items would visibly fade
       *out* before fading in. */
  
    for (const [index, item] of items.entries()) {
      item.style.transition = 'none';
      item.style.setProperty('--vc-is-delay', `${Math.min(index * stagger, maxDelay)}ms`);
      item.style.setProperty('--vc-is-distance', `${distance}px`);
      item.classList.add(ITEM, PENDING);
    }
  
    /* One forced layout commits the hidden state under transition: none. */
    target.getBoundingClientRect();
  
    for (const item of items) {
      item.style.removeProperty('transition');
    }
  
    cleanups.push(strip);
  
    /* ── Play ──────────────────────────────────────────────────────────────── */
  
    let finished = false;
  
    /** @type {ReturnType<typeof setTimeout> | null} */
    let safety = null;
  
    /** @type {(() => void) | null} */
    let stopFrame = null;
  
    /**
     * Bound at mount, not when the sequence starts playing: a keyboard visitor
     * can focus inside during the frame between hiding and playing, and that
     * frame is exactly when everything is at opacity 0.
     *
     * @type {Array<() => void>}
     */
    let watchers = [
      /* A keyboard visitor must never tab into something still at opacity 0.
         Any focus landing inside ends the sequence immediately. */
      on(target, 'focusin', () => finish()),
  
      /* Motion turned off mid-reveal: stop, do not animate the rest. */
      onReducedMotionChange((reduced) => {
        if (reduced) finish();
      })
    ];
  
    /**
     * Land everything in its final state now, and let go of every handle. The
     * one path that matters most: it must always end with the content visible.
     *
     * @returns {void}
     */
    function finish() {
      if (finished) return;
      finished = true;
  
      if (safety !== null) {
        clearTimeout(safety);
        safety = null;
      }
  
      stopFrame?.();
      stopFrame = null;
  
      for (const off of watchers) off();
      watchers = [];
  
      strip();
    }
  
    cleanups.push(finish);
  
    /**
     * @returns {void}
     */
    function play() {
      for (const item of items) item.classList.remove(PENDING);
  
      const last = items[items.length - 1];
      const declared = Number.parseFloat(getComputedStyle(last).transitionDuration) * 1000;
      const duration = Number.isFinite(declared) ? declared : 0;
      const total = Math.min((items.length - 1) * stagger, maxDelay) + duration + GRACE;
  
      /* The last item finishing is the sequence finishing. */
      watchers.push(on(last, 'transitionend', () => finish()));
  
      /* transitionend does not fire for an item that never rendered — inside a
         display: none parent, or a zero-size box. This is the floor. */
      safety = setTimeout(finish, total);
    }
  
    /* Flip on the next frame, so the hidden state is painted first. One frame,
       then the subscription is released — this component runs no loop. */
    stopFrame = onFrame(() => {
      stopFrame?.();
      stopFrame = null;
      play();
    });
  
    return {
      destroy() {
        while (cleanups.length > 0) cleanups.pop()?.();
      }
    };
  }

  /* ── exports ─────────────────────────────────────────────────────────── */
  var VC = (window.VC = window.VC || {});
  VC.createIntroSequence = createIntroSequence;

  /* Every export in the graph, for checklist assertions from the demo page. */
  VC.__all = {
    el: el,
    svgEl: svgEl,
    on: on,
    prefersReducedMotion: prefersReducedMotion,
    onReducedMotionChange: onReducedMotionChange,
    onFrame: onFrame,
    frameSubscriberCount: frameSubscriberCount,
    createIntroSequence: createIntroSequence
  };
})();

intro-sequence.css

1.4KB · 45 lines

Raw
View source
/* GENERATED — run pnpm build:components. Source: packages/registry/intro-sequence/intro-sequence.css */

/**
 * Intro Sequence
 *
 * Reads tokens, never defines them. No global rules — every selector is scoped
 * under .vc-intro-sequence.
 *
 * The hidden state is applied by JavaScript, never by this stylesheet. If it
 * lived here, a script that failed to load would leave the page permanently
 * blank. A flash of the finished page is a far better failure than no page.
 */

.vc-intro-sequence__item {
  transition:
    opacity var(--vc-dur-slow) var(--vc-ease-out) var(--vc-is-delay, 0s),
    transform var(--vc-dur-slow) var(--vc-ease-out) var(--vc-is-delay, 0s);
}

/**
 * translate3d, not translateY: it promotes each item to its own layer for the
 * duration of the reveal, which keeps a long stagger off the main thread. The
 * class is removed when the sequence ends, so the layers go with it.
 */
.vc-intro-sequence__item--pending {
  opacity: 0;
  transform: translate3d(0, var(--vc-is-distance, 0), 0);
}

/**
 * Belt and braces. The factory already declines to hide anything under reduced
 * motion; this covers a preference that changes mid-reveal, before the factory
 * has stripped the classes.
 */
@media (prefers-reduced-motion: reduce) {
  .vc-intro-sequence__item {
    transition: none;
  }

  .vc-intro-sequence__item--pending {
    opacity: 1;
    transform: none;
  }
}