# Cursor pill

A drawn cursor that replaces the system one and opens into a labelled pill
over anything that says what it does — "Drag" over a deck, "View" over a
card.

## Position on the node, shape in CSS

```js
const move = (e) => {
  el.style.transform = `translate3d(${e.clientX}px, ${e.clientY}px, 0)`
  const t = e.target
  el.dataset.mode = t?.closest('[data-cursor-pill]') ? 'pill' : 'dot'
  el.dataset.tone = t?.closest('[data-cursor-tone="light"]') ? 'light' : 'dark'
}
```

The position is written straight to the node, never held in state — a
pointermove fires per frame and re-rendering on each one is the one thing
guaranteed to make a cursor lag behind the real one. **No transition on the
position either**: a cursor that eases is a cursor that is wrong. The
*shape* transitions; where it is does not.

## Explicit widths at both ends

```css
.disc {
  width: 20px; height: 20px;
  border-radius: 999px;
  overflow: hidden;
  transition: width .3s, height .3s, background .25s;
}
[data-mode='pill'] .disc { width: 108px; height: 40px; background: #16130f; }
```

A width the browser works out from the content is a width it cannot
transition between. The label is in the markup all along at zero opacity —
a label inserted on hover cannot animate, it can only appear — and fades in
*after* the pill has opened, because text appearing inside a circle that is
still growing reads as a glitch.

## Sections opt in by attribute

The component should know nothing about your pages:

| attribute | effect |
|---|---|
| `data-cursor="hide"` | draw nothing (an element drawing its own) |
| `data-cursor-pill="View"` | the pill, carrying that word |
| `data-cursor-tone="light"` | invert, for light sections |

**The tone matters more than it sounds.** A white cursor on a white section
is no cursor at all — and because the system one is hidden, there is
nothing underneath it either.

## Traps

**Reading a label into state on every move re-renders at 60fps**, which
defeats the reason the position bypasses state. Keep the current label in a
ref, compare, and call `setState` only when the word actually changes.

**z-index above your sticky chrome.** A cursor that disappears behind the
header it is pointing at is worse than none, since the real one is hidden.
Above any modal too, or the dialog gets a disc floating over its close
button.

**Mouse only.** `(hover: hover) and (pointer: fine)` — a touch device has
no pointer to replace, and drawing one leaves a disc stranded wherever the
last tap happened.

## Prompt

> Build a replacement cursor. A zero-size fixed anchor follows the pointer
> by writing `transform` directly to the node with no transition; a centred
> disc inside it carries the shape. Over an element with `data-cursor-pill`,
> the disc grows to an explicit width and height and reveals a label read
> from that attribute, fading in after the growth. Elements opt into hiding
> it and into an inverted tone by attribute. Hold the current label in a ref
> and set state only when it changes. Render only for fine pointers.
