Copied!
Hero

Gooey word switcher

A word that melts into the next through an SVG goo filter, rotating on a timer with Next and Pause buttons.

← Library

Element · Vanilla JS + CSSUpdated November 6, 2026

HeroElementVanilla JS + 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 word switcher" for my site using the Enrichr Library element at https://enrichr.dev/elements/gooey-word-switcher.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/gooey-word-switcher.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:
- words: Create, Explore, Remix, Repeat
- interval: 2.4
- goo: 3
- blur: 18
- duration: 900
- background: #e6ff55
- ink: #0c1e18

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-word-switcher.json.

Code

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

HTML

<div class="ef-gws">
  <div class="ef-gws__stage">
    <div class="ef-gws__goo" aria-hidden="true"><span class="ef-gws__w"></span><span class="ef-gws__w"></span></div>
    <p class="ef-gws__sr ef-gws__now"></p>
    <p class="ef-gws__sr ef-gws__live" role="status"></p>
    <svg class="ef-gws__defs" width="0" height="0" aria-hidden="true" focusable="false"><defs><filter><feGaussianBlur in="SourceGraphic" stdDeviation="3" result="b"/><feColorMatrix in="b" mode="matrix" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 22 -9"/></filter></defs></svg>
  </div>
  <div class="ef-gws__bar">
    <button type="button" class="ef-gws__btn ef-gws__next">Next word</button>
    <button type="button" class="ef-gws__btn ef-gws__play" aria-pressed="false">Pause</button>
  </div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-gws { --ef-gws-bg: #e6ff55; --ef-gws-ink: #0c1e18; width: 100%; font-family: "Helvetica Neue", Arial, "Segoe UI", system-ui, sans-serif; }
.ef-gws__stage { position: relative; height: 15rem; border-radius: 1.2rem; overflow: hidden; background: var(--ef-gws-bg); color: var(--ef-gws-ink); container-type: size; }
.ef-gws__goo { position: absolute; inset: 0; display: grid; place-items: center; }
.ef-gws__w { grid-area: 1 / 1; font-size: clamp(2.4rem, min(17cqi, 52cqh), 10rem); font-weight: 900; line-height: 1; letter-spacing: -0.06em; text-transform: uppercase; white-space: nowrap; will-change: transform, filter, opacity; }
.ef-gws__w + .ef-gws__w { opacity: 0; }
.ef-gws__sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
.ef-gws__defs { position: absolute; }
.ef-gws__bar { display: flex; flex-wrap: wrap; gap: 0.6rem; margin-top: 0.9rem; }
.ef-gws__btn { padding: 0.65rem 1.15rem; border: 1px solid currentColor; border-radius: 999px; background: transparent; color: inherit; font: inherit; font-size: 0.9rem; font-weight: 600; cursor: pointer; }
.ef-gws__btn:hover { background: var(--ef-gws-bg); color: var(--ef-gws-ink); border-color: var(--ef-gws-bg); }
.ef-gws__btn:focus-visible { outline: 3px solid var(--ef-gws-bg); outline-offset: 3px; }
.ef-gws__btn[hidden] { display: none; }

JavaScript

// Enrichr Library · https://enrichr.dev
// Gooey word switcher: one word melts into the next. The old word blurs, swells and fades while the new one sharpens in,
// and an SVG filter on their shared layer (a blur followed by a steep alpha threshold) fuses the overlapping soft shapes
// into liquid blobs instead of a ghostly crossfade. Words rotate on a timer that pauses on hover, focus or the Pause button,
// and the Next button switches by hand. Screen readers hear the word only when the user switches it, not on every tick.
// mount(root, { words (comma separated), interval (seconds, 0 = manual only), goo (filter blur 1-8), blur (word blur in px),
//               duration (ms), background, ink })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var box = root.querySelector(".ef-gws");
  var goo = root.querySelector(".ef-gws__goo");
  var w = root.querySelectorAll(".ef-gws__w");
  var sr = root.querySelector(".ef-gws__now");
  var live = root.querySelector(".ef-gws__live");
  var liveTimer = 0;
  var filter = root.querySelector(".ef-gws__defs filter");
  var blurNode = root.querySelector(".ef-gws__defs feGaussianBlur");
  var next = root.querySelector(".ef-gws__next");
  var playBtn = root.querySelector(".ef-gws__play");
  var uid = "ef-gws-" + Math.random().toString(36).slice(2, 8);
  var opts = {}, words = [], index = 0, cur = 0, busy = false, anims = [], timer = 0, paused = false, hovering = false;

  filter.id = uid;
  goo.style.filter = "url(#" + uid + ")";

  function auto() { return !reduce && Number(opts.interval) > 0; }
  function schedule() {
    clearTimeout(timer);
    if (auto() && !paused && !hovering) timer = setTimeout(function () { go(false); }, Number(opts.interval) * 1000);
  }
  // The visible words are aria-hidden. ".ef-gws__now" always holds the current word for reading; the status region only
  // speaks when the user pressed Next, and is emptied again so it is not read twice when browsing.
  function announce(text, byUser) {
    sr.textContent = text;
    if (!byUser) return;
    clearTimeout(liveTimer);
    live.textContent = text;
    liveTimer = setTimeout(function () { live.textContent = ""; }, 1500);
  }
  function go(byUser) {
    if (busy || words.length < 2) return;
    index = (index + 1) % words.length;
    var a = w[cur], b = w[1 - cur], word = words[index];
    announce(word, byUser);
    if (reduce || !a.animate) {
      a.textContent = word; schedule(); return;
    }
    busy = true;
    b.textContent = word;
    var d = Math.max(200, Number(opts.duration) || 900), bl = Number(opts.blur) || 18;
    anims = [
      a.animate([{ opacity: 1, filter: "blur(0px)", transform: "scale(1)" }, { opacity: 0, filter: "blur(" + bl + "px)", transform: "scale(1.18)" }], { duration: d * 0.6, easing: "cubic-bezier(0.55, 0, 1, 0.45)", fill: "forwards" }),
      b.animate([{ opacity: 0, filter: "blur(" + bl + "px)", transform: "scale(0.82)" }, { opacity: 1, filter: "blur(0px)", transform: "scale(1)" }], { duration: d * 0.8, delay: d * 0.2, easing: "cubic-bezier(0.22, 1, 0.36, 1)", fill: "both" })
    ];
    anims[1].onfinish = function () {
      // Swap roles: the new word becomes the resting one, the old layer is cleared for next time.
      a.textContent = "";
      anims.forEach(function (x) { try { x.cancel(); } catch (e) {} });
      anims = [];
      a.style.opacity = "0"; b.style.opacity = "1";
      cur = 1 - cur;
      busy = false;
      schedule();
    };
  }
  function setPaused(p) {
    paused = p;
    playBtn.setAttribute("aria-pressed", p ? "true" : "false");
    playBtn.textContent = p ? "Play" : "Pause";
    schedule();
  }
  function onNext() { go(true); }
  function onPlay() { setPaused(!paused); }
  function onIn() { hovering = true; clearTimeout(timer); }
  function onOut(e) { if (e && e.type === "focusout" && box.contains(e.relatedTarget)) return; hovering = false; schedule(); }
  next.addEventListener("click", onNext);
  playBtn.addEventListener("click", onPlay);
  box.addEventListener("pointerenter", onIn);
  box.addEventListener("pointerleave", onOut);
  box.addEventListener("focusin", onIn);
  box.addEventListener("focusout", onOut);

  function update(nextOpts) {
    opts = Object.assign({ words: "Create, Explore, Remix, Repeat", interval: 2.4, goo: 3, blur: 18, duration: 900, background: "#e6ff55", ink: "#0c1e18" }, opts, nextOpts || {});
    words = String(opts.words).split(",").map(function (s) { return s.trim(); }).filter(Boolean).slice(0, 12);
    if (!words.length) words = ["Word"];
    anims.forEach(function (x) { try { x.cancel(); } catch (e) {} });
    anims = []; busy = false;
    index = 0; cur = 0;
    w[0].textContent = words[0]; w[1].textContent = "";
    w[0].style.opacity = "1"; w[1].style.opacity = "0";
    announce(words[0], false);
    blurNode.setAttribute("stdDeviation", String(Math.min(8, Math.max(1, Number(opts.goo) || 3))));
    box.style.setProperty("--ef-gws-bg", opts.background);
    box.style.setProperty("--ef-gws-ink", opts.ink);
    playBtn.hidden = !auto();
    schedule();
  }
  update(o);
  return {
    update: update,
    destroy: function () {
      clearTimeout(timer); clearTimeout(liveTimer);
      anims.forEach(function (x) { try { x.cancel(); } catch (e) {} });
      next.removeEventListener("click", onNext); playBtn.removeEventListener("click", onPlay);
      box.removeEventListener("pointerenter", onIn); box.removeEventListener("pointerleave", onOut);
      box.removeEventListener("focusin", onIn); box.removeEventListener("focusout", onOut);
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"words":"Create, Explore, Remix, Repeat","interval":2.4,"goo":3,"blur":18,"duration":900,"background":"#e6ff55","ink":"#0c1e18"});

How it works

Two word layers share a parent with an SVG filter: a Gaussian blur followed by a colour matrix that multiplies alpha by 22 and subtracts 9, which turns any soft edge into a hard one. To switch, the Web Animations API blurs, swells and fades the old word while the new one starts blurred and small and sharpens into place. Midway the two soft shapes overlap and the threshold fuses them into one liquid form instead of a ghostly crossfade. The layers swap roles when it finishes, and each instance gets its own filter id so several can share a page.

Accessibility and performance

The animated words are hidden from assistive technology. A visually hidden copy always holds the current word, and a status region speaks it only when the user presses Next, so the timer never chatters. Rotation pauses on hover and keyboard focus, and the Pause button stops it entirely, which meets WCAG 2.2.2. With prefers-reduced-motion the timer is off and Next swaps the word instantly.

Blur filters cost more than transforms, but only one short animation runs every few seconds on a small box. The timer stops while paused and on destroy.

SectionHero
TechniquesText animation
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byGSAP Gooey Word Switcher by Enrichr on CodePen. 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.

Marquee tickerEndless horizontal text that pauses on hover, with speed, gap and direction controls. Split text revealA headline whose words rise out of a mask one after another when it scrolls into view. Letter pop introEach letter overshoots its size and settles, staggered left to right, for a bold page intro.

FAQ

Why are the letters slightly rounded?

The goo filter blurs and re-sharpens the letters, which softens corners. Lower Gooeyness for crisper resting text, or raise it for a more liquid look.

Can I use it inside a sentence?

Yes. Put the stage after your fixed words and size it to the longest word; the word layers are centred in the stage.

Does it work on dark backgrounds?

Yes. The filter only changes alpha, so any text colour works on any background colour.