Intro Sequence
The page-load reveal — children rise and fade in on a stagger, once.
free vanilla 8.5KB revealloadstaggertransition
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
-
Copy
intro-sequence.jsandintro-sequence.cssinto your project. That is the whole component — it imports nothing. -
Include
tokens.cssonce, anywhere on the page. Every component reads its colour, spacing and motion from it. -
Link the two files, then call
createIntroSequenceon 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.
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
};
})();
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;
}
}