atelier

Catalog / Recipe

Reveal

The children of an element arrive as they scroll into view, each in its own time or all as one.

Family
scroll motion
Status
lab
Used for
ListingCard gridSection intro
Size
1,278 of 1,400 bytes, gzipped
Frame
Skin
mount(element)

The reference recipe. It exists to prove the recipe contract, the checks and the catalog stage end to end. It is a common effect and not the interesting part of this repo: what is worth keeping from it is the set of behaviours below, which every scroll-triggered recipe will need.

Use

import { mount } from './reveal';

const handle = mount(document.querySelector('ul.projects'));
handle.destroy();

Mount it on the list, not on the items. Its direct children are what arrive. mount accepts the null a failed querySelector returns and does nothing with it.

Options

Option Default Meaning
perItem true Each child waits until it is itself on screen. Off, every child arrives when the list does
distance 16 How far each child travels, in px
duration 600 How long one child takes, in ms
stagger 70 The delay between children arriving together, in ms
maxStagger 350 A ceiling on the accumulated delay, in ms
threshold 0.08 How much of a target must be on screen, 0 to 1

duration falls back to the element’s --motion-duration custom property before it falls back to the default.

perItem and threshold are read at mount. Changing them through update has no effect until the recipe is mounted again. Children added after mount are not animated and are never hidden.

Which mode

  • Per item for anything that can be taller than the screen: a project list, a long grid. Rows arrive as the reader reaches them, and rows entering together are staggered among themselves.
  • As one for a short group that reads as a unit: a row of three cards, a stat strip.

What it does when things are missing

With reduced motion requested, or without IntersectionObserver or Element.animate, it changes nothing and returns a handle whose methods do nothing. If animate throws, the child is shown without animating.

Content is hidden only by this file, only after those checks pass, and every hidden child is tracked until it is shown. destroy() shows whatever is still hidden.

One limit: a child that is on the page but can never be scrolled onto the screen (clipped by an ancestor with overflow: hidden, say) stays hidden. Do not mount this on content that is clipped out of view.

Learned while building it

  • A threshold is a share of the target, and the screen is a fixed size. The first version watched the list with a threshold of 8%. A list more than about twelve screens tall can never be 8% visible, so it never revealed and its rows stayed at opacity: 0. Dragging the threshold up did the same to an ordinary list on a phone. The observer never errors in this case. It simply never reports. neededRatio caps what is asked for at three quarters of the most the screen can show.
  • In an iframe, rootBounds is somebody else’s screen. The observer’s implicit root is the top window, so inside a frame entry.rootBounds reports the top window’s size while the frame clips what can actually be seen. Measuring against it told a 1010px list in a 760px frame that it could be fully visible, and it waited for good. The recipe measures against its own innerWidth and innerHeight. No unit test could have found this: it took a real browser.
  • The observer only reports at the thresholds it was given. Capping the requirement is not enough if no report falls between “enough” and “the most there will ever be”. The steps are geometric so that one always does.
  • Watching the list reveals rows nobody can see. With one observer on the container, every row animates when the first sliver of the list appears, so the reader scrolls down to rows that have already arrived. Watching each child is the default for that reason.
  • Mounting twice stacked the damage. The second mount recorded opacity: 0 as each child’s original value and restored that. One mount per element is kept in a WeakMap, and a new mount destroys the old one first.
  • fill: 'backwards' holds the first keyframe through the delay, so the inline opacity: 0 can be removed the moment the animation is created. Nothing has to be cleaned up when it finishes.
  • One keyframe, at offset 0. The end of the animation is then the child’s own computed style, so a child with its own opacity or transform arrives at the right value instead of snapping to it.
  • The platform throws on a threshold above 1 and on a negative duration. Options are clamped before they reach it.
reveal.ts233 lines · 1,278 bytes gzipped · no imports
/**
 * reveal
 *
 * The children of an element arrive when they scroll into view. Once.
 *
 * Copy this file. It imports nothing.
 *
 *     import { mount } from './reveal';
 *     const handle = mount(document.querySelector('ul.projects'));
 *     // later
 *     handle.destroy();
 *
 * By default each child waits until it is itself on screen, so a list
 * taller than the viewport arrives as the reader reaches it. Pass
 * `perItem: false` for a short group that should arrive as one.
 *
 * It reads `--motion-duration` from the element if one is set, so a
 * design system that already has a duration token drives it without an
 * option being passed.
 */

export interface Options {
  /** How far each child travels, in px. */
  distance: number;
  /** How long one child takes, in ms. */
  duration: number;
  /** The delay between one child and the next arriving together, in ms. */
  stagger: number;
  /** A ceiling on the accumulated delay, in ms, so a long list is not kept waiting. */
  maxStagger: number;
  /**
   * How much of a target must be on screen before it reveals, 0 to 1. A
   * target too big to ever show that much reveals once it covers three
   * quarters of the screen instead. Read at mount.
   */
  threshold: number;
  /**
   * Each child waits until it is itself on screen. Off, every child
   * arrives when the element does. Read at mount.
   */
  perItem: boolean;
}

export const DEFAULTS: Options = {
  distance: 16,
  duration: 600,
  stagger: 70,
  maxStagger: 350,
  threshold: 0.08,
  perItem: true,
};

export interface Handle {
  update(options: Partial<Options>): void;
  destroy(): void;
}

interface Size {
  width: number;
  height: number;
}

/** A CSS time, in ms. `null` for anything that is not one. */
export function parseDuration(value: string): number | null {
  const match = /^\s*(\d*\.?\d+)(ms|s)\s*$/.exec(value);
  if (!match) return null;
  const amount = Number(match[1]);
  return match[2] === 's' ? amount * 1000 : amount;
}

export function staggerDelay(index: number, stagger: number, maxStagger: number): number {
  return Math.min(Math.max(index, 0) * Math.max(stagger, 0), Math.max(maxStagger, 0));
}

/**
 * An explicit option wins, then the element's own duration token, then
 * the default. Whatever arrives is made safe: the platform throws on a
 * threshold above 1 or a negative duration, and a recipe must not.
 */
export function resolveOptions(token: string, explicit: Partial<Options>): Options {
  const merged: Options = { ...DEFAULTS };
  const fromToken = parseDuration(token);
  if (fromToken !== null) merged.duration = fromToken;

  for (const key of ['distance', 'duration', 'stagger', 'maxStagger', 'threshold'] as const) {
    const value = explicit[key];
    if (typeof value === 'number' && Number.isFinite(value)) merged[key] = value;
  }
  if (typeof explicit.perItem === 'boolean') merged.perItem = explicit.perItem;

  return {
    distance: merged.distance,
    duration: Math.max(merged.duration, 0),
    stagger: Math.max(merged.stagger, 0),
    maxStagger: Math.max(merged.maxStagger, 0),
    threshold: Math.min(Math.max(merged.threshold, 0), 1),
    perItem: merged.perItem,
  };
}

/**
 * The share of a target that has to be on screen before it reveals.
 *
 * Not simply the threshold, because a threshold is a share of the
 * TARGET and the screen is a fixed size: a list two screens tall can
 * never be more than half visible, so asking for 90% of it hides it for
 * good. Whatever is asked for is capped at three quarters of the most
 * the screen can show, which leaves room for the observer to report it.
 */
export function neededRatio(threshold: number, target: Size, root: Size): number {
  if (!(target.width > 0 && target.height > 0 && root.width > 0 && root.height > 0)) return 0;
  const reachable = Math.min(1, root.width / target.width) * Math.min(1, root.height / target.height);
  if (reachable >= 1) return threshold;
  // So large that its ratio is noise: any of it on screen counts.
  if (reachable < 0.01) return 0;
  return Math.min(threshold, reachable * 0.75);
}

/**
 * Ratios the observer reports at. Geometric, because what matters is a
 * report somewhere between "enough" and "the most there will ever be",
 * and for a big target those two are both small and close together.
 */
const STEPS = [0, ...Array.from({ length: 22 }, (_, index) => 1.25 ** -index)];

const NOOP: Handle = { update() {}, destroy() {} };

/** One mount to an element. A second replaces the first. */
const mounted = new WeakMap<HTMLElement, Handle>();

export function mount(element: HTMLElement | null, options: Partial<Options> = {}): Handle {
  // Anything missing, and the element is left exactly as it was found.
  if (!element || typeof IntersectionObserver === 'undefined') return NOOP;
  if (typeof element.animate !== 'function') return NOOP;
  // The one place motion is decided, so a host can override matchMedia.
  if (typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches) return NOOP;

  const items = Array.from(element.children).filter(
    (child): child is HTMLElement => child instanceof HTMLElement,
  );
  if (items.length === 0) return NOOP;

  mounted.get(element)?.destroy();

  let explicit: Partial<Options> = { ...options };
  const style = getComputedStyle(element);
  const resolve = (): Options => resolveOptions(style.getPropertyValue('--motion-duration'), explicit);
  const { threshold, perItem } = resolve();
  // An element with no box of its own is never reported as on screen,
  // so its children are watched in its place.
  const each = perItem || style.display === 'contents';

  // Hidden by this script, so shown again by this script: the same code
  // that hides is always present to unhide. `hidden` is what is still
  // ours to restore, and nothing else is ever touched.
  const hidden = new Map<HTMLElement, string>();
  for (const item of items) {
    hidden.set(item, item.style.opacity);
    item.style.opacity = '0';
  }
  const show = (item: HTMLElement): void => {
    const original = hidden.get(item);
    if (original === undefined) return;
    hidden.delete(item);
    item.style.opacity = original;
  };

  const animations: Animation[] = [];
  const reveal = (batch: HTMLElement[]): void => {
    const { distance, duration, stagger, maxStagger } = resolve();
    batch.forEach((item, index) => {
      try {
        // One keyframe, at the start: the end is whatever the child
        // already is, so its own opacity and transform are respected.
        animations.push(
          item.animate([{ offset: 0, opacity: 0, transform: `translateY(${distance}px)` }], {
            duration,
            delay: staggerDelay(index, stagger, maxStagger),
            easing: 'cubic-bezier(0.33, 1, 0.68, 1)',
            // Holds the first frame through the delay, which is what
            // lets the inline opacity go back at once.
            fill: 'backwards',
          }),
        );
      } catch {
        // No animation, then. The child is shown all the same.
      }
      show(item);
    });
  };

  const observer = new IntersectionObserver(
    (entries) => {
      const ready = new Set<Element>();
      // This window, not `entry.rootBounds`: inside an iframe the
      // observer's root is the top window, which is a screen this
      // document does not have.
      const screen = { width: innerWidth, height: innerHeight };
      for (const entry of entries) {
        if (
          entry.isIntersecting &&
          entry.intersectionRatio >= neededRatio(threshold, entry.boundingClientRect, screen)
        ) {
          ready.add(entry.target);
          observer.unobserve(entry.target);
        }
      }
      if (ready.size === 0) return;
      // Document order, whatever order the observer reported in.
      reveal(items.filter((item) => hidden.has(item) && (!each || ready.has(item))));
      if (hidden.size === 0) observer.disconnect();
    },
    { threshold: [...new Set([...STEPS, threshold])] },
  );
  for (const target of each ? items : [element]) observer.observe(target);

  let destroyed = false;
  const handle: Handle = {
    update(next) {
      explicit = { ...explicit, ...next };
    },
    destroy() {
      if (destroyed) return;
      destroyed = true;
      observer.disconnect();
      for (const animation of animations) animation.cancel();
      for (const item of [...hidden.keys()]) show(item);
      if (mounted.get(element) === handle) mounted.delete(element);
    },
  };
  mounted.set(element, handle);
  return handle;
}