Drip Type
Letters that bleed downward on pointer proximity.
free vanilla 24.4KB textcanvaspointer
Options
| Option | Type | Default | Notes |
|---|---|---|---|
text | string | null | Overrides the element's own text. Read from textContent when omitted. |
density | number | 0.6 | Drip particles per letter, per second, at the pointer. |
color | string | "var(--vc-ink)" | Any CSS colour, resolved against the element — a token or currentColor both work. |
speed | number | 1 | Multiplies how fast the ink falls. |
radius | number | 160 | Pointer influence radius in px. Vertical reach is 0.8 of this. |
reach | number | 140 | How far below the glyph a drip runs before fading, px. The canvas extends this far past the text. |
maxDrips | number | 240 | Ceiling on live drips, and the pool size — so the loop allocates nothing. |
maxChars | number | 400 | Above this the headline renders static instead of splitting. |
Install
-
Copy
drip-type.jsanddrip-type.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
createDripTypeon a<div>.
<link rel="stylesheet" href="tokens.css" />
<link rel="stylesheet" href="drip-type.css" />
<script defer src="drip-type.js"></script> const instance = VC.createDripType(
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 createDripType on
window.VC. If you would rather have the ES module, it is
packages/registry/drip-type/ 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/drip-type/drip-type.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.createDripType
*/
(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/drip-type/drip-type.js ──────────────────────── */
/**
* Drip Type
*
* Letters that bleed downward on pointer proximity. The text stays real text —
* selectable, themed, and laid out by the browser — and a canvas behind it
* carries the ink running off the bottom of each glyph.
*
* @see NOTES.md
*/
const BASE = 'vc-drip-type';
/** How long a drip swells at the glyph before gravity takes it, in seconds. */
const SWELL = 0.34;
/**
* Fall acceleration at speed 1, px/s². Low on purpose: ink creeps, and the
* reach is short enough that anything faster arrives before it is read as ink.
*/
const GRAVITY = 200;
/**
* How often the ink colour is re-resolved from the cascade, in ms. Cheap
* insurance against a theme switch — see resolveInk().
*/
const INK_TTL = 500;
/** Longest frame the integrator will accept, ms. Beyond this it is a tab-out. */
const MAX_STEP = 64;
const TAU = Math.PI * 2;
/**
* Targets with a live instance, mapped to the child nodes they had before.
* Mounting twice 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} DripTypeOptions
* @property {string | null} [text] Overrides the element's own text. Read from
* `target.textContent` when omitted.
* @property {number} [density] Drips per second per letter, at the pointer.
* @property {string} [color] Any CSS colour, resolved against the element — so
* a token or `currentColor` both work.
* @property {number} [speed] Multiplies how fast the ink falls.
* @property {number} [radius] Pointer influence radius, px. Vertical reach is
* 0.8 of this.
* @property {number} [reach] How far below the glyph a drip runs before it has
* faded out, px. The canvas extends this far past the text.
* @property {number} [maxDrips] Ceiling on live drips. Also the pool size, so
* the loop allocates nothing.
* @property {number} [maxChars] Above this the component renders static text
* instead of splitting.
*/
/**
* @typedef {object} DripTypeInstance
* @property {() => void} destroy
*/
/**
* @typedef {object} Char
* @property {HTMLElement} node
* @property {number} cx Centre x, canvas-local px.
* @property {number} cy Centre y, canvas-local px.
* @property {number} width Advance width, px.
* @property {number} bottom Where this glyph's ink ends, canvas-local px.
*/
/**
* @typedef {object} Drip
* @property {number} x
* @property {number} y Head position.
* @property {number} anchor Where it left the glyph.
* @property {number} vy
* @property {number} age Seconds since it appeared.
* @property {number} swell Seconds it clings before falling.
* @property {number} weight Head radius at full size, px.
*/
/**
* The computed `font` shorthand, with a longhand fallback for the cases where
* the shorthand serialises empty.
*
* @param {CSSStyleDeclaration} style
* @returns {string}
*/
function fontString(style) {
if (style.font) return style.font;
return `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
}
/**
* @param {HTMLElement} target
* @param {DripTypeOptions} [options]
* @returns {DripTypeInstance}
*/
function createDripType(target, options = {}) {
if (mounted.has(target)) {
throw new Error(
'createDripType: this element already has an instance — destroy it first'
);
}
const {
text = null,
density = 0.6,
color = 'var(--vc-ink)',
speed = 1,
radius = 160,
reach = 140,
maxDrips = 240,
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 beside it is aria-hidden — 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.
The canvas is aria-hidden too and takes no pointer events. It is the only
part of this component that is decoration; everything readable is text. */
const visual = el('span', { class: `${BASE}__visual` });
/** @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, width: 0, bottom: 0 });
}
visual.append(wordNode, ' ');
}
/* Only the split layer needs hiding, and only then does the hidden copy
earn its place. Text left whole is already perfectly readable to a
screen reader, and duplicating it would mean two copies in the DOM for
nothing. */
visual.setAttribute('aria-hidden', 'true');
} else if (content.length > 0) {
/* Too long to split. Still a complete headline, just a still one. */
visual.append(content);
}
const canvas = el('canvas', { class: `${BASE}__canvas`, 'aria-hidden': 'true' });
canvas.style.setProperty('--vc-dt-reach', `${reach}px`);
if (split) {
target.replaceChildren(
el('span', { class: `${BASE}__label` }, [content]),
visual,
canvas
);
} else {
target.replaceChildren(visual, canvas);
}
cleanups.push(() => {
target.classList.remove(BASE);
target.replaceChildren(...original);
mounted.delete(target);
});
const context = canvas.getContext('2d');
/* ── Nothing to animate ────────────────────────────────────────────────
No text, too much text, or no 2D context. The headline is already in its
final state, which is the whole requirement: static, but complete. */
if (!context || chars.length === 0) {
canvas.remove();
return {
destroy() {
while (cleanups.length > 0) cleanups.pop()?.();
}
};
}
/* Rebound past the guard. Everything below is a hoisted function declaration,
which the type checker has to assume could be called before the guard ran —
so the narrowing has to be carried by a binding the guard is upstream of. */
const ctx = context;
/* ── State ─────────────────────────────────────────────────────────────── */
let pointerX = 0;
let pointerY = 0;
let pointerActive = false;
let reduced = prefersReducedMotion();
let visible = true;
/** Radius of a drip's head at full size, from the font size. */
let weight = 1;
/** Resolved ink colour, and when it was last read from the cascade. */
let ink = '';
let inkAt = Number.NEGATIVE_INFINITY;
/** @type {(() => void) | null} Live rAF subscription. */
let frameHandle = null;
/** @type {Array<() => void>} Pointer listeners, bound only while animating. */
let pointerCleanups = [];
/* Preallocated, and never grown: the pool is the ceiling. The loop runs at
60fps with a live particle system, so it allocates nothing at all. */
const capacity = Math.max(1, Math.floor(maxDrips));
/** @type {Drip[]} */
const pool = Array.from({ length: capacity }, () => ({
x: 0,
y: 0,
anchor: 0,
vy: 0,
age: 0,
swell: SWELL,
weight: 1
}));
/** How many of `pool` are live. Live drips are always pool[0 … live-1]. */
let live = 0;
/* ── Ink ───────────────────────────────────────────────────────────────
A canvas cannot read a CSS custom property, so the colour has to be
resolved into an rgb() before it can be painted. The canvas resolves it
against itself: it sits inside the target, so it inherits exactly the
custom properties the text does, and setting `color` on a canvas paints
nothing of its own. No probe element, no assumption about where the token
is defined.
Re-resolved on a timer rather than watched. A theme can change by an
attribute, a class, or prefers-color-scheme, and one read every half second
while the loop is already running catches all three for less than an
observer costs to wire up. */
/**
* @param {number} time
* @returns {void}
*/
function resolveInk(time) {
if (time - inkAt < INK_TTL) return;
inkAt = time;
/* Cleared first, so an invalid colour falls back to the inherited text
colour rather than to whatever was set last time. */
canvas.style.color = '';
canvas.style.color = color;
const resolved = getComputedStyle(canvas).color;
if (!resolved || resolved === ink) return;
ink = resolved;
ctx.fillStyle = ink;
}
/* ── Measure ───────────────────────────────────────────────────────────── */
/**
* Size the backing store and cache where every glyph's ink ends.
*
* The ink bottom is the part worth getting right. A span's rect bottom is
* the font's descender line, identical for every character on a line, so a
* drip hung off it leaves `H` and `o` dripping from empty space below the
* letter. Chrome's Range rects report the same box, so they are no help
* either.
*
* What does know is the font itself: the baseline sits
* `fontBoundingBoxDescent` above the rect bottom, and each glyph's ink ends
* `actualBoundingBoxDescent` below that baseline. So `H` drips from the
* baseline and `y` drips 12px lower, which is where its tail actually is.
*
* @returns {void}
*/
function measure() {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const width = Math.max(1, Math.round(rect.width * dpr));
const height = Math.max(1, Math.round(rect.height * dpr));
/* Assigning width or height resets the whole context — transform, fill,
font, everything. So all of it has to be re-set below, every time, and
re-setting the fill from the cached ink is what stops a resize painting
black until the colour's next scheduled re-resolve. */
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = ink;
const style = getComputedStyle(target);
ctx.font = fontString(style);
weight = Math.max(1, (Number.parseFloat(style.fontSize) || 16) * 0.042);
/* Per font, not per glyph: the distance from the rect bottom up to the
baseline. */
const toBaseline = ctx.measureText('Hg').fontBoundingBoxDescent || 0;
for (const char of chars) {
const box = char.node.getBoundingClientRect();
char.cx = box.left - rect.left + box.width / 2;
char.cy = box.top - rect.top + box.height / 2;
char.width = box.width;
const descent = ctx.measureText(
char.node.textContent ?? ''
).actualBoundingBoxDescent;
char.bottom = box.bottom - rect.top - toBaseline + Math.max(0, descent || 0);
}
}
/* ── Drips ─────────────────────────────────────────────────────────────── */
/**
* @param {Char} char
* @returns {void}
*/
function spawn(char) {
if (live >= capacity) return;
const drip = pool[live];
live += 1;
/* Across the glyph rather than always at its centre, so a wide letter
bleeds from several places and a narrow one from roughly one. */
drip.x = char.cx + (Math.random() - 0.5) * char.width * 0.7;
drip.anchor = char.bottom;
drip.y = char.bottom;
drip.vy = 0;
drip.age = 0;
drip.swell = SWELL * (0.7 + Math.random() * 0.6);
drip.weight = weight * (0.7 + Math.random() * 0.6);
}
/**
* Retire drip `index` by swapping the last live one into its place. Nothing
* is spliced and nothing is allocated; the pool only ever gets reordered.
*
* @param {number} index
* @returns {void}
*/
function retire(index) {
live -= 1;
const dead = pool[index];
pool[index] = pool[live];
pool[live] = dead;
}
/**
* @param {number} dt Seconds.
* @returns {void}
*/
function advance(dt) {
const gravity = GRAVITY * speed;
for (let i = 0; i < live; ) {
const drip = pool[i];
drip.age += dt;
if (drip.age < drip.swell) {
/* Clinging. It beads at the glyph and barely moves — this is the part
that reads as ink rather than as rain. */
const t = drip.age / drip.swell;
drip.y = drip.anchor + t * t * 2;
} else {
drip.vy += gravity * dt;
drip.y += drip.vy * dt;
}
if (drip.y - drip.anchor > reach) retire(i);
else i += 1;
}
}
/**
* Wipe the whole backing store.
*
* In device pixels, under an identity transform, rather than by measuring the
* rendered box. The box is the wrong thing to ask twice over: it costs a
* layout read, and it reports 0×0 the moment the canvas is display: none —
* which is exactly the state reduced motion puts it in, so a clear that
* trusted it would leave the last frame of ink sitting in the buffer.
*
* @returns {void}
*/
function wipe() {
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.restore();
}
/**
* A drip is a wedge and a bead: narrow where it left the glyph, widening
* into a round head at the bottom. A constant-width stroke with a round cap
* is cheaper and reads as a scratch — the taper is what makes it ink.
*
* @returns {void}
*/
function draw() {
wipe();
for (let i = 0; i < live; i += 1) {
const drip = pool[i];
const fallen = drip.y - drip.anchor;
const t = fallen / reach;
const grown = Math.min(1, drip.age / drip.swell);
/* Full strength until it is past halfway, then out. */
ctx.globalAlpha = Math.min(1, (1 - t) * 2.2) * grown;
/* Swells while it clings, thins as it runs out of ink. */
const head = drip.weight * (0.45 + 0.55 * grown) * (1 - t * 0.35);
/* The trail reaches back to the glyph while the drip is still close to
it, then stops growing — so the ink visibly lets go and runs. */
const top = drip.y - Math.min(fallen, reach * 0.3);
const neck = head * 0.3;
ctx.beginPath();
ctx.moveTo(drip.x - neck, top);
ctx.lineTo(drip.x - head, drip.y);
ctx.lineTo(drip.x + head, drip.y);
ctx.lineTo(drip.x + neck, top);
ctx.closePath();
ctx.fill();
ctx.beginPath();
ctx.arc(drip.x, drip.y, head, 0, TAU);
ctx.fill();
}
ctx.globalAlpha = 1;
}
/* ── Frames ────────────────────────────────────────────────────────────── */
/**
* @param {number} time
* @param {number} delta
* @returns {void}
*/
function frame(time, delta) {
resolveInk(time);
const dt = Math.min(delta, MAX_STEP) / 1000;
if (pointerActive && dt > 0) {
const rect = canvas.getBoundingClientRect();
const px = pointerX - rect.left;
const py = pointerY - rect.top;
for (const char of chars) {
if (live >= capacity) break;
const dx = (px - char.cx) / radius;
const dy = (py - char.cy) / (radius * 0.8);
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance >= 1) continue;
const falloff = 1 - distance;
if (Math.random() < density * falloff * dt) spawn(char);
}
}
advance(dt);
draw();
/* Nothing left on screen and nothing feeding it: give the frame back. */
if (live === 0 && !pointerActive) sleep();
}
/**
* @returns {void}
*/
function wake() {
if (frameHandle || reduced || !visible) return;
frameHandle = onFrame(frame);
}
/**
* @returns {void}
*/
function sleep() {
if (!frameHandle) return;
frameHandle();
frameHandle = null;
}
/**
* Drop every drip and wipe the canvas, synchronously. The one path that
* matters most: it must always end with the headline clean.
*
* @returns {void}
*/
function clear() {
live = 0;
wipe();
}
cleanups.push(sleep);
/* ── 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 release() {
pointerActive = false;
/* Whatever is already falling still has to land. */
wake();
}
/**
* @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 keep
the last pointer position bleeding forever. */
on(document, 'pointerleave', release),
on(window, 'blur', release)
];
}
/**
* @returns {void}
*/
function unbindPointer() {
for (const off of pointerCleanups) off();
pointerCleanups = [];
}
cleanups.push(unbindPointer);
/* ── Off-screen ───────────────────────────────────────────────────────
A headline scrolled past should not be costing frames, and it should not
come back mid-drip either. */
if (typeof IntersectionObserver === 'function') {
const observer = new IntersectionObserver((entries) => {
visible = entries.some((entry) => entry.isIntersecting);
if (visible) {
measure();
} else {
sleep();
clear();
}
});
observer.observe(target);
cleanups.push(() => observer.disconnect());
}
/* ── Reflow ───────────────────────────────────────────────────────────
Wrapping changes where every glyph sits, and the canvas has to be resized
and rescaled with it. ResizeObserver fires at most once a frame, so this
needs no debounce. Drips in flight are dropped rather than left hanging
off coordinates that have moved. */
if (typeof ResizeObserver === 'function') {
const observer = new ResizeObserver(() => {
clear();
measure();
});
observer.observe(target);
cleanups.push(() => observer.disconnect());
}
/* ── Reduced motion ───────────────────────────────────────────────────
Static, but complete: the headline is fully present and readable, it just
does not bleed. The canvas is hidden by the stylesheet rather than
removed, so turning the preference back off needs no remount. */
/**
* @returns {void}
*/
function applyMotionPreference() {
if (reduced) {
unbindPointer();
sleep();
clear();
target.classList.add(`${BASE}--still`);
} else {
target.classList.remove(`${BASE}--still`);
measure();
bindPointer();
}
}
cleanups.push(() => target.classList.remove(`${BASE}--still`));
cleanups.push(
onReducedMotionChange((next) => {
reduced = next;
applyMotionPreference();
})
);
applyMotionPreference();
return {
destroy() {
while (cleanups.length > 0) cleanups.pop()?.();
}
};
}
/* ── exports ─────────────────────────────────────────────────────────── */
var VC = (window.VC = window.VC || {});
VC.createDripType = createDripType;
/* 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,
createDripType: createDripType
};
})();
View source
/* GENERATED — run pnpm build:components. Source: packages/registry/drip-type/drip-type.css */
/**
* Drip Type
*
* Reads tokens, never defines them. No global rules — every selector is scoped
* under .vc-drip-type. Colour, size and weight all inherit, so the headline
* looks like the page's headline and not like a component. The ink colour is
* resolved from the cascade by the factory, because a canvas cannot read a
* custom property.
*/
/**
* The positioning context for the canvas. A headline is a block anyway; this
* only makes it explicit, and makes the overlay's coordinates the element's
* own padding box.
*/
.vc-drip-type {
position: relative;
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-drip-type__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;
}
/**
* Above the canvas. Both are painted in the same colour, so the only thing
* this ordering decides is that a drip passing a letter runs behind it rather
* than over it.
*/
.vc-drip-type__visual {
position: relative;
z-index: 1;
display: block;
}
/**
* Words are atomic, so a line never breaks mid-word — splitting into
* characters would otherwise let the browser break between any two of them.
* max-width is the exception that matters: one word longer than the line wraps
* inside its own box instead of overflowing the container.
*/
.vc-drip-type__word {
display: inline-block;
max-width: 100%;
}
/**
* Left inline on purpose. An inline box's rect is font-metric height rather
* than line height, which is what the ink measurement is built on, and inline
* keeps the baseline alignment the browser already worked out.
*/
.vc-drip-type__char {
display: inline;
}
/**
* Sized to the text plus the reach the drips are given below it, which the
* factory writes as --vc-dt-reach. Takes no pointer events: everything under
* it stays clickable and selectable.
*
* width is stated explicitly, and that is not a style choice. A canvas is a
* replaced element, so anchoring it with `left: 0; right: 0` does not stretch
* it the way it would a div — it keeps `width: auto`, which for a replaced
* element means its intrinsic size. With only a height given it then takes the
* width from its intrinsic 2:1 ratio and lands nowhere near the text. Worse,
* the factory sizes the backing store from the rendered box, so the box would
* be deriving its size from the backing store that was derived from the box.
* A percentage width resolves against the containing block and breaks the loop.
*/
.vc-drip-type__canvas {
position: absolute;
top: 0;
left: 0;
z-index: 0;
display: block;
width: 100%;
height: calc(100% + var(--vc-dt-reach, 0px));
pointer-events: none;
}
/**
* Applied by the factory under reduced motion. Hidden rather than removed, so
* turning the preference back off needs no remount.
*/
.vc-drip-type--still .vc-drip-type__canvas {
display: none;
}
/**
* Belt and braces. The factory already declines to bind a pointer or take a
* frame under reduced motion; this covers the frame between a preference
* changing and the factory hearing about it.
*/
@media (prefers-reduced-motion: reduce) {
.vc-drip-type__canvas {
display: none;
}
}