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.
| Section | Text |
|---|---|
| Techniques | Text animation, Pointer interaction, Stagger, Reveal and intro |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| On CodePen | Open the live pen on our CodePen account; fork it to experiment. |
| Inspired by | GSAP 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.
More elements
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.