---
name: hover-distortion
description: Use when you want "Experimental, artistic, edgy" - Distorts shapes, images, or text when hovered.
---

# Hover Distortion

> **Category:** Motion / Interaction  -  **Personality:** Experimental, artistic, edgy
>
> **Best use:** Creative portfolios, art sites
>
> **Ratings:** Professional 2/5 - Casual 3/5 - Exotic 5/5 - Visual impact 5/5 - Difficulty 5/5 - Performance cost 4/5

## What it is

A hovered (or keyboard-focused) element gets a liquid, wobbling warp — and because the distortion is applied to the *whole element*, the image, its caption text, borders and rounded corners all ripple together as one piece of glass. It's built from two chained SVG filter primitives: `feTurbulence` paints a fractal-noise field, and `feDisplacementMap` uses that field to push each pixel of the element around. The warp is animated in JavaScript so it eases in, breathes while you hover, and eases back out. It's loud, artful and a little unhinged — perfect for a creative portfolio index or an art-direction studio, and deliberately wrong for a banking dashboard.

## Dependencies / CDN

**None - vanilla JS + inline SVG.** No libraries. The demo only pulls two display fonts (optional):

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Big+Shoulders+Display:wght@500;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
```

## HTML

```html
<!-- 1) One reusable SVG filter, hidden, placed anywhere in the DOM -->
<svg width="0" height="0" aria-hidden="true" style="position:absolute">
  <filter id="hd-warp" x="-30%" y="-30%" width="160%" height="160%"
          color-interpolation-filters="sRGB">
    <feTurbulence type="fractalNoise" baseFrequency="0.012 0.014"
                  numOctaves="2" seed="4" result="noise"/>
    <feDisplacementMap id="hd-disp" in="SourceGraphic" in2="noise"
                       scale="0" xChannelSelector="R" yChannelSelector="G"/>
  </filter>
</svg>

<!-- 2) Any mixed-content link opts in with data-hd-warp; image AND text warp as one -->
<a class="hd-card" href="/work/halcyon" data-hd-warp>
  <div class="hd-thumb"><img src="/img/portrait-01.jpg" alt="Halcyon"></div>
  <h3 class="hd-name">Halcyon</h3>
  <p class="hd-meta">Editorial &middot; 2026</p>
</a>

<!-- ...works on pure-text rows too -->
<a class="hd-row" href="/work/pale-fire" data-hd-warp>
  <span class="hd-rname">Pale Fire</span><span class="hd-rcat">Beauty Film &middot; 2026</span>
</a>
```

## CSS

```css
/* The filter is OFF until JS adds .is-warp to the hovered/focused element.
   Promote ONLY the active element, and never let the box clip its own warp. */
.hd-card.is-warp,
.hd-row.is-warp{ filter:url(#hd-warp); will-change:filter; z-index:4 }

/* the warp target must not clip the displaced pixels... */
.hd-card{ position:relative; overflow:visible; transition:transform .4s, box-shadow .4s }
/* ...so push overflow:hidden down to an INNER wrapper (rounded image);
   the warp is applied after the image is composited, so corners ripple too. */
.hd-thumb{ overflow:hidden; border-radius:11px }

/* a tasteful, non-distorting hover that always works (also the reduced-motion state) */
.hd-card:hover,.hd-card:focus-visible{ transform:translateY(-5px); outline:none }
.hd-card:hover .hd-thumb img{ filter:saturate(1) brightness(1) }   /* desaturated -> alive */
```

## JavaScript

```js
/* Ramp feDisplacementMap `scale` 0 -> peak on the hovered/focused element, while
   drifting feTurbulence `baseFrequency` so the warp stays liquid. One shared filter;
   only the active element wears filter:url(#hd-warp). */
(function(){
  var disp = document.getElementById('hd-disp');
  var turb = document.querySelector('#hd-warp feTurbulence');
  var els  = [].slice.call(document.querySelectorAll('[data-hd-warp]'));

  els.forEach(function(el){ el.addEventListener('click', function(e){ e.preventDefault(); }); });

  // prefers-reduced-motion -> NO distortion at all (clean CSS hover only)
  if(!disp || !turb || (window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches)) return;

  var MAX = 26, cur = 0, target = 0, active = null, raf = null, t0 = performance.now();

  function frame(now){
    var t = now - t0;
    cur += (target - cur) * 0.12;                       // ease intensity 0..1
    var bx = (0.012 + 0.005*Math.sin(t*0.0011)).toFixed(4);
    var by = (0.014 + 0.005*Math.cos(t*0.0008)).toFixed(4);
    turb.setAttribute('baseFrequency', bx + ' ' + by);  // slow churn = liquid feel
    disp.setAttribute('scale', (cur * MAX * (1 + 0.06*Math.sin(t*0.006))).toFixed(2));
    if(cur > 0.004 || target > 0){ raf = requestAnimationFrame(frame); }
    else { cur = 0; disp.setAttribute('scale','0');     // settled -> stop & detach
           if(active){ active.classList.remove('is-warp'); active = null; } raf = null; }
  }
  function spin(){ if(!raf) raf = requestAnimationFrame(frame); }
  function enter(el){ if(active && active!==el) active.classList.remove('is-warp');
                      active = el; el.classList.add('is-warp'); target = 1; spin(); }
  function leave(el){ if(el===active) target = 0; spin(); }

  els.forEach(function(el){
    el.addEventListener('pointerenter', function(){ enter(el); });
    el.addEventListener('pointerleave', function(){ leave(el); });
    el.addEventListener('focus', function(){ enter(el); });   // keyboard parity
    el.addEventListener('blur',  function(){ leave(el); });
  });
})();
```

## How it works

- **`feTurbulence`** synthesizes a Perlin/fractal-noise image into `result="noise"`. `baseFrequency` sets the wave size (small = big slow swells; large = fine ripples), `numOctaves` adds detail, `seed` picks the pattern.
- **`feDisplacementMap`** then reads that noise and, for every pixel of `SourceGraphic` (the rendered element), shifts it horizontally by the noise's **R** channel and vertically by its **G** channel, multiplied by `scale`. That single primitive is the whole effect — and it warps *whatever the element draws*: photo, headline, meta line, rounded border, all at once.
- **At rest `scale="0"`** = identity (no warp, no cost). On hover the JS eases `scale` up to ~26 and continuously nudges `baseFrequency`, so the noise field slides under the displacement and the warp **breathes** instead of freezing into a static smear.
- **One shared filter, one active element.** JS adds `filter:url(#hd-warp)` (via `.is-warp`) only to the element under the pointer, and the `requestAnimationFrame` loop stops entirely once nothing is hovered — so you pay for at most one warped element at a time.
- **`color-interpolation-filters="sRGB"`** keeps colours true; the SVG-filter default of linearRGB visibly washes/brightens the image.

## Customization

- **Intensity:** `MAX` displacement — `8` (a gentle quiver) -> `40` (molten). The demo uses `26`.
- **Wave character:** `baseFrequency`. One value is uniform; two values (`"0.012 0.014"`) stretch x vs y independently. Lower = oily swells, higher = crackly static.
- **Detail / texture:** `numOctaves` `1` (smooth) -> `3` (crunchy, grainy).
- **Liveliness:** the `0.005` drift amplitude and the `*0.0011 / *0.0008` speeds on `baseFrequency`; raise the ease factor `0.12` for a snappier ramp-in.
- **Static variant:** delete the JS and just set `.is-warp{filter:url(#hd-warp)}` toggled by `:hover`, with a fixed `scale` baked into the filter — cheaper, but it won't breathe or honour reduced-motion automatically.

## Accessibility & performance

- **`prefers-reduced-motion: reduce` => no distortion.** The script bails before touching the filter, leaving only the calm `:hover` lift/recolour. Always check it with `matchMedia` *before* starting the loop, not just in CSS.
- **Keyboard parity:** the same warp fires on `focus`/`blur`, and the targets are real focusable `<a>` links — so keyboard users get the identical affordance, with a visible `:focus-visible` state.
- **Cost is real (4/5).** An SVG filter re-rasterizes the element every frame its values change. Mitigations used here: warp **one** element at a time, **stop** the rAF at rest, keep warped elements modestly sized, and put `will-change:filter` **only** on the active `.is-warp` element (never on all of them, or you pin GPU memory).
- **Always-on fallback:** the lift / desaturate->colour hover needs no filter, so browsers that struggle with SVG-filter-on-HTML still feel responsive.

## Gotchas

- **Don't let the box clip its own warp.** Give the filter a roomy region (`x/y:-30%`, `width/height:160%`) **and** keep `overflow:visible` on the warp target — push `overflow:hidden` down to an inner image wrapper. Leave gap/padding so neighbours and the stage edge don't crop the displaced pixels (displacement reaches roughly `scale/2` px outward).
- **Washed-out colours?** That's the linearRGB default — set `color-interpolation-filters="sRGB"` on the `<filter>`.
- **Prefer JS over SMIL.** Animating `baseFrequency` with `<animate>` works but is jankier and far harder to ramp in/out or gate behind reduced-motion than driving `scale`/`baseFrequency` from a `requestAnimationFrame` loop.
- **One shared filter = one global `scale`.** If two elements could be hovered simultaneously they'd share intensity, so track a single `active` element (a pointer is only ever over one; focus is too).
- **Don't animate `seed`** to get motion — integer seed changes *jump* the pattern. Drift `baseFrequency` instead for smooth churn.
- **Safari** renders SVG-filter-on-HTML but can flicker when a heavy CSS `transform` animates on the *same* element — keep the transform light and let the filter own its element.
