Copied!
Text

Kinetic type exits

Four ways for type to leave: letters that shatter, a word that dissolves like smoke, a line that folds flat, and a terminal that types and deletes commands.

← Library

Element · Vanilla JS + CSSUpdated November 6, 2026

TextElementVanilla 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 "Kinetic type exits" for my site using the Enrichr Library element at https://enrichr.dev/elements/kinetic-type-exits.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/kinetic-type-exits.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:
- effect: shatter
- text: BREAK ON CONTACT
- words: npm run build, git commit -m "ship it", deploy --prod
- speed: 1
- size: 42
- color: #fbf8f1
- accent: #e6ff55
- background: #0c1e18
- label: 1

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/kinetic-type-exits.json.

Code

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

HTML

<div class="ef-kte" data-fx="shatter" data-label="1" tabindex="0" role="group" aria-label="Exit type study. Hover it, or focus it with the keyboard, to play the exit.">
  <div class="ef-kte__head">
    <span class="ef-kte__num" aria-hidden="true">21</span>
    <span class="ef-kte__meta"><span class="ef-kte__name">Shatter</span><span class="ef-kte__desc">Letters fly apart along their own trajectory.</span></span>
    <span class="ef-kte__tag" hidden>continuous</span>
  </div>
  <div class="ef-kte__stage"><span class="ef-kte__demo">BREAK ON CONTACT</span></div>
  <span class="ef-kte__hint" aria-hidden="true">hover</span>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-kte {
  --ef-kte-k: 1; --ef-kte-size: 42;
  --ef-kte-fg: #fbf8f1; --ef-kte-fg-rgb: 251, 248, 241; --ef-kte-muted: #9ca39b;
  --ef-kte-bg: #0c1e18; --ef-kte-bg-rgb: 12, 30, 24; --ef-kte-top: #17281f;
  --ef-kte-acc: #e6ff55; --ef-kte-acc-rgb: 230, 255, 85;
  --ef-kte-fs: min(calc(var(--ef-kte-size) * 1px), max(1.5rem, 9cqw));
  position: relative; box-sizing: border-box; width: 100%; min-height: 24rem; overflow: hidden; border-radius: 1rem;
  display: flex; flex-direction: column; container-type: inline-size; touch-action: pan-y;
  color: var(--ef-kte-fg); font-family: 'Instrument Sans', system-ui, sans-serif;
  background: radial-gradient(ellipse at 50% 120%, rgba(var(--ef-kte-acc-rgb), .1), transparent 60%), linear-gradient(180deg, var(--ef-kte-top), var(--ef-kte-bg));
  border: 1px solid rgba(var(--ef-kte-acc-rgb), .2);
  box-shadow: inset 0 1px 0 rgba(var(--ef-kte-fg-rgb), .05);
}
.ef-kte *, .ef-kte *::before, .ef-kte *::after { box-sizing: border-box; }
.ef-kte:focus { outline: none; }
.ef-kte:focus-visible { outline: 2px solid var(--ef-kte-acc); outline-offset: -4px; }

/* specimen label */
.ef-kte .ef-kte__head { position: relative; z-index: 2; display: flex; align-items: center; gap: 1rem; padding: 1.1rem 1.25rem 0; }
.ef-kte[data-label="0"] .ef-kte__head, .ef-kte[data-label="0"] .ef-kte__hint { display: none; }
.ef-kte .ef-kte__num {
  flex-shrink: 0; width: 2.2rem; height: 2.2rem; border-radius: 50%; display: flex; align-items: center; justify-content: center;
  font: 600 .78rem/1 'Fira Code', ui-monospace, monospace; color: var(--ef-kte-bg); background: var(--ef-kte-acc);
  box-shadow: 0 0 0 1px rgba(var(--ef-kte-acc-rgb), .4), 0 4px 14px -4px rgba(var(--ef-kte-acc-rgb), .55);
}
.ef-kte .ef-kte__meta { display: block; min-width: 0; text-align: left; }
.ef-kte .ef-kte__name { display: block; margin: 0 0 .15rem; font: 600 1.1rem/1.2 'Parkinsans', 'Space Grotesk', sans-serif; letter-spacing: .01em; color: var(--ef-kte-fg); opacity: 1; }
.ef-kte .ef-kte__desc { display: block; margin: 0; font-size: .84rem; line-height: 1.45; color: var(--ef-kte-muted); opacity: 1; }
.ef-kte.is-narrow .ef-kte__desc { display: none; }
.ef-kte .ef-kte__hint {
  position: absolute; z-index: 2; bottom: .9rem; right: .9rem; padding: .3rem .6rem; border-radius: 2px;
  font: .68rem/1.2 'Fira Code', ui-monospace, monospace; letter-spacing: .06em; text-transform: uppercase; color: var(--ef-kte-fg);
  background: rgba(var(--ef-kte-bg-rgb), .65); border: 1px solid rgba(var(--ef-kte-acc-rgb), .3);
  -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px); pointer-events: none;
}

/* stage and type */
.ef-kte .ef-kte__stage { position: relative; z-index: 1; flex: 1; display: flex; align-items: center; justify-content: center; text-align: center; padding: 2rem 1.5rem 3rem; min-height: 12rem; }
.ef-kte .ef-kte__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-kte .ef-kte__demo {
  display: inline-block; margin: 0; white-space: pre-wrap; color: var(--ef-kte-fg); opacity: 1;
  font-family: 'Parkinsans', 'Space Grotesk', sans-serif; font-weight: 500; font-size: var(--ef-kte-fs); letter-spacing: .01em; line-height: 1.2;
}
.ef-kte .ef-kte__w { display: inline-block; white-space: nowrap; }
.ef-kte .ef-kte__ch { display: inline-block; will-change: transform, opacity, filter; }
.ef-kte .ef-kte__tag {
  margin-left: auto; align-self: center; white-space: nowrap; padding: .3rem .6rem; border-radius: 20px;
  font: .62rem/1.2 'Fira Code', ui-monospace, monospace; letter-spacing: .08em; text-transform: uppercase;
  color: rgba(var(--ef-kte-acc-rgb), .85); border: 1px solid rgba(var(--ef-kte-acc-rgb), .35); background: rgba(var(--ef-kte-acc-rgb), .06);
}
.ef-kte .ef-kte__tag[hidden], .ef-kte .ef-kte__hint[hidden], .ef-kte.is-narrow .ef-kte__tag { display: none; }
.ef-kte .ef-kte__demo--small { font-family: 'Fira Code', ui-monospace, monospace; font-size: calc(var(--ef-kte-fs) * .615); }

/* 21 shatter: each letter has its own random offset and turn */
.ef-kte[data-fx="shatter"] .ef-kte__ch { transition: transform calc(.5s * var(--ef-kte-k)) ease, opacity calc(.5s * var(--ef-kte-k)) ease; }
.ef-kte[data-fx="shatter"].is-hot .ef-kte__ch { transform: translate(var(--tx, 0), var(--ty, 0)) rotate(var(--tr, 0deg)); opacity: 0; }

/* 22 dissolve */
.ef-kte[data-fx="dissolve"] .ef-kte__demo { transition: filter calc(.7s * var(--ef-kte-k)) ease, opacity calc(.7s * var(--ef-kte-k)) ease, transform calc(.7s * var(--ef-kte-k)) ease; }
.ef-kte[data-fx="dissolve"].is-hot .ef-kte__demo { filter: blur(10px); opacity: 0; transform: scale(1.15); }

/* 23 fold collapse (the opacity drop is instant, as in the original) */
.ef-kte[data-fx="fold"] .ef-kte__stage { perspective: 500px; }
.ef-kte[data-fx="fold"] .ef-kte__demo { transition: transform calc(.55s * var(--ef-kte-k)) cubic-bezier(.6, 0, .3, 1); transform-origin: 50% 50%; }
.ef-kte[data-fx="fold"].is-hot .ef-kte__demo { transform: rotateX(70deg) scaleY(.15); opacity: .2; }

/* 24 terminal cycle */
.ef-kte .ef-kte__term { border-right: 2px solid var(--ef-kte-acc); padding-right: 4px; white-space: pre; }

/* reduced motion: the word only dims on hover or focus, nothing moves */
.ef-kte.is-reduced *, .ef-kte.is-reduced *::before, .ef-kte.is-reduced *::after { transition: none !important; animation: none !important; }
.ef-kte.is-reduced.is-hot .ef-kte__ch { transform: none !important; opacity: 1 !important; }
.ef-kte.is-reduced.is-hot .ef-kte__demo { transform: none !important; filter: none !important; opacity: .35 !important; }
@media (prefers-reduced-motion: reduce) {
  .ef-kte *, .ef-kte *::before, .ef-kte *::after { transition: none !important; animation: none !important; }
}

JavaScript

// Enrichr Library · https://enrichr.dev
// Kinetic type exits: the four exit studies from a kinetic type specimen book, one at a time (pick it with effect).
// Shatter: on hover every letter flies to its own random offset (up to 120 px sideways, 90 px up or down) and turns up to
// 90 degrees while fading out, over 0.5 s; leaving brings them back the same way. Dissolve: the word blurs to 10 px, grows
// to 115% and fades over 0.7 s. Fold collapse: the line tips back 70 degrees and flattens to 15% height over 0.55 s on
// cubic-bezier(.6,0,.3,1), dimming at once. Terminal cycle: commands type at 60 ms a letter behind an accent cursor, hold
// for 1.1 s, delete at 35 ms a letter, pause 0.3 s and move to the next.
// Keyboard: focus plays the hover exits (Enter or Space too) and Escape or leaving brings the word back. Speed scales every
// duration. Reduced motion: nothing moves; hover or focus only dims the word, and the terminal shows its first command.
// The terminal's timers pause off screen and in hidden tabs.
// mount(root, { effect (shatter | dissolve | fold | terminal), text, words (terminal commands, comma separated),
//               speed (1 = original timing), size (px), color, accent, background, label (1 shows the label and hint) })
function mount(root, o) {
  var reduce = !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
  var box = root.querySelector(".ef-kte");
  var numEl = box.querySelector(".ef-kte__num"), nameEl = box.querySelector(".ef-kte__name");
  var descEl = box.querySelector(".ef-kte__desc"), tagEl = box.querySelector(".ef-kte__tag"), hintEl = box.querySelector(".ef-kte__hint");
  var stage = box.querySelector(".ef-kte__stage");
  var FX = {
    shatter: ["21", "Shatter", "Letters fly apart along their own trajectory.", "hover"],
    dissolve: ["22", "Dissolve", "The word lifts, blurs, and disperses like smoke.", "hover"],
    fold: ["23", "Fold collapse", "The line folds flat toward its own centre.", "hover"],
    terminal: ["24", "Terminal cycle", "Typed, held, deleted, and typed again.", ""]
  };
  var opts = {}, fx = "", text = "", demo = null, visible = true, io = null, ro = null, destroyed = false;
  var timers = [], clock = 0, last = 0, raf = 0;

  function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }
  function rgb(h, d) { var m = /^#?([0-9a-f]{6})$/i.exec(String(h || "")); var v = parseInt(m ? m[1] : d, 16); return [v >> 16, (v >> 8) & 255, v & 255]; }
  function mix(a, b, t) { return "rgb(" + [0, 1, 2].map(function (i) { return Math.round(a[i] + (b[i] - a[i]) * t); }).join(",") + ")"; }
  function rand(a, b) { return Math.random() * (b - a) + a; }
  function make(tag, cls, txt) { var e = document.createElement(tag); if (cls) e.className = cls; if (txt != null) e.textContent = txt; return e; }

  // one clock for the terminal: it only advances on screen in a visible tab, scaled by speed
  function tick(now) {
    raf = 0;
    var dt = Math.min(50, Math.max(0, now - last)); last = now;
    if (visible && !document.hidden) {
      clock += dt * opts.speed;
      var list = timers.slice();
      for (var i = 0; i < list.length; i++) {
        var t = list[i], n = 0;
        while (!t.dead && t.at <= clock && n++ < 4) { if (t.every) t.at += t.every; else t.dead = true; t.fn(t); }
      }
      timers = timers.filter(function (t) { return !t.dead; });
    }
    if (timers.length && !destroyed && visible) raf = requestAnimationFrame(tick);
  }
  function kick() { if (!raf && !destroyed && timers.length) { last = performance.now(); raf = requestAnimationFrame(tick); } }
  function every(ms, fn) { var t = { at: clock + ms, every: ms, fn: fn }; timers.push(t); kick(); return t; }
  function after(ms, fn) { var t = { at: clock + ms, every: 0, fn: fn }; timers.push(t); kick(); return t; }
  function stopTimers() { timers.forEach(function (t) { t.dead = true; }); timers = []; }

  // the terminal loop, step for step as the original: type, hold 1.1 s, delete, wait 0.3 s, next command
  function terminal(words) {
    var wi = 0;
    function typeWord(word, cb) {
      var i = 0;
      every(60, function (t) {
        demo.textContent = word.slice(0, i + 1);
        i++;
        if (i > word.length) { t.dead = true; cb(); }
      });
    }
    function deleteWord(cb) {
      var txt = demo.textContent;
      every(35, function (t) {
        txt = txt.slice(0, -1);
        demo.textContent = txt;
        if (txt.length === 0) { t.dead = true; cb(); }
      });
    }
    function loop() {
      typeWord(words[wi], function () {
        after(1100, function () {
          deleteWord(function () { wi = (wi + 1) % words.length; after(300, loop); });
        });
      });
    }
    loop();
  }

  function build() {
    stopTimers();
    fx = FX[opts.effect] ? opts.effect : "shatter";
    var f = FX[fx];
    box.setAttribute("data-fx", fx);
    box.classList.remove("is-hot");
    numEl.textContent = f[0]; nameEl.textContent = f[1]; descEl.textContent = f[2];
    hintEl.textContent = f[3]; hintEl.hidden = !f[3]; tagEl.hidden = fx !== "terminal";
    text = String(opts.text == null ? "" : opts.text).replace(/\s+/g, " ").trim() || "BREAK ON CONTACT";
    var words = String(opts.words || "").split(",").map(function (w) { return w.trim(); }).filter(Boolean);
    if (!words.length) words = ["npm run build", 'git commit -m "ship it"', "deploy --prod"];
    stage.textContent = "";
    stage.appendChild(make("span", "ef-kte__sr", fx === "terminal" ? words.join(", then ") : text));
    demo = make("span", "ef-kte__demo"); demo.setAttribute("aria-hidden", "true");
    if (fx === "shatter") {
      var word = null;
      Array.from(text).forEach(function (ch) {
        if (/\s/.test(ch)) { word = null; demo.appendChild(document.createTextNode(" ")); return; }
        if (!word) { word = make("span", "ef-kte__w"); demo.appendChild(word); }
        var s = make("span", "ef-kte__ch", ch);
        s.style.setProperty("--tx", rand(-120, 120).toFixed(0) + "px");
        s.style.setProperty("--ty", rand(-90, 90).toFixed(0) + "px");
        s.style.setProperty("--tr", rand(-90, 90).toFixed(0) + "deg");
        word.appendChild(s);
      });
    } else if (fx === "terminal") {
      demo.className += " ef-kte__demo--small ef-kte__term";
      if (reduce) demo.textContent = words[0];
    } else demo.textContent = text;
    stage.appendChild(demo);
    if (fx === "terminal" && !reduce) terminal(words);
  }

  function hot(on) { if (fx !== "terminal") box.classList.toggle("is-hot", on); }
  function onEnter() { hot(true); }
  function onLeave() { if (document.activeElement !== box || !box.matches(":focus-visible")) hot(false); }
  function onFocus() { if (box.matches(":focus-visible")) hot(true); }
  function onBlur() { hot(false); }
  function onKey(e) {
    if (e.key === "Escape") hot(false);
    else if (e.key === "Enter" || e.key === " ") { e.preventDefault(); hot(!box.classList.contains("is-hot")); }
  }

  function paint() {
    var fg = rgb(opts.color, "fbf8f1"), bg = rgb(opts.background, "0c1e18"), ac = rgb(opts.accent, "e6ff55"), s = box.style;
    s.setProperty("--ef-kte-fg", "rgb(" + fg + ")"); s.setProperty("--ef-kte-fg-rgb", fg.join(","));
    s.setProperty("--ef-kte-bg", "rgb(" + bg + ")"); s.setProperty("--ef-kte-bg-rgb", bg.join(","));
    s.setProperty("--ef-kte-top", mix(bg, fg, 0.045)); s.setProperty("--ef-kte-muted", mix(bg, fg, 0.62));
    s.setProperty("--ef-kte-acc", "rgb(" + ac + ")"); s.setProperty("--ef-kte-acc-rgb", ac.join(","));
    s.setProperty("--ef-kte-k", String(1 / opts.speed));
    s.setProperty("--ef-kte-size", String(opts.size));
    box.setAttribute("data-label", opts.label ? "1" : "0");
    box.classList.toggle("is-reduced", reduce);
  }
  function update(partial) {
    var prev = opts;
    opts = Object.assign({ effect: "shatter", text: "BREAK ON CONTACT", words: 'npm run build, git commit -m "ship it", deploy --prod', speed: 1, size: 42, color: "#fbf8f1", accent: "#e6ff55", background: "#0c1e18", label: 1 }, opts, partial || {});
    opts.speed = Math.min(5, Math.max(0.1, num(opts.speed, 1)));
    opts.size = Math.max(10, num(opts.size, 42));
    opts.label = !(opts.label === 0 || opts.label === "0" || opts.label === false || opts.label === "off");
    paint();
    if (destroyed) return;
    if (!fx || prev.effect !== opts.effect || prev.text !== opts.text || prev.words !== opts.words) build();
  }

  update(o);
  box.addEventListener("pointerenter", onEnter);
  box.addEventListener("pointerleave", onLeave);
  box.addEventListener("focus", onFocus);
  box.addEventListener("blur", onBlur);
  box.addEventListener("keydown", onKey);
  if ("IntersectionObserver" in window) {
    io = new IntersectionObserver(function (es) { visible = es[es.length - 1].isIntersecting; if (visible) kick(); });
    io.observe(box);
  }
  if ("ResizeObserver" in window) {
    ro = new ResizeObserver(function () { if (!destroyed) box.classList.toggle("is-narrow", box.clientWidth < 520); });
    ro.observe(box);
  }
  function onVis() { if (!document.hidden) kick(); }
  document.addEventListener("visibilitychange", onVis);
  return {
    update: update,
    destroy: function () {
      destroyed = true; stopTimers(); cancelAnimationFrame(raf); raf = 0;
      box.removeEventListener("pointerenter", onEnter);
      box.removeEventListener("pointerleave", onLeave);
      box.removeEventListener("focus", onFocus);
      box.removeEventListener("blur", onBlur);
      box.removeEventListener("keydown", onKey);
      document.removeEventListener("visibilitychange", onVis);
      if (io) io.disconnect();
      if (ro) ro.disconnect();
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"effect":"shatter","text":"BREAK ON CONTACT","words":"npm run build, git commit -m \"ship it\", deploy --prod","speed":1,"size":42,"color":"#fbf8f1","accent":"#e6ff55","background":"#0c1e18","label":1});

How it works

Shatter splits the line into letters and gives each one a random offset (up to 120 px sideways and 90 px up or down) and turn (up to 90 degrees) once, as CSS variables; hovering moves every letter there and fades it out over 0.5 s, and leaving brings them home the same way. Dissolve blurs the word to 10 px, grows it to 115% and fades it over 0.7 s. Fold collapse tips the line back 70 degrees and flattens it to 15% height over 0.55 s on cubic-bezier(.6,0,.3,1) inside a 500 px perspective, dimming at once as the original does. The terminal types a command at 60 ms a letter behind an accent cursor, holds it for 1.1 s, deletes it at 35 ms a letter, waits 0.3 s and moves on, on a requestAnimationFrame clock. Speed divides every duration.

Accessibility and performance

The line, or the list of commands, is visually hidden text for screen readers; the animated copy is hidden from them. The element is focusable: keyboard focus plays the exit, Enter or Space toggles it and Escape brings the word back. With prefers-reduced-motion nothing moves: hover or focus only dims the word, and the terminal shows its first command, still.

The exits are CSS transitions of transform, opacity and filter, triggered by a class. The terminal's clock pauses off screen and in hidden tabs, and nothing else runs between interactions.

SectionText
TechniquesText animation, Pointer interaction, CSS 3D
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
On CodePenOpen the live pen on our CodePen account; fork it to experiment.
Inspired byText Animations 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.

Serif motion study heroA starlit green hero whose serif headline blurs into place with hand-drawn underlines, then opens onto an endless curved wall of cards you can drag through. Team card fan heroA "Small team, big results" hero where a fanned row of photo cards drops in, floats, tilts under the pointer and fans out as you scroll away. 3D ring carouselCards on a CSS 3D ring that rotates one step at a time, with buttons, arrow keys and autoplay.

FAQ

Can the shatter play on click instead of hover?

Yes. The exit is just the is-hot class on the root; toggle it from your own click handler, as the keyboard Enter key does.

Can I put real commands in the terminal?

Type any comma-separated list. Each entry is typed, held, deleted and followed by the next, looping forever.

Why does the fold dim straight away?

In the original only the fold's transform is animated and the dimming is instant, so the word greys out first and then folds away. That is kept here.