Melting Text
A bold word that melts when you hover it. The letters droop, drips grow from the bottom edge of each glyph and drops pinch off and fall. Drag through the melted liquid and it parts like a finger through syrup, then flows back.
Copy markdown includes the prompt, your current settings and a single HTML file that runs on its own.
/* Melting Text — a word that melts, drips and parts like liquid under the cursor.
*
* mount(root, settings) renders into `root` and returns { onChange(key) }.
* `settings` is a plain object (start from DEFAULTS); change a value, then call
* onChange(key) to apply it live. CONTROLS describes the tweakable values for
* the playground's control panel.
*/
/* SETTINGS — every tweakable value lives here. */
export const DEFAULTS = {
// Type
text: 'PRIMA.',
textColor: '#ff3a3f',
bgColor: '#000000',
fontWeight: 800,
letterSpacing: 8, // extra space between letters (SVG units)
fontSize: 200, // internal detail resolution; the SVG scales to fit anyway
// Melt behaviour
mode: 'hover', // 'hover' | 'toggle' (click) | 'always'
meltDuration: 1.8, // seconds to fully melt
recoverDuration: 0.9, // seconds to re-solidify
sag: 18, // how far letters droop
stretch: 0.06, // vertical stretch of drooping letters
distortion: 12, // liquid wobble on the edges
noiseScale: 0.03, // size of the liquid streaks (smaller = wider streaks)
flowSpeed: 1.2, // how fast the liquid surface churns
dissolve: 0.35, // how much the letters erode and drops evaporate
// Drips
dripCount: 4, // drips per letter (approx.)
dripLength: 170, // max drip length
dripWidth: 18,
bulbSize: 1.45, // drip tip size relative to its width
dripRate: 1, // how often drops pinch off (0 = never)
gravity: 1400, // fall acceleration of detached drops
seed: 7, // re-roll drip placement
// Touch — the cursor parts the melted liquid like a finger
touchEnabled: true,
touchSize: 30, // radius of the "finger"
healTime: 1.1, // seconds for the liquid to flow back into the gap
cutDrips: true, // dragging through a drip severs it
stirBoost: 2.2, // drip rate near an aggressively moving cursor (2 = twice as many drops)
stirRadius: 5, // how far the stirring spreads, in finger sizes
// Goo (metaball merge)
goo: 7, // blur radius — bigger = rounder, gooier
gooSharpness: 18, // edge contrast of the goo threshold
// Motion / UI
reducedMotion: false, // the host turns this on when the visitor prefers reduced motion
showHint: true,
};
/* Control panel schema */
export const CONTROLS = [
{ group: 'Text', items: [
{ key: 'text', type: 'text', label: 'Text' },
{ key: 'textColor', type: 'color', label: 'Text colour' },
{ key: 'bgColor', type: 'color', label: 'Background' },
]},
{ group: 'Melt', items: [
{ key: 'mode', type: 'select', label: 'Trigger', options: [['hover','Hover'],['toggle','Click']] },
{ key: 'meltDuration', type: 'range', label: 'Melt time', min: 0.2, max: 5, step: 0.1, unit: 's' },
{ key: 'sag', type: 'range', label: 'Droop', min: 0, max: 80, step: 1 },
{ key: 'goo', type: 'range', label: 'Gooeyness', min: 1, max: 16, step: 0.5 },
]},
{ group: 'Drips', items: [
{ key: 'dripCount', type: 'range', label: 'Drips per letter', min: 0, max: 10, step: 1 },
{ key: 'dripLength', type: 'range', label: 'Length', min: 20, max: 400, step: 5 },
]},
{ group: 'Touch', items: [
{ key: 'touchEnabled', type: 'toggle', label: 'Cursor parts liquid' },
{ key: 'touchSize', type: 'range', label: 'Finger size', min: 8, max: 90, step: 1 },
]},
];
export function mount(root, S) {
const NS = 'http://www.w3.org/2000/svg';
const FONT = '"Rubik", "Arial Rounded MT Bold", "Helvetica Neue", Arial, sans-serif';
const uid = 'melt-' + Math.random().toString(36).slice(2, 8);
const REBUILD = new Set(['text', 'fontWeight', 'letterSpacing', 'dripCount', 'dripLength', 'seed']);
root.classList.add('melt-root');
root.innerHTML = '<div class="melt-stage" tabindex="0" role="img"></div><div class="melt-hint"></div>';
const stage = root.querySelector('.melt-stage');
const hint = root.querySelector('.melt-hint');
const canHover = window.matchMedia ? window.matchMedia('(hover: hover)').matches : true;
const el = (tag, attrs, parent) => {
const n = document.createElementNS(NS, tag);
for (const k in attrs) n.setAttribute(k, attrs[k]);
if (parent) parent.appendChild(n);
return n;
};
const clamp = (v, a = 0, b = 1) => Math.min(b, Math.max(a, v));
const smooth = x => x * x * (3 - 2 * x);
const rng = seed => { let a = seed >>> 0; return () => { a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; };
const gooValues = () => `1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 ${S.gooSharpness} ${0.5 - 0.5 * S.gooSharpness}`;
let svg, fx, content, dropsG, turb, disp, blur, gooM, blend, maskG, fingerC;
let ptr = null, trail = [], agitation = 0;
let letters = [], drips = [], drops = [];
let W = 0, H = 0, capTop = 0, wordBox = null;
let melt = 0, time = 0, hovered = false, focused = false, toggled = false, lastPointer = 'mouse';
let running = false, last = 0, buildToken = 0;
async function fontReady() {
if (!document.fonts || !document.fonts.load) return;
try {
await Promise.race([
document.fonts.load(`${S.fontWeight} ${S.fontSize}px "Rubik"`, S.text || 'A'),
new Promise(r => setTimeout(r, 2500)),
]);
} catch (e) { /* fall back to system font */ }
}
async function build() {
const token = ++buildToken;
await fontReady();
if (token !== buildToken) return;
const F = S.fontSize;
const chars = [...(S.text && S.text.length ? S.text : ' ')];
const cv = document.createElement('canvas');
const ctx = cv.getContext('2d', { willReadFrequently: true });
const font = `${S.fontWeight} ${F}px ${FONT}`;
ctx.font = font;
const cap = ctx.measureText('H').actualBoundingBoxAscent || F * 0.7;
const padX = F * 0.3, topSpace = F * 0.35, bottomSpace = S.dripLength * 1.9 + F * 0.4;
let x = 0;
const metrics = chars.map(ch => { const w = ctx.measureText(ch).width; const m = { ch, x, w }; x += w + S.letterSpacing; return m; });
const textW = Math.max(1, x - S.letterSpacing);
W = Math.ceil(textW + padX * 2);
H = Math.ceil(topSpace + cap + bottomSpace);
const baseline = topSpace + cap;
capTop = topSpace;
const rand = rng(S.seed * 9973 + 1);
// Find each glyph's bottom contour so drips grow from real edges
const gw = Math.ceil(F * 1.8), gh = Math.ceil(F * 1.4), off = F * 0.2, base = F * 1.1;
cv.width = gw; cv.height = gh;
letters = metrics.map(m => {
ctx.clearRect(0, 0, gw, gh);
ctx.font = font; ctx.fillStyle = '#000'; ctx.textBaseline = 'alphabetic';
ctx.fillText(m.ch, off, base);
const data = ctx.getImageData(0, 0, gw, gh).data;
const cands = [];
for (let cx = 0; cx < gw; cx += 2) {
for (let cy = gh - 1; cy >= 0; cy--) {
if (data[(cy * gw + cx) * 4 + 3] > 140) { cands.push({ x: cx - off, y: cy - base }); break; }
}
}
return { ...m, cands, sagF: 0.6 + rand() * 0.8, s: 1, ty: 0 };
});
// Hit area = the visible glyphs only (not the empty drip room below)
let minX = Infinity, maxX = -Infinity;
letters.forEach(L => L.cands.forEach(c => { minX = Math.min(minX, padX + L.x + c.x); maxX = Math.max(maxX, padX + L.x + c.x); }));
if (!isFinite(minX)) { minX = padX; maxX = padX + textW; }
wordBox = { x0: minX, x1: maxX, y0: topSpace, y1: baseline };
// Place drips: favour edges sitting on the baseline (feet of letters)
drips = [];
letters.forEach((L, li) => {
if (!L.cands.length || S.dripCount <= 0) return;
const minGap = S.dripWidth * 1.8;
const count = Math.max(1, Math.round(S.dripCount * clamp(L.w / (F * 0.62), 0.35, 1.4)));
const weights = L.cands.map(c => 0.15 + Math.pow(Math.max(0, 1 + c.y / cap), 8));
const total = weights.reduce((a, b) => a + b, 0);
const chosen = [];
for (let t = 0; t < count * 14 && chosen.length < count; t++) {
let r = rand() * total, i = 0;
while (i < weights.length - 1 && r > weights[i]) r -= weights[i++];
const c = L.cands[i];
if (chosen.some(o => Math.abs(o.x - c.x) < minGap)) continue;
chosen.push(c);
}
chosen.forEach(c => drips.push({
li, x: padX + L.x + c.x, y: baseline + c.y,
maxLen: S.dripLength * (0.22 + 0.78 * Math.pow(rand(), 1.3)),
delay: rand() * 0.45, speed: 0.6 + rand() * 0.8, wF: 0.75 + rand() * 0.5,
phase: rand() * Math.PI * 2, heat: 0, cut: Infinity, w: 0, extra: 0, thresh: 25 + rand() * 50, retract: false, bulb: 1, len: 0, r: 0,
}));
});
// SVG scene
stage.innerHTML = '';
stage.setAttribute('aria-label', `${S.text} — melting text`);
svg = el('svg', { viewBox: `0 0 ${W} ${H}`, 'aria-hidden': 'true' }, stage);
const defs = el('defs', {}, svg);
const filter = el('filter', { id: uid, filterUnits: 'userSpaceOnUse', x: 0, y: 0, width: W, height: H, 'color-interpolation-filters': 'sRGB' }, defs);
turb = el('feTurbulence', { type: 'fractalNoise', baseFrequency: `${S.noiseScale} ${S.noiseScale * 0.18}`, numOctaves: 2, seed: S.seed, result: 'n' }, filter);
el('feColorMatrix', { in: 'n', type: 'matrix', values: '0 0 0 0 0.5 0 1 0 0 0 0 0 0 0 0 0 0 0 0 1', result: 'vn' }, filter);
disp = el('feDisplacementMap', { in: 'SourceGraphic', in2: 'vn', scale: 0, xChannelSelector: 'R', yChannelSelector: 'G', result: 'd' }, filter);
blur = el('feGaussianBlur', { in: 'd', stdDeviation: S.goo, result: 'b' }, filter);
gooM = el('feColorMatrix', { in: 'b', type: 'matrix', values: gooValues(), result: 'g' }, filter);
blend = el('feComposite', { in: 'g', in2: 'SourceGraphic', operator: 'arithmetic', k1: 0, k2: 1, k3: 0, k4: 0 }, filter);
const mask = el('mask', { id: uid + '-m', maskUnits: 'userSpaceOnUse', x: 0, y: 0, width: W, height: H }, defs);
el('rect', { x: 0, y: 0, width: W, height: H, fill: '#fff' }, mask);
maskG = el('g', { fill: '#000' }, mask);
fingerC = el('circle', { cx: -999, cy: -999, r: 0 }, maskG);
trail = [];
fx = el('g', {}, svg);
content = el('g', { fill: S.textColor, mask: `url(#${uid}-m)` }, fx);
letters.forEach(L => {
L.g = el('g', {}, content);
const t = el('text', { x: padX + L.x, y: baseline, 'font-family': FONT, 'font-weight': S.fontWeight, 'font-size': F }, L.g);
t.textContent = L.ch;
});
drips.forEach(d => {
const g = letters[d.li].g;
d.rect = el('rect', { x: d.x, y: d.y, width: 0, height: 0 }, g);
d.circ = el('circle', { cx: d.x, cy: d.y, r: 0 }, g);
});
dropsG = el('g', {}, content);
drops = [];
// keep the word optically centred despite the drip room below it
svg.style.transform = `translateY(${((bottomSpace - topSpace) / 2) / H * 100}%)`;
kick();
}
const target = () => S.mode === 'always' ? 1 : S.mode === 'toggle' ? (toggled ? 1 : 0) : ((hovered || focused || toggled) ? 1 : 0);
function spawnAt(x, y, r, vy) {
const c = el('circle', { cx: x, cy: y, r }, dropsG);
drops.push({ x, y, vy, r, c });
if (drops.length > 120) drops.shift().c.remove();
}
function spawnDrop(d) {
const L = letters[d.li];
spawnAt(d.x, capTop + (d.y + d.len - capTop) * L.s + L.ty, d.r, 60 * (d.boost || 1) + 120 * ((d.boost || 1) - 1));
}
/* ---- Touch: the cursor pushes the liquid aside ---- */
const touchOn = () => S.touchEnabled && !S.reducedMotion;
function addTrail(x, y) {
const c = el('circle', { cx: x.toFixed(1), cy: y.toFixed(1), r: 0 }, maskG);
trail.push({ x, y, life: 1, c });
if (trail.length > 90) trail.shift().c.remove();
if (S.cutDrips) cutDripsAt(x, y);
}
// A drip the finger passes through snaps: the part below falls as a broken string of liquid
function cutDripsAt(x, y) {
const fr = S.touchSize * smooth(melt);
for (const d of drips) {
const curLen = Math.min(d.len, d.cut);
if (curLen < 10 || Math.abs(x - d.x) > fr * 0.8 + d.w / 2) continue;
const L = letters[d.li];
const at = capTop + (y - L.ty - capTop) / L.s - d.y; // distance down the drip
if (at < 6 || at > curLen - 3) continue;
const seg = curLen - at;
const n = Math.max(1, Math.round(seg / (d.w * 1.3)));
for (let i = 0; i < n; i++) {
const ly = d.y + at + seg * (i + 0.6) / n;
const gy = capTop + (ly - capTop) * L.s + L.ty;
const r = i === n - 1 ? Math.max(d.r, d.w * 0.6) : d.w * (0.42 + 0.2 * Math.random());
spawnAt(d.x + (Math.random() - 0.5) * 2, gy, r, 30 + i * 30 + Math.random() * 40);
}
d.cut = Math.max(0, at - fr * 0.6);
d.extra = 0; d.retract = false; d.bulb = 0.4;
}
}
function moveFinger(p) {
if (!touchOn() || !p) { ptr = p; return; }
const prev = ptr;
ptr = p;
if (melt < 0.05) return;
if (prev) {
const dx = p.x - prev.x, dy = p.y - prev.y, dist = Math.hypot(dx, dy);
agitation = Math.min(1, agitation + dist / (S.touchSize * 3));
const n = Math.min(14, Math.floor(dist / (S.touchSize * 0.35)));
for (let i = 1; i <= n; i++) addTrail(prev.x + dx * i / (n + 1), prev.y + dy * i / (n + 1));
}
addTrail(p.x, p.y);
kick();
}
function update(dt) {
const tgt = target();
const rm = S.reducedMotion;
if (rm) melt = tgt;
else if (tgt > melt) melt = Math.min(tgt, melt + dt / Math.max(0.05, S.meltDuration));
else melt = Math.max(tgt, melt - dt / Math.max(0.05, S.recoverDuration));
if (!rm) time += dt;
agitation *= Math.exp(-dt * 1.2);
const reach = S.touchSize * S.stirRadius;
for (const d of drips) {
let want = 0;
if (ptr && touchOn() && !rm) {
const near = clamp(1 - Math.abs(ptr.x - d.x) / reach);
want = smooth(near) * agitation;
}
d.heat += (want - d.heat) * Math.min(1, dt * (want > d.heat ? 6 : 1.5));
const boost = 1 + (S.stirBoost - 1) * d.heat;
d.boost = boost;
const local = clamp((melt - d.delay) / (1 - d.delay));
if (!rm && tgt === 1 && local >= 1 && S.dripRate > 0) {
if (!d.retract) {
d.extra += dt * S.dripRate * d.speed * 38 * boost;
if (d.extra > d.thresh / (1 + 0.5 * (boost - 1))) { spawnDrop(d); d.retract = true; d.bulb = 0.35; }
} else {
d.extra = Math.max(0, d.extra - dt * 260 * boost);
if (d.extra === 0) { d.retract = false; d.thresh = 25 + Math.random() * 55; }
}
} else {
d.extra *= Math.exp(-dt * 5);
if (d.extra < 0.1) d.extra = 0;
d.retract = false;
}
d.bulb += (1 - d.bulb) * Math.min(1, dt * 2.5);
}
for (let i = trail.length - 1; i >= 0; i--) {
const t = trail[i];
t.life -= dt / Math.max(0.1, S.healTime);
if (t.life <= 0 || !touchOn()) { t.c.remove(); trail.splice(i, 1); }
}
for (const d of drips) {
if (melt < 0.01) d.cut = Infinity;
else if (d.cut !== Infinity) {
d.cut += dt * 55 / Math.max(0.2, S.healTime);
if (d.cut > d.maxLen + d.extra + 4) d.cut = Infinity;
}
}
for (let i = drops.length - 1; i >= 0; i--) {
const p = drops[i];
p.vy += S.gravity * dt;
p.y += p.vy * dt;
p.r -= p.r * dt * (0.3 + S.dissolve * 1.6);
if (p.y - p.r > H || p.r < 1.2) { p.c.remove(); drops.splice(i, 1); continue; }
p.c.setAttribute('cy', p.y.toFixed(1));
p.c.setAttribute('r', p.r.toFixed(2));
}
}
function render() {
if (!svg) return;
const e = smooth(melt);
const active = melt > 0.001 || drops.length > 0;
if (active) fx.setAttribute('filter', `url(#${uid})`); else fx.removeAttribute('filter');
const a = Math.min(1, melt * 3);
blend.setAttribute('k2', a.toFixed(3));
blend.setAttribute('k3', (1 - a).toFixed(3));
disp.setAttribute('scale', (S.distortion * e).toFixed(2));
const t = time;
turb.setAttribute('baseFrequency',
`${(S.noiseScale * (1 + 0.015 * Math.sin(t * S.flowSpeed * 0.7 + 1))).toFixed(5)} ${(S.noiseScale * 0.18 * (1 + 0.25 * Math.sin(t * S.flowSpeed))).toFixed(5)}`);
content.setAttribute('opacity', (1 - 0.45 * S.dissolve * e).toFixed(3));
const fr = touchOn() ? S.touchSize * e : 0;
for (const t of trail) t.c.setAttribute('r', (fr * Math.pow(t.life, 0.7)).toFixed(2));
if (ptr && fr > 0.5) {
fingerC.setAttribute('cx', ptr.x.toFixed(1));
fingerC.setAttribute('cy', ptr.y.toFixed(1));
fingerC.setAttribute('r', fr.toFixed(2));
} else fingerC.setAttribute('r', 0);
for (const L of letters) {
L.s = 1 + S.stretch * e * L.sagF;
L.ty = S.sag * e * L.sagF;
L.g.setAttribute('transform', `matrix(1 0 0 ${L.s.toFixed(4)} 0 ${(L.ty + capTop - L.s * capTop).toFixed(2)})`);
}
for (const d of drips) {
const local = clamp((melt - d.delay) / (1 - d.delay));
const le = smooth(local);
const w = le > 0 ? S.dripWidth * d.wF * (0.75 + 0.25 * le) : 0;
const len = Math.min(d.maxLen * le + d.extra, d.cut);
const r = w * S.bulbSize * 0.5 * Math.min(1, le * 1.5) * d.bulb * (1 + 0.25 * Math.min(1, d.extra / d.thresh));
const sway = S.reducedMotion ? 0 : Math.sin(time * 1.3 + d.phase) * 1.5 * le;
const over = w * 0.8;
d.len = len; d.r = r; d.w = w;
d.rect.setAttribute('x', (d.x - w / 2 + sway * 0.4).toFixed(2));
d.rect.setAttribute('y', (d.y - over).toFixed(2));
d.rect.setAttribute('width', w.toFixed(2));
d.rect.setAttribute('height', Math.max(0, len + over).toFixed(2));
d.rect.setAttribute('rx', (w / 2).toFixed(2));
d.circ.setAttribute('cx', (d.x + sway).toFixed(2));
d.circ.setAttribute('cy', (d.y + len).toFixed(2));
d.circ.setAttribute('r', r.toFixed(2));
}
}
function tick(now) {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
update(dt);
render();
const tgt = target();
const quiet = drops.length === 0 && trail.length === 0 && agitation < 0.01 && drips.every(d => d.extra === 0 && d.cut === Infinity);
const idle = melt === tgt && quiet && (tgt === 0 || S.reducedMotion);
if (idle) { running = false; return; }
requestAnimationFrame(tick);
}
function kick() {
if (running) return;
running = true;
last = performance.now();
requestAnimationFrame(tick);
}
function updateHint() {
const txt = S.mode === 'always' ? '' : (S.mode === 'toggle' || !canHover) ? (canHover ? 'Click to melt' : 'Tap to melt') : 'Hover to melt';
hint.textContent = txt;
hint.style.display = S.showHint && txt ? '' : 'none';
}
// Interaction
stage.addEventListener('pointerdown', e => { lastPointer = e.pointerType; });
// Is the pointer over the letters themselves? (SVG units, small pad)
function toSvg(e) {
const m = svg && svg.getScreenCTM();
return m ? new DOMPoint(e.clientX, e.clientY).matrixTransform(m.inverse()) : null;
}
function overWord(p) {
if (!p || !wordBox) return false;
const pad = S.fontSize * 0.04;
const sagRoom = S.sag * smooth(melt); // letters droop, so follow them down
return p.x >= wordBox.x0 - pad && p.x <= wordBox.x1 + pad &&
p.y >= wordBox.y0 - pad + sagRoom * 0.5 && p.y <= wordBox.y1 + pad + sagRoom;
}
function setHover(v) {
stage.style.cursor = v ? 'pointer' : 'default';
if (v !== hovered) { hovered = v; kick(); }
}
stage.addEventListener('pointermove', e => {
const p = toSvg(e);
if (e.pointerType !== 'touch') setHover(overWord(p));
moveFinger(p);
});
stage.addEventListener('pointerleave', e => {
ptr = null;
if (e.pointerType !== 'touch') setHover(false);
kick();
});
stage.addEventListener('click', e => {
if ((S.mode === 'toggle' || lastPointer === 'touch') && overWord(toSvg(e))) { toggled = !toggled; kick(); }
});
stage.addEventListener('focus', () => { focused = S.mode === 'hover'; kick(); });
stage.addEventListener('blur', () => { focused = false; kick(); });
stage.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); focused = false; toggled = !toggled; kick(); }
});
// Live settings
function onChange(key) {
if (REBUILD.has(key)) { clearTimeout(onChange.t); onChange.t = setTimeout(build, key === 'text' ? 150 : 40); return; }
switch (key) {
case 'textColor': if (content) content.setAttribute('fill', S.textColor); root.style.setProperty('--dk-accent', S.textColor); break;
case 'bgColor': root.style.setProperty('--bg', S.bgColor); break;
case 'goo': if (blur) blur.setAttribute('stdDeviation', S.goo); break;
case 'gooSharpness': if (gooM) gooM.setAttribute('values', gooValues()); break;
case 'mode': toggled = false; updateHint(); break;
case 'showHint': updateHint(); break;
}
kick();
}
root.style.setProperty('--bg', S.bgColor);
root.style.setProperty('--dk-accent', S.textColor);
updateHint();
build();
return { onChange, rebuild: build };
}/* ---------- Experiment root (mount anywhere) ---------- */
.melt-root {
--bg: #000;
position: relative;
width: 100%;
height: 100%;
min-height: 100%;
background: var(--bg);
display: grid;
place-items: center;
overflow: hidden;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
transition: background-color .3s;
}
.melt-stage {
width: min(78%, 980px);
outline: none;
cursor: default;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
user-select: none;
-webkit-user-select: none;
}
.melt-stage svg { display: block; width: 100%; height: auto; overflow: visible; }
.melt-stage:focus-visible { outline: 1px dashed rgba(255,255,255,.25); outline-offset: 8px; border-radius: 8px; }
.melt-hint {
position: absolute; left: 50%; bottom: 22px; transform: translateX(-50%);
font-size: 11px; letter-spacing: .14em; text-transform: uppercase;
color: rgba(255,255,255,.5); pointer-events: none; white-space: nowrap;
}
@media (max-width: 640px) { .melt-stage { width: 92%; } }
@media (prefers-reduced-motion: reduce) {
.melt-root { transition: none; }
}Build a single self-contained HTML file (vanilla JS, no libraries) with a bold red word on black that “melts” on hover. Render each letter as SVG text. Scan each glyph on a canvas to find its bottom edge and place drips there; each drip is a rounded rect with a circle tip. Wrap everything in an SVG filter that applies vertical-streak turbulence displacement, then a gooey merge (Gaussian blur followed by an alpha-threshold colour matrix). On hover, letters droop and stretch slightly, drips grow, and drops periodically detach, fall with gravity and shrink away. On leave, everything recovers. Put all values in one settings object and add a compact control panel (sliders, colour pickers, toggles, copy/reset buttons) that updates them live. Keep it in one root element and respect prefers-reduced-motion.