← Hover & cursor

Snap Bracket Cursor

Four corner brackets ride along with the pointer as a small square. Hover a button or link and they spring out to frame it, leaning slightly towards the pointer, then shrink back as you move off.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-snap-bracket-cursor to a section, nav bar or the whole <body>.
  3. Links and buttons inside it are snapped to. Mark anything else with data-snap.

Options

Add any of these attributes to the same element.

data-targetsA CSS selector for what to snap to. Default a, button, [data-snap].
data-colorBracket colour. Default #6a0dad.
data-paddingGap between the brackets and the item, in px. Default 6.
data-hide-cursorHide the real cursor, on mouse and trackpad only. A small dot marks the pointer instead.
data-autoplayA pretend pointer visits each item in turn while a real one isn’t in the area. Handy for demos and touch screens.

The brackets sit over the area without catching clicks. On touch screens a tap snaps them to what you tapped, and keyboard focus moves them too.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Snap Bracket Cursor: four corner brackets ride along with the pointer as
    // a small square. Hover a button or link and they spring out to frame it,
    // leaning slightly towards the pointer; move off and they shrink back.
    // Usage: <nav data-snap-bracket-cursor>…<a href="#">Work</a>…</nav>
    // Optional: data-targets (a CSS selector for what to snap to, default
    // "a, button, [data-snap]"), data-color (bracket colour, default #6a0dad),
    // data-padding (gap between the brackets and the item, px, default 6),
    // data-hide-cursor (hide the real cursor, on mouse and trackpad only),
    // data-autoplay (a pretend pointer visits each item in turn while a real
    // one isn't in the area; handy for demos and touch screens).
    // The brackets sit over the area without catching clicks.
    var DEFAULTS = { targets: 'a, button, [data-snap]', color: '#6a0dad', padding: 6 };

    var css =
      '.sbk{position:relative}' +
      '.sbk-layer{position:absolute;inset:0;z-index:5;pointer-events:none;color:var(--sbk-c)}' +
      '.sbk-box{position:absolute;left:0;top:0;width:100px;height:100px;opacity:0;transition:opacity .25s}' +
      '.sbk-on .sbk-box{opacity:1}' +
      '.sbk-box i{position:absolute;width:10px;height:10px;border:0 solid currentColor}' +
      '.sbk-box i:nth-child(1){left:0;top:0;border-width:2px 0 0 2px;border-top-left-radius:3px}' +
      '.sbk-box i:nth-child(2){right:0;top:0;border-width:2px 2px 0 0;border-top-right-radius:3px}' +
      '.sbk-box i:nth-child(3){right:0;bottom:0;border-width:0 2px 2px 0;border-bottom-right-radius:3px}' +
      '.sbk-box i:nth-child(4){left:0;bottom:0;border-width:0 0 2px 2px;border-bottom-left-radius:3px}' +
      '.sbk-dot{position:absolute;left:-3px;top:-3px;width:6px;height:6px;border-radius:50%;background:currentColor;opacity:0;transition:opacity .25s}' +
      '.sbk-on .sbk-dot{opacity:1}' +
      '@media (hover:hover) and (pointer:fine){.sbk-nocursor,.sbk-nocursor *{cursor:none}}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function num(v, def) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : def; }

    function setup(el) {
      var d = el.dataset;
      var sel = d.targets || DEFAULTS.targets;
      var pad = num(d.padding, DEFAULTS.padding);
      var auto = d.autoplay !== undefined && d.autoplay !== 'false';
      var still = reduced();
      el.classList.add('sbk');
      el.style.setProperty('--sbk-c', d.color || DEFAULTS.color);
      if (d.hideCursor !== undefined && d.hideCursor !== 'false') el.classList.add('sbk-nocursor');

      var layer = document.createElement('div');
      layer.className = 'sbk-layer';
      layer.setAttribute('aria-hidden', 'true');
      layer.innerHTML = '<span class="sbk-box"><i></i><i></i><i></i><i></i></span><span class="sbk-dot"></span>';
      el.appendChild(layer);
      var box = layer.firstChild, dot = layer.lastChild, corners = box.querySelectorAll('i');

      // The box is drawn at its real size each frame; x, y, w, h spring
      // towards the goal so the brackets overshoot a touch as they snap.
      var cur = { x: 0, y: 0, w: 24, h: 24 }, vel = { x: 0, y: 0, w: 0, h: 0 };
      var ptr = { x: 0, y: 0 }, snapped = null, hover = false, hold = 0, visible = false, raf = 0, placed = false;
      var tour = -1, nextStop = 0, fake = { x: 0, y: 0 }, rest = false;

      function local(cx, cy) {
        var b = el.getBoundingClientRect();
        return { x: cx - b.left, y: cy - b.top };
      }
      function rectOf(t) {
        var b = el.getBoundingClientRect(), r = t.getBoundingClientRect();
        var radius = parseFloat(getComputedStyle(t).borderRadius) || 0;
        return { x: r.left - b.left - pad, y: r.top - b.top - pad, w: r.width + pad * 2, h: r.height + pad * 2, r: radius };
      }
      function targets() {
        return Array.prototype.filter.call(el.querySelectorAll(sel), function (t) { return !layer.contains(t) && t.offsetWidth; });
      }
      function goal() {
        if (snapped) {
          var r = rectOf(snapped);
          // Lean a little towards the pointer, like a magnet.
          var lx = (ptr.x - (r.x + r.w / 2)) * 0.08, ly = (ptr.y - (r.y + r.h / 2)) * 0.08;
          return { x: r.x + lx, y: r.y + ly, w: r.w, h: r.h };
        }
        return { x: ptr.x - 12, y: ptr.y - 12, w: 24, h: 24 };
      }
      function jump() {
        var g = goal();
        cur.x = g.x; cur.y = g.y; cur.w = g.w; cur.h = g.h;
        vel.x = vel.y = vel.w = vel.h = 0;
        placed = true;
      }
      function pick(cx, cy) {
        var hit = document.elementFromPoint(cx, cy);
        var t = hit && hit.closest ? hit.closest(sel) : null;
        return t && el.contains(t) && !layer.contains(t) ? t : null;
      }
      function aim(cx, cy, t) {
        var p = local(cx, cy);
        ptr.x = p.x; ptr.y = p.y;
        snapped = t === undefined ? pick(cx, cy) : t;
        if (!placed || still) jump();
        wake();
      }

      function render() {
        box.style.transform = 'translate(' + cur.x.toFixed(1) + 'px,' + cur.y.toFixed(1) + 'px)';
        box.style.width = Math.max(4, cur.w).toFixed(1) + 'px';
        box.style.height = Math.max(4, cur.h).toFixed(1) + 'px';
        var arm = Math.max(5, Math.min(14, Math.min(cur.w, cur.h) / 3)).toFixed(1) + 'px';
        for (var i = 0; i < 4; i++) corners[i].style.width = corners[i].style.height = arm;
        dot.style.transform = 'translate(' + ptr.x.toFixed(1) + 'px,' + ptr.y.toFixed(1) + 'px)';
        dot.style.visibility = snapped ? 'hidden' : '';
      }

      function frame(now) {
        raf = 0;
        if (!visible) return;
        var touchHeld = Date.now() < hold;
        var faking = auto && !hover && !touchHeld;
        if (faking) {
          // Visit each target in turn, with a stop in open space between.
          var list = targets();
          if (!placed) { ptr.x = el.clientWidth / 2; ptr.y = el.clientHeight * 0.8; jump(); }
          if (now >= nextStop && list.length) {
            tour = (tour + 1) % (list.length + 1);
            if (tour < list.length) {
              var r = rectOf(list[tour]);
              fake.x = r.x + r.w * (0.35 + Math.random() * 0.3); fake.y = r.y + r.h * (0.3 + Math.random() * 0.4);
              snapped = list[tour];
            } else {
              fake.x = el.clientWidth * (0.3 + Math.random() * 0.4); fake.y = el.clientHeight * (0.15 + Math.random() * 0.15);
              snapped = null;
            }
            nextStop = now + 1100;
          }
          ptr.x += (fake.x - ptr.x) * 0.12; ptr.y += (fake.y - ptr.y) * 0.12;
        }
        var on = hover || touchHeld || faking || rest;
        el.classList.toggle('sbk-on', on);
        var g = goal(), moving = 0;
        ['x', 'y', 'w', 'h'].forEach(function (k) {
          vel[k] = (vel[k] + (g[k] - cur[k]) * 0.22) * 0.68;
          cur[k] += vel[k];
          moving += Math.abs(vel[k]) + Math.abs(g[k] - cur[k]);
        });
        render();
        if (faking || moving > 0.2 || touchHeld) raf = requestAnimationFrame(frame);
      }
      function wake() { if (!raf && visible) raf = requestAnimationFrame(frame); }

      el.addEventListener('pointerenter', function (e) {
        if (e.pointerType === 'touch') return;
        hover = true;
        aim(e.clientX, e.clientY);
      });
      el.addEventListener('pointermove', function (e) {
        if (e.pointerType === 'touch') return;
        hover = true;
        aim(e.clientX, e.clientY);
      });
      el.addEventListener('pointerleave', function (e) {
        if (e.pointerType === 'touch') return;
        hover = false;
        snapped = null;
        wake();
      });
      // Touch: a tap snaps the brackets to whatever was tapped.
      el.addEventListener('pointerdown', function (e) {
        if (e.pointerType !== 'touch') return;
        hold = Date.now() + 2500;
        aim(e.clientX, e.clientY);
      });
      // Keyboard focus snaps the brackets too.
      el.addEventListener('focusin', function (e) {
        var t = e.target.closest && e.target.closest(sel);
        if (!t || !el.contains(t)) return;
        var r = t.getBoundingClientRect();
        hold = Date.now() + 2500;
        aim(r.left + r.width / 2, r.top + r.height / 2, t);
      });
      window.addEventListener('resize', function () { if (still) { jump(); render(); } wake(); });

      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (visible) wake();
      }).observe(el);

      if (still && auto) {
        // Reduced motion: frame the first item and stay there.
        auto = false;
        rest = true;
        var first = targets()[0];
        if (first) {
          var fr = first.getBoundingClientRect();
          aim(fr.left + fr.width / 2, fr.top + fr.height / 2, first);
          el.classList.add('sbk-on');
          render();
        }
      }
    }

    function init() {
      document.querySelectorAll('[data-snap-bracket-cursor]').forEach(function (el) {
        if (el._sbk) return;
        el._sbk = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<nav data-snap-bracket-cursor data-hide-cursor>
  <a href="/work">Work</a>
  <a href="/about">About</a>
  <button>Contact</button>
</nav>