Contextual cursor
A custom cursor that changes size and picks up a text label depending on what it is hovering.
Grade: ARuntimes: css · gsapStatus: code-only
Documented, no live demo yet — the code below is complete.
Intent
Replaces hover states that would otherwise need to live on the element itself. Instead of every card growing a 'View project' overlay, the cursor carries the verb — which keeps the layout still and lets the cursor do the talking.
Implementation
The lerp is skipped under reduced motion so the cursor tracks exactly — smoothing is the part that reads as motion.
Dependencies: none
<div class="cursor" data-cursor aria-hidden="true"><span class="cursor__label"></span></div>
<a href="/work/atlas" data-cursor-label="View" data-cursor-mode="lg">Atlas</a>
<style>
.cursor {
position: fixed;
top: 0;
left: 0;
z-index: var(--z-cursor, 9999);
display: grid;
place-items: center;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--accent, #3d2fe8);
color: #fff;
pointer-events: none;
transform: translate3d(var(--cx, -50px), var(--cy, -50px), 0) translate(-50%, -50%);
transition: width var(--dur-instant, 120ms) ease, height var(--dur-instant, 120ms) ease;
}
.cursor[data-mode="lg"] { width: 84px; height: 84px; }
.cursor__label {
font-size: var(--fs-label, 0.8125rem);
text-transform: uppercase;
letter-spacing: 0.08em;
opacity: 0;
transition: opacity var(--dur-instant, 120ms) ease;
}
.cursor[data-mode="lg"] .cursor__label { opacity: 1; }
/* Only hide the native cursor where the custom one is live. */
html.has-custom-cursor,
html.has-custom-cursor a,
html.has-custom-cursor button { cursor: none; }
html.has-custom-cursor input,
html.has-custom-cursor textarea,
html.has-custom-cursor [contenteditable] { cursor: auto; }
</style>
<script type="module">
const fine = matchMedia("(pointer: fine)").matches;
const smooth = !matchMedia("(prefers-reduced-motion: reduce)").matches;
if (fine) {
const cursor = document.querySelector("[data-cursor]");
const label = cursor.querySelector(".cursor__label");
document.documentElement.classList.add("has-custom-cursor");
let tx = -50;
let ty = -50;
let x = tx;
let y = ty;
addEventListener("pointermove", (event) => { tx = event.clientX; ty = event.clientY; }, { passive: true });
const frame = () => {
const k = smooth ? 0.2 : 1;
x += (tx - x) * k;
y += (ty - y) * k;
cursor.style.setProperty("--cx", x + "px");
cursor.style.setProperty("--cy", y + "px");
requestAnimationFrame(frame);
};
requestAnimationFrame(frame);
// Delegated, so targets added later still work.
document.addEventListener("pointerover", (event) => {
const target = event.target.closest("[data-cursor-label]");
cursor.dataset.mode = target?.dataset.cursorMode ?? "";
label.textContent = target?.dataset.cursorLabel ?? "";
});
}
</script>Use it when
- Portfolio grids, galleries, and drag surfaces where the available action differs per element.
Avoid it when
- Anywhere the native cursor communicates something important — text selection, resize handles, form fields.
- As the only signal for an action. If the cursor is the only affordance, touch and keyboard users have none.
Accessibility
- Never hide the native cursor over text inputs, textareas, or selectable prose.
- Every label the cursor shows must also exist in the DOM — as a visually hidden link text or an aria-label — so it is not the only route to the information.
- Gate on `(pointer: fine)`; touch devices must keep every native behaviour.
- Under reduced motion, drop the follow smoothing and track the pointer exactly.
Performance
- One document-level `pointermove` that only records coordinates, and one rAF loop that writes the transform. Writing transforms from the event handler causes forced layout.
- Use `translate3d` and never animate `left`/`top`.
Knobs
- idle size
- Diameter at rest. Default: 12px
- active size
- Diameter over a labelled target. Default: 84px
- lerp
- Follow smoothing, 1 is instant. Default: 0.2
Composition
Conflicts with
None.
Instruction for Claude Code
Build a contextual custom cursor: a fixed aria-hidden element at z-index 9999 positioned with --cx/--cy custom properties on translate3d. Record pointer coordinates in a passive document pointermove listener and write the transform in a separate rAF loop with 0.2 lerp smoothing (1.0 under prefers-reduced-motion). Use delegated pointerover to read data-cursor-label and data-cursor-mode from the hovered target, growing the cursor to 84px and fading in the label. Apply cursor: none only under a has-custom-cursor root class, and explicitly restore cursor: auto on inputs, textareas, and contenteditable. Gate everything on (pointer: fine), and make sure every cursor label also exists in the DOM as real link text or an aria-label.