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.
| Section | Navigation and lists |
|---|---|
| Techniques | Pointer interaction, Text animation |
| Stack | Vanilla JS, SVG filter, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | Ideas 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.
More elements
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.