---
name: text-distortion-effect
description: Use when you want "Experimental, edgy, artistic" - Warps or bends text through displacement or shader effects.
---

# Text Distortion Effect

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

## What it is

Text Distortion warps live, real text through an inline SVG filter: `feTurbulence` generates an invisible noise field and `feDisplacementMap` pushes each pixel of the rendered letters sideways and down by sampling that noise, so the type ripples and bends without ever becoming a flat image. Because it stays actual DOM text it remains selectable, editable and (largely) accessible while looking like melting or "breathing" letterforms. Driving the filter's displacement `scale` from the pointer makes a headline react as the cursor approaches, so it "refuses to hold still." It suits experimental, edgy work — creative portfolios, type-foundry sites, and event/poster pages.

## Dependencies / CDN

None - pure CSS + inline SVG + a few lines of vanilla JS (no library, no build step). The demo only loads two Google Fonts, which are 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=Anton&family=Space+Mono:ital,wght@0,400;0,700;1,400&display=swap" rel="stylesheet">
```

## HTML

The warpable text plus the SVG filter that does the work. The `<svg>` defs block lives once in the document and is referenced by id from CSS:

```html
<h2 class="td-display">
  <span class="td-warp" data-warp>
    <span class="td-word td-echo" aria-hidden="true">DISTORT</span>
    <span class="td-word td-fore">DISTORT</span>
  </span>
</h2>

<!-- Filter definition: takes no layout space, referenced via filter:url(#id) -->
<svg class="td-defs" aria-hidden="true" focusable="false" width="0" height="0">
  <filter id="td-warp-filter" x="-25%" y="-25%" width="150%" height="150%" color-interpolation-filters="sRGB">
    <feTurbulence id="td-turb" type="fractalNoise" baseFrequency="0.02 0.034" numOctaves="2" seed="11" result="td-noise"/>
    <feDisplacementMap id="td-disp" in="SourceGraphic" in2="td-noise" scale="3" xChannelSelector="R" yChannelSelector="G"/>
  </filter>
</svg>
```

## CSS

```css
/* Apply the SVG filter to the live text. will-change hints the GPU. */
.td-warp{position:relative;display:inline-block;will-change:filter;filter:url(#td-warp-filter)}
.td-word{display:block;font-family:'Anton',sans-serif;text-transform:uppercase;
  font-size:clamp(70px,17.5vw,208px);line-height:.82;letter-spacing:-.012em}

/* Foreground letters */
.td-fore{position:relative;z-index:2;color:#f3f1ea}
/* Outlined "echo" stacked behind and offset, for a printed-misregister look */
.td-echo{position:absolute;left:0;top:0;z-index:1;color:transparent;
  -webkit-text-stroke:1.6px #d8ff45;transform:translate(9px,10px);opacity:.5}

/* The SVG that holds the filter takes no layout space */
.td-defs{position:absolute;width:0;height:0;overflow:hidden}
```

## JavaScript

```js
/* Drive the feDisplacementMap "scale" from pointer proximity to the headline. */
(function(){
  var stage=document.querySelector('.td-stage');     // the area that captures the pointer
  var word=document.querySelector('[data-warp]');     // the warped text box
  var disp=document.getElementById('td-disp');         // <feDisplacementMap>
  var turb=document.getElementById('td-turb');         // <feTurbulence>
  if(!stage||!word||!disp)return;
  /* Reduced motion: a tiny static warp keeps the identity, but nothing animates. */
  if(window.matchMedia&&matchMedia('(prefers-reduced-motion: reduce)').matches){
    disp.setAttribute('scale','5');return;
  }
  var IDLE=3, MAX=46, REACH=380;          /* px of "magnetism" around the word box */
  var cur=IDLE, target=IDLE, running=false, t=0;
  function aim(x,y){
    var r=word.getBoundingClientRect();
    var dx=Math.max(r.left-x,0,x-r.right);
    var dy=Math.max(r.top-y,0,y-r.bottom);
    var k=Math.max(0,1-Math.hypot(dx,dy)/REACH);   /* 1 = on the word, 0 = far away */
    target=IDLE+k*k*(MAX-IDLE);
    if(!running){running=true;requestAnimationFrame(loop);}
  }
  function loop(){
    cur+=(target-cur)*0.14;                /* ease toward target */
    t+=0.02;
    disp.setAttribute('scale',cur.toFixed(2));
    if(cur>IDLE+0.4){                       /* living drift in the noise while warped */
      turb.setAttribute('baseFrequency',
        (0.02+Math.sin(t)*0.004).toFixed(4)+' '+(0.034+Math.cos(t*0.8)*0.005).toFixed(4));
    }
    if(target<=IDLE+0.01&&Math.abs(cur-target)<0.08){   /* settled: stop the loop */
      cur=target;disp.setAttribute('scale',cur.toFixed(2));running=false;return;
    }
    requestAnimationFrame(loop);
  }
  stage.addEventListener('pointermove',function(e){aim(e.clientX,e.clientY);},{passive:true});
  stage.addEventListener('pointerleave',function(){target=IDLE;});
})();
```

## How it works

- **`feTurbulence`** synthesises a fractal-noise image (never drawn to screen itself). `baseFrequency` sets the noise scale — small values give large, smooth swells; large values give fine grain. `numOctaves` layers extra detail; `seed` picks which noise pattern you get.
- **`feDisplacementMap`** takes the rendered text (`in="SourceGraphic"`) and, for every pixel, reads the noise pixel's **Red** channel to decide horizontal shift (`xChannelSelector="R"`) and the **Green** channel for vertical shift (`yChannelSelector="G"`). The `scale` attribute is the maximum shift in pixels: `scale="0"` = no distortion, higher = more warp.
- **CSS** wires the filter to live text with `filter:url(#td-warp-filter)`, so the letters remain real, selectable DOM text rather than a rasterised picture.
- **JS** animates a single number — the displacement `scale`. It measures the pointer's distance to the word's bounding box, maps closeness to a target scale with a quadratic falloff (`k*k`), and `requestAnimationFrame`-lerps `cur` toward that target (`cur += (target-cur)*0.14`). While warped it also drifts `baseFrequency` slightly so the noise field "breathes," then halts the loop once everything settles back to idle to save CPU.

## Customization

- **Warp strength:** raise/lower `MAX` (peak `scale`) and `IDLE` (resting warp). Set `IDLE` to `0` for crisp text at rest that only melts under the cursor.
- **Reach:** `REACH` is the pointer-magnetism radius in pixels — larger = the headline reacts from further away.
- **Texture:** `baseFrequency` — lower (e.g. `0.008`) = long, liquid waves; higher (e.g. `0.08`) = jittery, shredded edges. Two space-separated values give different X and Y frequencies.
- **Detail / pattern:** bump `numOctaves` for rougher noise; change `seed` for an entirely different distortion shape.
- **Static use:** delete the JS and set a fixed `scale` on `<feDisplacementMap>` for a permanently-melted logo or wordmark.
- **Echo layer:** the offset outlined `.td-echo` duplicate is pure styling — recolour its `-webkit-text-stroke`, change the `translate()` offset, or drop it entirely.

## Accessibility & performance

- SVG filters are GPU/CPU-heavy. Animating `scale` each frame on very large text can drop frames on low-end devices — keep `will-change:filter`, warp ONE headline (never body copy), and stop the rAF loop when idle (the demo returns out of `loop()` once settled).
- The text stays real, selectable DOM text and is announced normally by screen readers; the visual warp does not change the accessibility tree. The decorative `.td-echo` duplicate is marked `aria-hidden="true"` so it is not read twice.
- Honour `prefers-reduced-motion`: the demo skips the animation loop entirely and applies a small fixed `scale` so the identity survives without motion.
- Heavy warp hurts legibility — keep `scale` modest on anything users must actually read.

## Gotchas

- **`color-interpolation-filters="sRGB"` matters.** The SVG default is linearRGB, which shifts colours and edge weight; without the sRGB override the warp can look muted or grimy.
- **The filter region clips.** Its default region is `-10% / -10% / 120% / 120%` and anything the displacement pushes beyond it gets cut off — the demo widens it to `x="-25%" y="-25%" width="150%" height="150%"` so large warps aren't sliced at the edges.
- **`scale` is an SVG attribute, not CSS.** Drive it with `disp.setAttribute('scale', ...)`; there is no CSS property for `feDisplacementMap` scale.
- **Keep the filter `<svg>` in the DOM.** Here it is sized `width="0" height="0"` and pushed off-layout, but it must not be `display:none`'d away or the `url(#...)` reference breaks.
- **Filter ids are global.** If you place several distortion blocks on one page, give each its own filter id (and matching `filter:url(#...)`) or they'll fight over the same noise.
- **Safari** has historically been shaky with animated SVG displacement on text — test it there. A large `numOctaves` combined with huge text is the main performance risk.
