Copied!
Navigation and lists

Gooey text hover

A menu of big words with a gooey blob that stretches and follows the pointer from row to row while the active word turns bold.

← Library

Element · Vanilla JS + SVG filter + CSSUpdated November 5, 2026

TextElementVanilla JS + SVG filter + CSS

Live preview needs JavaScript. The code below works without any dependencies.

Prompt for your AI assistant

Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.

Build "Gooey text hover" for my site using the Enrichr Library element at https://enrichr.dev/elements/gooey-text-hover.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/gooey-text-hover.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.

Settings:
- items: Work, Studio, Journal, Archive, Contact
- goo: 9
- rest: 1
- color: #fbf8f1
- blob: #e6ff55

Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.

The same spec as JSON, for tools that read it directly: /elements/gooey-text-hover.json.

Code

Mount it on an element that contains the markup. It returns an object with update(options) and destroy().

HTML

<div class="ef-goo"><div class="ef-goo__blob" aria-hidden="true"><i class="ef-goo__pill"></i><i class="ef-goo__dot"></i><i class="ef-goo__dot ef-goo__dot--b"></i></div><ul class="ef-goo__list"></ul></div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-goo { container-type: inline-size; position: relative; width: 100%; min-height: 16rem; padding: 0.4rem 0; box-sizing: border-box; }
.ef-goo__blob { position: absolute; inset: 0; pointer-events: none; }
.ef-goo__pill { position: absolute; left: 2%; top: 0; width: 96%; height: 0; border-radius: 999px; background: var(--ef-goo-b, #e6ff55); will-change: transform, height; }
.ef-goo__dot { position: absolute; left: 0; top: 0; width: 1.5rem; height: 1.5rem; margin: -0.75rem 0 0 -0.75rem; border-radius: 50%; background: var(--ef-goo-b, #e6ff55); transform: scale(0); will-change: transform; }
.ef-goo__dot--b { width: 1rem; height: 1rem; margin: -0.5rem 0 0 -0.5rem; }
.ef-goo__list { position: relative; list-style: none; margin: 0; padding: 0; }
.ef-goo__row { padding: 0.28em 7%; font-family: ui-sans-serif, system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 2rem; font-size: clamp(1.6rem, 11cqi, 3.4rem); font-weight: 300; line-height: 1.12; letter-spacing: -0.02em; color: var(--ef-goo-c, #fbf8f1); cursor: pointer; outline: none; transition: color 0.3s ease, letter-spacing 0.4s ease, transform 0.4s cubic-bezier(0.2, 0.7, 0.2, 1); }
.ef-goo__row.is-on { font-weight: 800; letter-spacing: -0.035em; color: var(--ef-goo-ink, #0c1e18); transform: translateX(0.12em); }
.ef-goo__row:focus-visible .ef-goo__t { text-decoration: underline; text-underline-offset: 0.12em; text-decoration-thickness: 2px; }
.ef-goo.is-still .ef-goo__row { transition: none; }

JavaScript

// Enrichr Library · https://enrichr.dev
// Gooey text hover: a menu of big words with a gooey blob that stretches and follows the pointer from row to row while the active word turns bold and dark.
// The blob is a pill plus two small dots inside an SVG blur and contrast filter, so shapes that come close melt together. It parks on one row when idle.
// mount(root, { items (comma separated), color (text), blob (blob colour), goo (blur strength 2-16), rest (row the blob rests on, 1-based, 0 = none), ink (text colour on the blob) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var box = root.querySelector(".ef-goo"), pill = box.querySelector(".ef-goo__pill"), layer = box.querySelector(".ef-goo__blob");
  var dotA = box.querySelector(".ef-goo__dot"), dotB = box.querySelector(".ef-goo__dot--b"), ul = box.querySelector(".ef-goo__list");
  var NS = "http://www.w3.org/2000/svg", id = "ef-goo-" + Math.random().toString(36).slice(2, 8);
  var svg = document.createElementNS(NS, "svg"), blur = document.createElementNS(NS, "feGaussianBlur");
  svg.setAttribute("aria-hidden", "true"); svg.setAttribute("width", "0"); svg.setAttribute("height", "0"); svg.style.cssText = "position:absolute;width:0;height:0;pointer-events:none";
  var f = document.createElementNS(NS, "filter"), cm = document.createElementNS(NS, "feColorMatrix");
  f.setAttribute("id", id); f.setAttribute("color-interpolation-filters", "sRGB");
  blur.setAttribute("in", "SourceGraphic"); blur.setAttribute("result", "b");
  cm.setAttribute("in", "b"); cm.setAttribute("mode", "matrix"); cm.setAttribute("values", "1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 20 -9");
  f.appendChild(blur); f.appendChild(cm); svg.appendChild(f); box.appendChild(svg);
  layer.style.filter = "url(#" + id + ")";
  box.classList.toggle("is-still", reduce);

  var opts = {}, rows = [], geo = [], hover = false, active = -1, raf = 0, last = 0, visible = true, io = null, ro = null;
  var s = { top: 0, bot: 0, a: { x: 0, y: 0 }, b: { x: 0, y: 0 }, dot: 0 };
  var tgt = { top: 0, bot: 0, show: 0 }, ptr = { x: 0, y: 0 };

  function want() {
    if (active >= 0) return active;
    var r = Math.round(Number(opts.rest)) - 1;
    return r >= 0 && r < rows.length ? r : -1;
  }
  function measure() {
    var b = box.getBoundingClientRect();
    geo = rows.map(function (r) { var q = r.getBoundingClientRect(); return { t: q.top - b.top, b: q.bottom - b.top }; });
  }
  function retarget() {
    var w = want();
    rows.forEach(function (r, i) { r.classList.toggle("is-on", i === w); });
    if (w >= 0 && geo[w]) { tgt.top = geo[w].t; tgt.bot = geo[w].b; tgt.show = 1; }
    else { tgt.top = tgt.bot = (s.top + s.bot) / 2; tgt.show = 0; }
  }
  function paint() {
    pill.style.transform = "translate3d(0," + s.top.toFixed(2) + "px,0)";
    pill.style.height = Math.max(0, s.bot - s.top).toFixed(2) + "px";
    dotA.style.transform = "translate3d(" + s.a.x.toFixed(1) + "px," + s.a.y.toFixed(1) + "px,0) scale(" + s.dot.toFixed(3) + ")";
    dotB.style.transform = "translate3d(" + s.b.x.toFixed(1) + "px," + s.b.y.toFixed(1) + "px,0) scale(" + s.dot.toFixed(3) + ")";
  }
  function kick() {
    if (reduce) { s.top = tgt.top; s.bot = tgt.bot; s.dot = 0; paint(); return; }
    if (!raf && visible) { last = performance.now(); raf = requestAnimationFrame(tick); }
  }
  function tick(t) {
    raf = 0;
    var dt = Math.min(0.05, (t - last) / 1000 || 0.016); last = t;
    var kl = 1 - Math.exp(-dt * 24), kt = 1 - Math.exp(-dt * 10);
    if (tgt.top >= s.top) { s.bot += (tgt.bot - s.bot) * kl; s.top += (tgt.top - s.top) * kt; }
    else { s.top += (tgt.top - s.top) * kl; s.bot += (tgt.bot - s.bot) * kt; }
    var k1 = 1 - Math.exp(-dt * 14), k2 = 1 - Math.exp(-dt * 8);
    s.a.x += (ptr.x - s.a.x) * k1; s.a.y += (ptr.y - s.a.y) * k1;
    s.b.x += (s.a.x - s.b.x) * k2; s.b.y += (s.a.y - s.b.y) * k2;
    s.dot += ((hover ? 1 : 0) - s.dot) * kt;
    paint();
    var moving = Math.abs(tgt.top - s.top) + Math.abs(tgt.bot - s.bot) > 0.2 || Math.abs(s.dot - (hover ? 1 : 0)) > 0.01 ||
      (hover && (Math.abs(ptr.x - s.b.x) + Math.abs(ptr.y - s.b.y) > 0.3));
    if (moving && visible) raf = requestAnimationFrame(tick);
  }
  function rowAt(y) {
    var best = 0, bd = 1e9;
    for (var i = 0; i < geo.length; i++) {
      var d = y < geo[i].t ? geo[i].t - y : y > geo[i].b ? y - geo[i].b : 0;
      if (d < bd) { bd = d; best = i; }
    }
    return best;
  }
  function onEnter(e) {
    var r = box.getBoundingClientRect();
    ptr.x = e.clientX - r.left; ptr.y = e.clientY - r.top;
    if (!hover) { s.a.x = s.b.x = ptr.x; s.a.y = s.b.y = ptr.y; }
    hover = true; measure();
  }
  function onMove(e) {
    if (!hover) onEnter(e);
    var r = box.getBoundingClientRect();
    ptr.x = e.clientX - r.left; ptr.y = e.clientY - r.top;
    var i = rowAt(ptr.y);
    if (i !== active) { active = i; retarget(); }
    kick();
  }
  function onLeave() { hover = false; active = -1; retarget(); kick(); }
  function onFocusIn(e) {
    var i = rows.indexOf(e.target.closest ? e.target.closest(".ef-goo__row") : null);
    if (i < 0) return;
    measure(); active = i;
    if (!hover) { ptr.y = (geo[i].t + geo[i].b) / 2; ptr.x = box.clientWidth * 0.3; }
    retarget(); kick();
  }
  function onFocusOut() { if (!hover) { active = -1; retarget(); kick(); } }

  function render() {
    ul.textContent = "";
    rows = opts.items.split(",").map(function (s2) { return s2.trim(); }).filter(Boolean).map(function (label) {
      var li = document.createElement("li"); li.className = "ef-goo__row"; li.tabIndex = 0;
      var sp = document.createElement("span"); sp.className = "ef-goo__t"; sp.textContent = label; li.appendChild(sp);
      ul.appendChild(li); return li;
    });
    measure();
    active = -1;
    retarget(); s.top = tgt.top; s.bot = tgt.bot; paint();
  }
  function update(next) {
    var prev = opts.items;
    opts = Object.assign({ items: "Work, Studio, Journal, Archive, Contact", color: "#fbf8f1", blob: "#e6ff55", goo: 9, rest: 1, ink: "#0c1e18" }, opts, next || {});
    box.style.setProperty("--ef-goo-c", opts.color);
    box.style.setProperty("--ef-goo-b", opts.blob);
    box.style.setProperty("--ef-goo-ink", opts.ink);
    blur.setAttribute("stdDeviation", String(Math.max(0, Number(opts.goo))));
    if (prev !== opts.items) render(); else { measure(); retarget(); kick(); }
  }
  update(o);
  box.addEventListener("pointerenter", onEnter);
  box.addEventListener("pointermove", onMove);
  box.addEventListener("pointerleave", onLeave);
  ul.addEventListener("focusin", onFocusIn);
  ul.addEventListener("focusout", onFocusOut);
  if ("ResizeObserver" in window) { ro = new ResizeObserver(function () { measure(); retarget(); s.top = tgt.top; s.bot = tgt.bot; if (!hover) paint(); }); ro.observe(box); }
  if ("IntersectionObserver" in window && !reduce) {
    io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; if (visible) kick(); else { cancelAnimationFrame(raf); raf = 0; } });
    io.observe(root);
  }
  return {
    update: update,
    destroy: function () {
      cancelAnimationFrame(raf); raf = 0;
      if (io) io.disconnect();
      if (ro) ro.disconnect();
      box.removeEventListener("pointerenter", onEnter); box.removeEventListener("pointermove", onMove); box.removeEventListener("pointerleave", onLeave);
      ul.removeEventListener("focusin", onFocusIn); ul.removeEventListener("focusout", onFocusOut);
      layer.style.filter = "";
      if (svg.parentNode) svg.parentNode.removeChild(svg);
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"items":"Work, Studio, Journal, Archive, Contact","goo":9,"rest":1,"color":"#fbf8f1","blob":"#e6ff55"});

How it works

Behind the list sits a layer holding one wide pill and two small dots, all the same colour. That layer carries an SVG filter that blurs it and then pushes the alpha channel hard with a colour matrix, so shapes that come close melt into each other and edges snap back to crisp curves. The pill's top and bottom edges are animated separately: the edge facing the direction of travel moves fast and the trailing edge lags, which stretches the pill as it jumps between rows. The two dots follow the pointer with different amounts of lag and bridge the gap. The text sits above the blob and is not filtered, so it stays sharp. The active row swaps to a heavier weight and a dark colour. When the pointer leaves, the blob returns to a resting row.

Accessibility and performance

Rows are focusable, and keyboard focus moves the blob the same way the pointer does. The text is real text in a list and is never filtered, so screen readers and zoom are unaffected. With reduced motion the blob jumps straight to the active row with no stretch, dots or transitions. Keep enough contrast between the dark active text and the blob colour if you recolour it.

A requestAnimationFrame loop runs only while something is still moving and stops once the blob settles. It also pauses when the element is off screen. Each frame writes a transform and a height on the pill and a transform on each dot. The SVG filter covers the list area only, so keep the list from growing to the full page height.

SectionNavigation and lists
TechniquesPointer interaction, Text animation
StackVanilla JS, SVG filter, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byMorphing Gooey Text Hover Effect by Manoela Ilic on Codrops. Original code, written from scratch.

Find this technique on a real site

Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.

Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns

Licence and provenance

Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.

This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.

Hover font variantsA text index whose rows flip through serif, mono, outline, wide and italic styles while hovered or focused. Distorted link hoverBig links whose letters and underline ripple like heat haze on hover, using an animated SVG turbulence displacement filter. Infinite scroll menuA vertical looping list of big words you move with the wheel, touch drag or arrow keys, with inertia and scale and fade by distance from the centre.

FAQ

Why is the text not inside the goo filter?

The filter thresholds alpha, which would make thin text blobby. The blob is drawn on its own layer under the text so only the shapes melt together.

Can the rows be links?

Yes. Put an anchor inside each row's text span. The hover and focus handling lives on the row, so the link keeps working normally.

Does the filter work in every browser?

CSS filter references to inline SVG filters work in current Chrome, Firefox and Safari. If a browser ignores the filter, the blob still shows as a plain pill with no melting.