Split Text Hover
Hover the text and it’s sliced clean through the middle: the top half slides one way and the bottom half the other, with a thin line along the cut. Move away and the halves glide back together.
Sliced
Parted
How to use it
- Paste the snippet just before
</head>. - Add
data-split-text-hoverto a heading, link or word.
Options
Add any of these attributes to the same element.
data-axis | x (default) slides the halves left and right; y parts them up and down. |
data-distance | How far each half moves, in em. Default 0.12. |
data-color | Colour of the bottom half (and the cut), for a two-tone split. |
data-line="false" | No line along the cut. |
data-duration | How long the split takes. Default 500 ms. |
data-autoplay | Split and rejoin on a timer while the pointer isn’t over it. Handy for demos and touch screens. |
Best on a single line of big, bold text.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Split Text Hover: hover the text and it is sliced through the middle, its
// top half sliding one way and its bottom half the other, then they slide
// back together when the pointer leaves.
// Usage: <h2 data-split-text-hover>Split</h2>
// Optional: data-axis ("x", default: the halves slide left and right; "y":
// they part up and down), data-distance (how far each half moves, in em,
// default 0.12), data-color (colour of the bottom half, for a two-tone
// split), data-line="false" (no thin line along the cut), data-duration
// (ms, default 500), data-autoplay (split and rejoin on a timer while the
// pointer isn't over it; handy for demos and touch screens).
var DEFAULTS = { axis: 'x', distance: 0.12, duration: 500 };
var css =
'.sth{position:relative;display:inline-block}' +
'.sth-base{color:transparent}' +
'.sth-top,.sth-bot{position:absolute;inset:0;pointer-events:none;will-change:transform}' +
'.sth-top{clip-path:inset(-20% -20% 50% -20%)}' +
'.sth-bot{clip-path:inset(50% -20% -20% -20%)}' +
'.sth-cut{position:absolute;left:-4%;right:-4%;top:50%;height:2px;margin-top:-1px;border-radius:2px;' +
'background:currentColor;opacity:.55;transform:scaleX(0);pointer-events:none}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function layer(cls, text) {
var s = document.createElement('span');
s.className = cls;
s.setAttribute('aria-hidden', 'true');
s.textContent = text;
return s;
}
function setup(el) {
var d = el.dataset;
var axis = d.axis === 'y' ? 'y' : DEFAULTS.axis;
var dist = d.distance !== undefined ? +d.distance : DEFAULTS.distance;
var duration = d.duration !== undefined ? +d.duration : DEFAULTS.duration;
var text = el.textContent.replace(/\s+/g, ' ').trim();
el.setAttribute('aria-label', text);
el.classList.add('sth');
el.textContent = '';
var base = layer('sth-base', text);
var top = layer('sth-top', text);
var bot = layer('sth-bot', text);
if (d.color) bot.style.color = d.color;
el.appendChild(base);
el.appendChild(top);
el.appendChild(bot);
var cut = null;
if (d.line !== 'false') {
cut = layer('sth-cut', '');
if (d.color) cut.style.background = d.color;
el.appendChild(cut);
anime.utils.set(cut, { scaleX: 0 });
}
var split = false;
function go(on) {
if (on === split) return;
split = on;
var px = on ? dist * parseFloat(getComputedStyle(el).fontSize) : 0;
var ms = reduced() ? 0 : duration;
var ease = on ? 'out(3)' : 'inOut(3)';
var a = {}, b = {};
a[axis] = -px;
b[axis] = px;
a.duration = b.duration = ms;
a.ease = b.ease = ease;
anime.animate(top, a);
anime.animate(bot, b);
if (cut) anime.animate(cut, { scaleX: on ? 1 : 0, duration: ms * 0.9, delay: on ? ms * 0.1 : 0, ease: ease });
}
el.addEventListener('pointerenter', function (e) { if (e.pointerType !== 'touch') { el._sthReal = true; go(true); } });
el.addEventListener('pointerleave', function (e) { if (e.pointerType !== 'touch') { el._sthReal = false; go(false); } });
// Touch: a tap splits it, another tap joins it back up.
el.addEventListener('pointerdown', function (e) {
if (e.pointerType !== 'touch') return;
el._sthHold = Date.now() + 5000;
go(!split);
});
el.addEventListener('focus', function () { go(true); });
el.addEventListener('blur', function () { go(false); });
return { go: go, isSplit: function () { return split; } };
}
function autoplay(el, api, offset) {
var visible = false, timer = null, mine = false;
function tick() {
if (!visible) return;
var busy = el._sthReal || el === document.activeElement || (el._sthHold && Date.now() < el._sthHold);
if (busy) { mine = false; timer = setTimeout(tick, 800); return; }
if (mine) { api.go(false); mine = false; timer = setTimeout(tick, 1100); }
else if (!api.isSplit()) { api.go(true); mine = true; timer = setTimeout(tick, 1500); }
else timer = setTimeout(tick, 800);
}
new IntersectionObserver(function (entries) {
var was = visible;
visible = entries[0].isIntersecting;
if (visible && !was) { clearTimeout(timer); timer = setTimeout(tick, 400 + offset); }
}).observe(el);
}
function init() {
var counts = [];
document.querySelectorAll('[data-split-text-hover]').forEach(function (el) {
if (el._sth) return;
el._sth = true;
var api = setup(el);
var auto = el.dataset.autoplay;
if (auto === undefined || auto === 'false' || reduced()) return;
var g = counts.filter(function (c) { return c.parent === el.parentElement; })[0];
if (!g) counts.push((g = { parent: el.parentElement, n: 0 }));
autoplay(el, api, g.n++ * 700);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><h2 data-split-text-hover>Sliced</h2>
<!-- parting up and down, two-tone: -->
<h2 data-split-text-hover data-axis="y" data-color="#a855f7">Parted</h2>