Liquid Headline
A headline that lifts toward the pointer like a surface under a finger.
free vanilla 12.7KB textpointerspringheadline
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
-
Copy
liquid-headline.jsandliquid-headline.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
createLiquidHeadlineon 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.
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
};
})();
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;
}
}