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.

Tweak it

Code

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 };
}