Copied!
Text

Hover lift type

Big uppercase type that rises into place letter by letter, then lifts and changes colour under the pointer, word by word or letter by letter.

← 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 "Hover lift type" for my site using the Enrichr Library element at https://enrichr.dev/elements/hover-lift-type.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/hover-lift-type.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:
- text: Built to move
- split: chars
- lift: 50
- speed: 0.4
- background: #fbf8f1
- ink: #0c1e18
- accent: #e54c1e

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/hover-lift-type.json.

Code

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

HTML

<div class="ef-hlt">
  <h2 class="ef-hlt__t">Built to move</h2>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-hlt { position: relative; box-sizing: border-box; width: 100%; height: 100%; min-height: 26rem; display: flex; align-items: center; justify-content: center; padding: 6rem 2rem 2rem; overflow: hidden; border-radius: 1rem; background: #fbf8f1; color: #0c1e18; container-type: inline-size; }
.ef-hlt .ef-hlt__t { width: 100%; max-width: 64rem; margin: 0; padding: 0; opacity: 1; color: inherit; font-family: 'Parkinsans', 'Space Grotesk', sans-serif; font-size: clamp(2.25rem, 12cqw, 6rem); font-weight: 800; line-height: 1.1; letter-spacing: 0; text-align: center; text-transform: uppercase; cursor: crosshair; }
.ef-hlt .ef-hlt__sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.ef-hlt .ef-hlt__w, .ef-hlt .ef-hlt__c { display: inline-block; transform-origin: bottom center; }

JavaScript

// Enrichr Library · https://enrichr.dev
// Hover lift type: big uppercase type split into letters or words. On first view every piece rises 100 px into place and
// fades in, one after another 0.03 s apart, over twice the animation speed on a power2.inOut curve. Hovering a piece
// lifts it by the hover lift and turns it the accent colour; leaving drops it back and returns the colour, both over the
// animation speed on power2.inOut, and every tween starts from wherever the piece is, so quick sweeps make a rolling wave.
// GSAP and SplitType are re-implemented: spans per word and letter, and a small tween engine on requestAnimationFrame.
// Reduced motion: no rise and no lift; hovering only changes the colour. Animation stops when nothing is moving, and the
// intro waits until the element is on screen.
// mount(root, { text, split (chars | words), lift (px), speed (seconds), background, ink (text colour), accent (hover colour) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var wrap = root.querySelector(".ef-hlt");
  var h = root.querySelector(".ef-hlt__t");
  var opts = {}, raf = 0, io = null, destroyed = false, seen = false, items = [], tweens = [], built = "";

  function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }
  function rgb(hex, d) { var m = /^#?([0-9a-f]{6})$/i.exec(hex || ""); var v = parseInt(m ? m[1] : d, 16); return [v >> 16, (v >> 8) & 255, v & 255]; }
  // power2.inOut, GSAP's cubic in-out
  function ease(p) { return p < 0.5 ? Math.pow(p * 2, 3) / 2 : 1 - Math.pow((1 - p) * 2, 3) / 2; }

  function apply(it) {
    it.el.style.transform = it.y ? "translate(0px, " + it.y + "px)" : "";
    it.el.style.opacity = it.op;
    it.el.style.color = "rgb(" + Math.round(it.c[0]) + "," + Math.round(it.c[1]) + "," + Math.round(it.c[2]) + ")";
  }
  // gsap.to / fromTo for one property of one piece: drop any running tween of that property, start from the current value
  function tween(it, prop, to, d, delay) {
    tweens = tweens.filter(function (t) { return !(t.it === it && t.prop === prop); });
    var from = prop === "c" ? it.c.slice() : it[prop];
    tweens.push({ it: it, prop: prop, from: from, to: to, t0: performance.now() + (delay || 0) * 1000, d: Math.max(0.001, d) });
    if (!raf) raf = requestAnimationFrame(tick);
  }
  function tick(now) {
    raf = 0;
    if (document.hidden) { raf = requestAnimationFrame(tick); return; }
    var dirty = [];
    tweens = tweens.filter(function (t) {
      var p = (now - t.t0) / (t.d * 1000);
      if (p < 0) return true; // still waiting for its stagger slot
      p = p > 1 ? 1 : p;
      var e = ease(p), it = t.it;
      if (t.prop === "c") it.c = [t.from[0] + (t.to[0] - t.from[0]) * e, t.from[1] + (t.to[1] - t.from[1]) * e, t.from[2] + (t.to[2] - t.from[2]) * e];
      else it[t.prop] = t.from + (t.to - t.from) * e;
      if (dirty.indexOf(it) < 0) dirty.push(it);
      return p < 1;
    });
    dirty.forEach(apply);
    if (tweens.length) raf = requestAnimationFrame(tick);
  }

  function onEnter(e) {
    var it = e.currentTarget.__ef;
    if (!it) return;
    if (!reduce) tween(it, "y", -opts.lift, opts.speed);
    tween(it, "c", rgb(opts.accent, "e54c1e"), opts.speed);
  }
  function onLeave(e) {
    var it = e.currentTarget.__ef;
    if (!it) return;
    if (!reduce) tween(it, "y", 0, opts.speed);
    tween(it, "c", rgb(opts.ink, "0c1e18"), opts.speed);
  }

  function unbind() { items.forEach(function (it) { it.el.removeEventListener("mouseenter", onEnter); it.el.removeEventListener("mouseleave", onLeave); it.el.__ef = null; }); }
  // SplitType(h, { types: "words, chars" }): words are inline-block spans holding inline-block letter spans
  function split() {
    unbind();
    tweens = [];
    var text = String(opts.text == null ? "" : opts.text).replace(/\s+/g, " ").trim() || "Built to move";
    h.textContent = "";
    var sr = document.createElement("span"); sr.className = "ef-hlt__sr"; sr.textContent = text; h.appendChild(sr);
    var vis = document.createElement("span"); vis.setAttribute("aria-hidden", "true"); h.appendChild(vis);
    var words = [], chars = [];
    text.split(" ").forEach(function (w, i) {
      if (i) vis.appendChild(document.createTextNode(" "));
      var ws = document.createElement("span"); ws.className = "ef-hlt__w";
      for (var k = 0; k < w.length; k++) {
        var cs = document.createElement("span"); cs.className = "ef-hlt__c"; cs.textContent = w[k];
        ws.appendChild(cs); chars.push(cs);
      }
      vis.appendChild(ws); words.push(ws);
    });
    var ink = rgb(opts.ink, "0c1e18");
    items = (opts.split === "words" ? words : chars).map(function (el) {
      var it = { el: el, y: 0, op: 1, c: ink.slice() };
      el.__ef = it;
      el.addEventListener("mouseenter", onEnter);
      el.addEventListener("mouseleave", onLeave);
      return it;
    });
    built = text + "|" + opts.split + "|" + opts.speed;
  }
  // intro: fromTo(targets, { y: 100, opacity: 0 }, { y: 0, opacity: 1, duration: speed * 2, stagger: 0.03, ease: power2.inOut })
  function intro() {
    items.forEach(function (it, i) {
      if (reduce) { it.y = 0; it.op = 1; apply(it); return; }
      it.y = 100; it.op = 0; apply(it); // immediateRender: the from values show at once
      tween(it, "y", 0, opts.speed * 2, i * 0.03);
      tween(it, "op", 1, opts.speed * 2, i * 0.03);
    });
  }

  function update(partial) {
    opts = Object.assign({ text: "Built to move", split: "chars", lift: 50, speed: 0.4, background: "#fbf8f1", ink: "#0c1e18", accent: "#e54c1e" }, opts, partial || {});
    opts.lift = num(opts.lift, 50); opts.speed = Math.max(0.05, num(opts.speed, 0.4));
    if (opts.split !== "words") opts.split = "chars";
    wrap.style.background = opts.background;
    wrap.style.color = opts.ink;
    if (destroyed) return;
    var key = String(opts.text == null ? "" : opts.text).replace(/\s+/g, " ").trim() + "|" + opts.split + "|" + opts.speed;
    // like the original: new text, a new split or a new speed re-splits and replays the intro; lift and colours apply live
    if (key !== built) { split(); if (seen) intro(); else items.forEach(function (it) { it.op = reduce ? 1 : 0; apply(it); }); }
    else {
      var ink = rgb(opts.ink, "0c1e18");
      items.forEach(function (it) { if (it.y === 0 && !tweens.some(function (t) { return t.it === it; })) { it.c = ink.slice(); apply(it); } });
    }
  }

  update(o);
  function show() { if (seen || destroyed) return; seen = true; intro(); }
  if ("IntersectionObserver" in window) {
    io = new IntersectionObserver(function (e) { if (e[0].isIntersecting) { show(); io.disconnect(); io = null; } });
    io.observe(wrap);
  } else show();
  return {
    update: update,
    destroy: function () {
      destroyed = true; cancelAnimationFrame(raf); raf = 0; tweens = [];
      unbind();
      if (io) io.disconnect();
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"text":"Built to move","split":"chars","lift":50,"speed":0.4,"background":"#fbf8f1","ink":"#0c1e18","accent":"#e54c1e"});

How it works

The text is split into word spans holding letter spans, both inline-block so they can move, the way SplitType does it. When the element first comes into view, every letter (or word) starts 100 pixels low and invisible and rises into place, 0.03 seconds after the one before, over twice the animation speed on a power2.inOut curve. Hovering a piece lifts it by the hover lift and fades it to the hover colour over the animation speed; leaving drops it back and fades the colour home. Every tween starts from wherever the piece is at that moment, so sweeping across the line makes a rolling wave, and a lifted letter that slides out from under the pointer drops and can be caught again. A small tween engine on requestAnimationFrame runs only while something is moving. As in the original, changing the text, the split or the speed replays the intro; the lift and the colours apply straight away.

Accessibility and performance

The full text stays in the heading as visually hidden text and the split pieces are hidden from screen readers, so it reads as one normal heading. With prefers-reduced-motion nothing rises or lifts; hovering only fades the colour. The hover is decorative, so keyboard users miss nothing. Check the hover colour against the background if you change either.

Only transform, opacity and colour change, on a few dozen spans, and the animation loop stops as soon as nothing is moving. The intro waits until the element is on screen.

SectionText
TechniquesText animation, Pointer interaction, Stagger, Reveal and intro
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
On CodePenOpen the live pen on our CodePen account; fork it to experiment.
Inspired byGSAP Dual-Effect Typography 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.

Flight path heroA photo hero with a gradient headline that drops in letter by letter, a plane flying a soft contrail, and a menu button that wipes a full-screen menu down while the page swings away. 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. Stagger letter swapClick to swap two screens of big type: every character slides up and out in a stagger, the background shifts colour, and the next text rises into place, with a soft cursor circle following the mouse.

FAQ

Can I lift whole words instead of letters?

Yes. Set Split by to Words. Words never break across lines, and each one lifts and changes colour as a block.

Does it need GSAP or SplitType?

No. The split is two loops that wrap each word and letter in a span, and the tweens are a few lines that ease from the current value to the target with the same power2.inOut curve GSAP uses.

Why does a letter sometimes bounce when I hold the pointer on it?

When a letter lifts it can move out from under the pointer, which counts as leaving, so it drops, and when it comes back under the pointer it lifts again. That bounce is part of the original effect; lower the lift if you want it calmer.

Will it work on phones?

A tap lifts the piece you touch and it drops when you tap somewhere else, because phones send a mouse enter on tap. The intro plays the same everywhere.