Rejouice — loading0

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

html-css-js
<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

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.