← Hover & cursor

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

  1. Paste the snippet just before </head>.
  2. Add data-split-text-hover to a heading, link or word.

Options

Add any of these attributes to the same element.

data-axisx (default) slides the halves left and right; y parts them up and down.
data-distanceHow far each half moves, in em. Default 0.12.
data-colorColour of the bottom half (and the cut), for a two-tone split.
data-line="false"No line along the cut.
data-durationHow long the split takes. Default 500 ms.
data-autoplaySplit 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.

1 · Snippet
<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>
2 · Your HTML
<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>