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
- Paste the snippet just before
</head>. - Add
data-snap-bracket-cursorto a section, nav bar or the whole<body>. - 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-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, in px. Default 6. |
data-hide-cursor | Hide the real cursor, on mouse and trackpad only. A small dot marks the pointer instead. |
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. On touch screens a tap snaps them to what you tapped, and keyboard focus moves them too.
<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><nav data-snap-bracket-cursor data-hide-cursor>
<a href="/work">Work</a>
<a href="/about">About</a>
<button>Contact</button>
</nav>