Copied!
Navigation and lists

Distorted link hover

Big links whose letters and underline ripple like heat haze on hover, using an animated SVG turbulence displacement filter.

← 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 "Distorted link hover" for my site using the Enrichr Library element at https://enrichr.dev/elements/distorted-link-hover.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/distorted-link-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, About, Contact
- strength: 24
- idle: 1
- color: #fbf8f1
- accent: #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/distorted-link-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-dis"><ul class="ef-dis__list"></ul></div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-dis { container-type: inline-size; width: 100%; min-height: 16rem; display: flex; align-items: center; justify-content: center; padding: 1rem 0; box-sizing: border-box; }
.ef-dis__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; align-items: center; gap: 0.1em; }
.ef-dis__a { position: relative; display: inline-block; padding: 0.06em 0.14em; font-family: ui-sans-serif, system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 2rem; font-size: clamp(1.7rem, 12cqi, 3.8rem); font-weight: 800; letter-spacing: -0.03em; line-height: 1.1; color: var(--ef-dis-c, #fbf8f1); text-decoration: none; outline: none; transition: color 0.35s ease; }
.ef-dis__a::after { content: ""; position: absolute; left: 0.14em; right: 0.14em; bottom: 0.03em; height: 0.085em; border-radius: 999px; background: var(--ef-dis-a, #e6ff55); transform: scaleX(0); transform-origin: left center; transition: transform 0.55s cubic-bezier(0.7, 0, 0.2, 1); }
.ef-dis__a:hover, .ef-dis__a:focus-visible, .ef-dis__a.is-on { color: var(--ef-dis-a, #e6ff55); }
.ef-dis__a:hover::after, .ef-dis__a:focus-visible::after, .ef-dis__a.is-on::after { transform: scaleX(1); }
@media (prefers-reduced-motion: reduce) { .ef-dis__a, .ef-dis__a::after { transition: none; } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Distorted link hover: big links whose letters and underline ripple like heat haze on hover or focus.
// Each link owns an SVG feTurbulence + feDisplacementMap filter that JS animates, then removes once the link is calm again.
// mount(root, { items (comma separated), strength (max displacement in px for a 64px tall link, scaled with link size, 0-60), idle (1 = a link ripples by itself every few seconds, 0 = only on hover), color, accent (hover and underline colour), frequency (noise detail, 0.005-0.08) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var ul = root.querySelector(".ef-dis__list");
  var NS = "http://www.w3.org/2000/svg", uid = Math.random().toString(36).slice(2, 8);
  var svg = document.createElementNS(NS, "svg");
  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";
  root.querySelector(".ef-dis").appendChild(svg);

  var opts = {}, links = [], raf = 0, last = 0, clock = 0, visible = true, io = null, idleTimer = 0, idleI = 0;

  function build() {
    links.forEach(function (l) { l.a.removeEventListener("pointerenter", l.on); l.a.removeEventListener("pointerleave", l.off); l.a.removeEventListener("focus", l.on); l.a.removeEventListener("blur", l.off); l.a.removeEventListener("click", l.click); });
    ul.textContent = ""; svg.textContent = ""; links = [];
    opts.items.split(",").map(function (s) { return s.trim(); }).filter(Boolean).forEach(function (label, i) {
      var li = document.createElement("li"), a = document.createElement("a");
      a.className = "ef-dis__a"; a.href = "#"; a.textContent = label; li.appendChild(a); ul.appendChild(li);
      var fid = "ef-dis-" + uid + "-" + i;
      var f = document.createElementNS(NS, "filter"), turb = document.createElementNS(NS, "feTurbulence"), disp = document.createElementNS(NS, "feDisplacementMap");
      f.setAttribute("id", fid); f.setAttribute("x", "-20%"); f.setAttribute("y", "-60%"); f.setAttribute("width", "140%"); f.setAttribute("height", "220%");
      turb.setAttribute("type", "fractalNoise"); turb.setAttribute("numOctaves", "1"); turb.setAttribute("seed", String(i * 7 + 3)); turb.setAttribute("result", "n");
      turb.setAttribute("baseFrequency", "0.012 0.04");
      disp.setAttribute("in", "SourceGraphic"); disp.setAttribute("in2", "n"); disp.setAttribute("scale", "0");
      disp.setAttribute("xChannelSelector", "R"); disp.setAttribute("yChannelSelector", "G");
      f.appendChild(turb); f.appendChild(disp); svg.appendChild(f);
      var l = { a: a, fid: fid, turb: turb, disp: disp, e: 0, hover: false, on: null, off: null, click: null, filtered: false };
      l.on = function () { l.hover = true; l.e = Math.max(l.e, 1); kick(); };
      l.off = function () { l.hover = false; kick(); };
      l.click = function (ev) { if (a.getAttribute("href") === "#") ev.preventDefault(); };
      a.addEventListener("pointerenter", l.on); a.addEventListener("pointerleave", l.off);
      a.addEventListener("focus", l.on); a.addEventListener("blur", l.off); a.addEventListener("click", l.click);
      links.push(l);
    });
  }
  function kick() {
    if (reduce || raf || !visible) return;
    last = performance.now(); raf = requestAnimationFrame(tick);
  }
  function tick(t) {
    raf = 0;
    var dt = Math.min(0.05, (t - last) / 1000 || 0.016); last = t; clock += dt;
    var str = Number(opts.strength), fr = Number(opts.frequency), busy = false;
    links.forEach(function (l) {
      var goal = l.hover ? 0.32 : 0;
      l.e += (goal - l.e) * (1 - Math.exp(-dt * (l.hover ? 2.6 : 3.4)));
      if (l.e < 0.004 && !l.hover) {
        if (l.filtered) { l.a.style.filter = ""; l.disp.setAttribute("scale", "0"); l.filtered = false; }
        return;
      }
      busy = true;
      if (!l.filtered) { l.a.style.filter = "url(#" + l.fid + ")"; l.filtered = true; }
      var w = 0.7 + 0.3 * Math.sin(clock * 5.1);
      l.turb.setAttribute("baseFrequency", (fr * 0.5 * (1 + 0.3 * Math.sin(clock * 1.7))).toFixed(4) + " " + (fr * 1.6 * (1 + 0.25 * Math.cos(clock * 1.3))).toFixed(4));
      l.disp.setAttribute("scale", (str * Math.min(1.3, Math.max(0.45, l.a.offsetHeight / 64)) * l.e * w).toFixed(2));
    });
    if (busy && visible) raf = requestAnimationFrame(tick);
  }
  function idle() {
    clearTimeout(idleTimer); idleTimer = 0;
    if (reduce || !Number(opts.idle) || !links.length || !visible) return;
    idleTimer = setTimeout(function () {
      var l = links[idleI++ % links.length];
      if (!l.hover) l.e = Math.max(l.e, 0.8);
      kick(); idle();
    }, 2600);
  }
  function update(next) {
    var prev = opts.items;
    opts = Object.assign({ items: "Work, Studio, Journal, About, Contact", strength: 24, idle: 1, color: "#fbf8f1", accent: "#e6ff55", frequency: 0.025 }, opts, next || {});
    root.querySelector(".ef-dis").style.setProperty("--ef-dis-c", opts.color);
    root.querySelector(".ef-dis").style.setProperty("--ef-dis-a", opts.accent);
    if (prev !== opts.items) build();
    kick(); idle();
  }
  update(o);
  if ("IntersectionObserver" in window && !reduce) {
    io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; if (visible) { kick(); idle(); } else { cancelAnimationFrame(raf); raf = 0; clearTimeout(idleTimer); } });
    io.observe(root);
  }
  return {
    update: update,
    destroy: function () {
      cancelAnimationFrame(raf); raf = 0; clearTimeout(idleTimer);
      if (io) io.disconnect();
      links.forEach(function (l) { l.a.style.filter = ""; l.a.removeEventListener("pointerenter", l.on); l.a.removeEventListener("pointerleave", l.off); l.a.removeEventListener("focus", l.on); l.a.removeEventListener("blur", l.off); l.a.removeEventListener("click", l.click); });
      if (svg.parentNode) svg.parentNode.removeChild(svg);
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"items":"Work, Studio, Journal, About, Contact","strength":24,"idle":1,"color":"#fbf8f1","accent":"#e6ff55"});

How it works

Each link gets its own SVG filter made of an feTurbulence noise source feeding an feDisplacementMap, which pushes the pixels of the link sideways and up and down by the noise value. JavaScript animates the noise frequency and the displacement scale every frame. On hover the effect jumps to full strength and then eases down to a gentle steady wobble; on leave it fades to zero. Once a link is calm the filter is removed from it completely, so resting text is crisp and costs nothing. The underline is a pseudo element inside the link, so it warps with the letters. With idle ripple on, one link pulses by itself every few seconds so the effect is visible without a pointer.

Accessibility and performance

Links are normal anchors, so keyboard focus triggers the same ripple as hover and the colour and underline change give a clear focus state. The text is never replaced, only displaced visually. With reduced motion there is no filter at all: hover and focus just change the colour and draw the underline. Anchors that point to # are kept from jumping the page in demos.

The animation loop runs only while at least one link is rippling and stops afterwards, and it pauses off screen. SVG filters repaint the filtered element on every change, so keep the number of links modest and the displacement strength moderate on low powered devices.

SectionNavigation and lists
TechniquesPointer interaction, Text animation
StackVanilla JS, SVG filter, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byIdeas for Distorted Link Effects on Menus 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. Gooey text hoverA menu of big words with a gooey blob that stretches and follows the pointer from row to row while the active word turns bold. 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 does the distortion look different at different sizes?

The displacement is measured in pixels. The element scales the strength with the height of each link so a small link is not torn apart, and you can lower or raise the strength control to taste.

Can I use it on images or buttons?

Yes. Apply the same filter reference to any element. It works on text, images and boxes alike, though large images cost more to repaint.

Why remove the filter when the link is calm?

A filtered element is rasterised and can look slightly soft. Removing the filter at rest keeps the text crisp and stops the browser doing extra work.