Liquid Headline

A headline that lifts toward the pointer like a surface under a finger.

free vanilla 12.7KB textpointerspringheadline
Move the pointer across the headline.

Options

Option Type Default Notes
text string null Overrides the element's own text. Read from textContent when omitted.
radius number 180 Pointer influence radius in px. Vertical reach is 0.6 of this.
lift number 26 Peak upward displacement in px.
swell number 0.12 Peak extra scale at the pointer.
stiffness number 0.14 Spring constant, 0–1. Higher is snappier.
damping number 0.76 Velocity kept per frame, 0–1.
maxChars number 400 Above this the headline renders static instead of splitting.

Install

  1. Copy liquid-headline.js and liquid-headline.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 createLiquidHeadline on a <h1>.
<link rel="stylesheet" href="tokens.css" />
<link rel="stylesheet" href="liquid-headline.css" />

<script defer src="liquid-headline.js"></script>
const instance = VC.createLiquidHeadline(
  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 createLiquidHeadline on window.VC. If you would rather have the ES module, it is packages/registry/liquid-headline/ in the repo — same source, four files once _utils/ comes with it.

liquid-headline.js

21.3KB · 710 lines

Raw
View source
/**
 * GENERATED — run pnpm build:components — do not edit.
 *
 * Source of truth: packages/registry/liquid-headline/liquid-headline.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.createLiquidHeadline
 */
(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/liquid-headline/liquid-headline.js ──────────── */
  /**
   * Liquid Headline
   *
   * Splits a headline into per-character boxes and lifts them toward the pointer
   * on a spring, so the line behaves like a surface under a finger rather than a
   * row of letters with a hover state.
   *
   * @see NOTES.md
   */
  
  
  
  
  
  const BASE = 'vc-liquid-headline';
  
  /**
   * Below this a spring is treated as stopped, in px. Sub-pixel by a wide
   * margin: the tail of a damped spring is exponential, so a tighter bound buys
   * nothing visible and costs frames for another half second after the pointer
   * has gone.
   */
  const REST = 0.05;
  
  /**
   * Targets with a live instance, mapped to the child nodes they had before.
   * Mounting twice over the same element would read the split text back as input,
   * so that is an error rather than a silent mangle.
   *
   * @type {WeakMap<HTMLElement, ChildNode[]>}
   */
  const mounted = new WeakMap();
  
  /**
   * @typedef {object} LiquidHeadlineOptions
   * @property {string | null} [text] Overrides the element's own text. Read from
   *   `target.textContent` when omitted.
   * @property {number} [radius] Pointer influence radius, px. Vertical reach is
   *   0.6 of this, so the wave tracks along the line rather than above it.
   * @property {number} [lift] Peak displacement at the pointer, px. Negative is
   *   upward; pass the magnitude.
   * @property {number} [swell] Peak extra scale at the pointer, 0.12 = 12%.
   * @property {number} [stiffness] Spring constant, 0–1. Higher is snappier.
   * @property {number} [damping] Velocity retained per frame, 0–1. Lower settles
   *   sooner.
   * @property {number} [maxChars] Above this the component renders static text
   *   instead of splitting. Guards against a novel in an h1.
   */
  
  /**
   * @typedef {object} LiquidHeadlineInstance
   * @property {() => void} destroy
   */
  
  /**
   * @typedef {object} Char
   * @property {HTMLElement} node
   * @property {number} cx Centre x, in target-local px, measured at rest.
   * @property {number} cy Centre y, in target-local px, measured at rest.
   * @property {number} y Current displacement.
   * @property {number} v Current velocity.
   * @property {number} wroteY Last value written to the DOM.
   * @property {number} wroteS Last scale written to the DOM.
   */
  
  /**
   * @param {HTMLElement} target
   * @param {LiquidHeadlineOptions} [options]
   * @returns {LiquidHeadlineInstance}
   */
  function createLiquidHeadline(target, options = {}) {
    if (mounted.has(target)) {
      throw new Error(
        'createLiquidHeadline: this element already has an instance — destroy it first'
      );
    }
  
    const {
      text = null,
      radius = 180,
      lift = 26,
      swell = 0.12,
      stiffness = 0.14,
      damping = 0.76,
      maxChars = 400
    } = options;
  
    /** @type {Array<() => void>} */
    const cleanups = [];
  
    const original = [...target.childNodes];
    mounted.set(target, original);
    target.classList.add(BASE);
  
    const content = (text ?? target.textContent ?? '').replace(/\s+/g, ' ').trim();
  
    /* ── DOM ───────────────────────────────────────────────────────────────
       A visually hidden copy carries the text to assistive tech, and the split
       layer is hidden from it — letter-per-element markup otherwise gets read
       out one character at a time. The hidden copy is user-select: none, so a
       reader copying the headline gets it once, not twice. */
  
    const label = el('span', { class: `${BASE}__label` }, [content]);
    const visual = el('span', { class: `${BASE}__visual`, 'aria-hidden': 'true' });
  
    /** @type {Char[]} */
    const chars = [];
  
    const codepoints = [...content];
    const split = content.length > 0 && codepoints.length <= maxChars;
  
    if (split) {
      for (const word of content.split(' ')) {
        const wordNode = el('span', { class: `${BASE}__word` });
  
        for (const character of [...word]) {
          const node = el('span', { class: `${BASE}__char` }, [character]);
          wordNode.append(node);
          chars.push({ node, cx: 0, cy: 0, y: 0, v: 0, wroteY: 0, wroteS: 1 });
        }
  
        visual.append(wordNode, ' ');
      }
    } else if (content.length > 0) {
      /* Too long to split, or nothing to split. Still a complete headline. */
      visual.append(content);
    }
  
    target.replaceChildren(label, visual);
  
    /* ── State ─────────────────────────────────────────────────────────── */
  
    let pointerX = 0;
    let pointerY = 0;
    let pointerActive = false;
  
    let reduced = prefersReducedMotion();
    let visible = true;
  
    /** @type {(() => void) | null} Live rAF subscription. */
    let frameHandle = null;
  
    /** @type {Array<() => void>} Pointer listeners, bound only while animating. */
    let pointerCleanups = [];
  
    /**
     * Push a char's spring state to the DOM, skipping writes that would not
     * change a rendered pixel.
     *
     * @param {Char} char
     * @returns {void}
     */
    function write(char) {
      const y = Math.round(char.y * 100) / 100;
      if (y !== char.wroteY) {
        char.node.style.setProperty('--vc-lh-y', `${y}px`);
        char.wroteY = y;
      }
  
      const reach = lift === 0 ? 0 : Math.min(1, Math.max(0, char.y / -lift));
      const scale = Math.round((1 + swell * reach) * 1000) / 1000;
      if (scale !== char.wroteS) {
        char.node.style.setProperty('--vc-lh-s', String(scale));
        char.wroteS = scale;
      }
    }
  
    /**
     * @returns {void}
     */
    function snapToRest() {
      for (const char of chars) {
        char.y = 0;
        char.v = 0;
        write(char);
      }
    }
  
    /**
     * Cache each character's centre. Runs with every spring at rest, because a
     * transformed box reports its transformed rect and the geometry would drift
     * a little further on every resize.
     *
     * @returns {void}
     */
    function measure() {
      snapToRest();
  
      const base = target.getBoundingClientRect();
      for (const char of chars) {
        const rect = char.node.getBoundingClientRect();
        char.cx = rect.left - base.left + rect.width / 2;
        char.cy = rect.top - base.top + rect.height / 2;
      }
    }
  
    /**
     * @returns {void}
     */
    function frame() {
      const base = target.getBoundingClientRect();
      const px = pointerX - base.left;
      const py = pointerY - base.top;
  
      let moving = false;
  
      for (const char of chars) {
        let goal = 0;
  
        if (pointerActive) {
          const dx = (px - char.cx) / radius;
          const dy = (py - char.cy) / (radius * 0.6);
          const distance = Math.sqrt(dx * dx + dy * dy);
  
          if (distance < 1) {
            const f = 1 - distance;
            goal = -lift * (f * f * (3 - 2 * f));
          }
        }
  
        char.v += (goal - char.y) * stiffness;
        char.v *= damping;
        char.y += char.v;
  
        if (Math.abs(char.v) > REST || Math.abs(char.y - goal) > REST) moving = true;
  
        write(char);
      }
  
      /* Nothing to animate and nothing driving it: land exactly at rest, then
         give the frame back. Snapping avoids leaving a stray 0.04px behind. */
      if (!moving && !pointerActive) {
        snapToRest();
        sleep();
      }
    }
  
    /**
     * @returns {void}
     */
    function wake() {
      if (frameHandle || reduced || !visible || chars.length === 0) return;
      frameHandle = onFrame(frame);
    }
  
    /**
     * @returns {void}
     */
    function sleep() {
      if (!frameHandle) return;
      frameHandle();
      frameHandle = null;
    }
  
    /**
     * @returns {void}
     */
    function release() {
      pointerActive = false;
      wake();
    }
  
    /* ── Pointer ───────────────────────────────────────────────────────────
       Bound only while motion is allowed, so a reduced-motion visitor carries no
       listeners at all. Pointer events, not mouse events: one code path covers
       mouse, pen and touch. */
  
    /**
     * @returns {void}
     */
    function bindPointer() {
      if (pointerCleanups.length > 0) return;
  
      pointerCleanups = [
        on(
          window,
          'pointermove',
          (/** @type {PointerEvent} */ event) => {
            pointerX = event.clientX;
            pointerY = event.clientY;
            pointerActive = true;
            wake();
          },
          { passive: true }
        ),
  
        on(window, 'pointerup', (/** @type {PointerEvent} */ event) => {
          /* A finger that lifts has no hover to fall back on. A mouse does. */
          if (event.pointerType !== 'mouse') release();
        }),
  
        on(window, 'pointercancel', release),
  
        /* Leaving the document, or the window losing focus, would otherwise
           strand the wave mid-lift. */
        on(document, 'pointerleave', release),
        on(window, 'blur', release)
      ];
    }
  
    /**
     * @returns {void}
     */
    function unbindPointer() {
      for (const off of pointerCleanups) off();
      pointerCleanups = [];
    }
  
    cleanups.push(unbindPointer);
  
    /* ── Reduced motion ───────────────────────────────────────────────────
       Static, but complete: the headline is fully present and readable, it just
       does not move. */
  
    /**
     * @returns {void}
     */
    function applyMotionPreference() {
      if (reduced) {
        unbindPointer();
        sleep();
        snapToRest();
      } else {
        measure();
        bindPointer();
      }
    }
  
    cleanups.push(
      onReducedMotionChange((next) => {
        reduced = next;
        applyMotionPreference();
      })
    );
  
    /* ── Off-screen ───────────────────────────────────────────────────────
       A headline scrolled past should not be costing frames. */
  
    if (typeof IntersectionObserver === 'function') {
      const observer = new IntersectionObserver((entries) => {
        visible = entries.some((entry) => entry.isIntersecting);
        if (visible) {
          measure();
          wake();
        } else {
          sleep();
          snapToRest();
        }
      });
      observer.observe(target);
      cleanups.push(() => observer.disconnect());
    }
  
    /* ── Reflow ───────────────────────────────────────────────────────────
       Wrapping changes where every character sits, so the cached geometry has to
       go. ResizeObserver fires at most once a frame, so this needs no debounce. */
  
    if (chars.length > 0 && typeof ResizeObserver === 'function') {
      const observer = new ResizeObserver(() => measure());
      observer.observe(target);
      cleanups.push(() => observer.disconnect());
    }
  
    cleanups.push(sleep);
  
    if (chars.length > 0) applyMotionPreference();
  
    return {
      destroy() {
        while (cleanups.length > 0) cleanups.pop()?.();
  
        target.classList.remove(BASE);
        target.replaceChildren(...original);
        mounted.delete(target);
      }
    };
  }

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

  /* 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,
    createLiquidHeadline: createLiquidHeadline
  };
})();

liquid-headline.css

1.8KB · 69 lines

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

/**
 * Liquid Headline
 *
 * Reads tokens, never defines them. No global rules — every selector is scoped
 * under .vc-liquid-headline. Colour, size and weight all inherit, so the
 * headline looks like the page's headline and not like a component.
 */

.vc-liquid-headline {
  display: block;
}

/**
 * The text, for assistive tech only. The split layer beside it is aria-hidden,
 * because per-character elements get announced one letter at a time.
 *
 * user-select: none keeps it out of a copied selection, so the headline is
 * copied once rather than twice.
 */
.vc-liquid-headline__label {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
  user-select: none;
  -webkit-user-select: none;
}

.vc-liquid-headline__visual {
  display: block;
}

/**
 * Words are atomic, so a line never breaks mid-word. max-width is the exception
 * that matters: one word longer than the line wraps inside its own box instead
 * of overflowing the container.
 */
.vc-liquid-headline__word {
  display: inline-block;
  max-width: 100%;
}

/**
 * inline-block is load-bearing: transforms do not apply to a non-replaced
 * inline box. The custom properties are written by the spring loop.
 */
.vc-liquid-headline__char {
  display: inline-block;
  transform: translate(0, var(--vc-lh-y, 0)) scale(var(--vc-lh-s, 1));
}

/**
 * Belt and braces. The factory already declines to bind a pointer or take a
 * frame under reduced motion; this covers a stale inline value left behind by
 * a preference change mid-animation.
 */
@media (prefers-reduced-motion: reduce) {
  .vc-liquid-headline__char {
    transform: none;
  }
}