Cursor CTA
The badge that replaces the cursor while the pointer is over a card that is wholly a link — a glass droplet carrying a diagonal arrow over a short label. Move fast and it stretches into a liquid string behind the pointer; stop and it pools back into one round drop. Used by the service cards and the home page case study cards.
Live
Hover me (desktop, fine pointer only)
Sweep across the card to stretch it; hold still to let it pool. The droplet is position: fixed and portalled to document.body, so it is never clipped by the card it belongs to.
Anatomy
Three siblings under document.body. A zero-size <svg> holding the filter. A liquid layer: the 104px head box, moved by the head springs, carrying the head circle and nine trailing beads that overflow it, with filter: url(#goo) applied to the box and the whole result held at 84% opacity — every visible pixel of the drop comes from this one layer. And a label layer: the same box, the same motion, painting nothing of its own — it exists only because text under a blur-and-threshold filter is destroyed. Both share the head’s MotionValues and the same reveal object, so they cannot drift apart.
How the stretch works
The head is a spring behind the pointer (400/32 — 0.08s of lag, the “follows rather than glued” feel). The beads are not springs: each frame the head’s position is recorded, and bead n is placed where the head was n × 28ms ago — the path the pointer just travelled, time-based so a 120Hz display gets the same tail as a 60Hz one. Each bead is then held within 0.55× the smaller diameter of the bead ahead of it, so adjacent circles always overlap and the goo always has a neck to fill: the string stretches but can never break into dots, however fast the flick. That cap is also the string’s maximum length, about 250px. Stop, and the recorded path collapses onto the head within a quarter second — the pool. A chained-spring version was tried first; its gaps grow with speed (velocity × lag per link), and a real flick at 4000px/s left the beads wider apart than they are.
The glass
The glass is lit inside the filter from the merged silhouette, so it belongs to the whole drop and stretches with it — nothing is painted on a disc. Three passes over the goo alpha. Absorption: a wide blur of the silhouette is a fair thickness map, and tinted glass is darkest where thickest, so the body’s own colour is darkened by it (0.72 at the pooled head’s centre, fading through the tail and at every edge). This is what holds the white label at AA over a white screenshot — 5.6:1 — without giving the head a heavier disc. Rim: the silhouette eroded 2px and subtracted leaves a band tracing the whole outline, darkened from its own hue rather than flooded black. Highlight: specular lighting from a distant light at the top-left, computed from a blurred-alpha height field — one direction for the whole body, so on a stretched drop the highlight runs along the ridge instead of sitting on each bead. A whole-body frost or refraction is not achievable cross-browser (it needs HTML-in-canvas); a backdrop-filter on the head alone was tried and read as a separate disc, which is exactly the cue this avoids.
Entrance
On activation the head is snapped to the pointer and the path buffer is reset to that point, so the droplet grows in under the cursor. The springs otherwise initialise wherever the pointer last was — (0,0) on a fresh page — and the first hover would animate the whole string in from the corner. The exit is never snapped: the tail retracting into the head as it fades is the point.
Filter region
For url() filters the region is a hard clip, and it defaults to 10% past the element’s own box — on a 104px head that would cut the string off a few pixels behind it. The <filter> declares a region ±5× the box each way instead. It costs nothing to be generous: engines size the offscreen surface from the content’s bounds, so the region only ever clips. Do not put the filter on a viewport-sized wrapper — that rasterises the blur over the whole screen every frame.
Why it is portalled
A fixed element positions against the viewport only while no ancestor carries a transform, filter, or will-change. Any of those makes that ancestor its containing block, at which point an overflow-hidden wrapper clips the badge. Service cards are overflow-hidden and animate a transform on entrance; the case study row is itself transformed as it scrubs horizontally. Portalling to document.body removes the whole ancestor chain from the equation.
Reduced motion
The droplet still appears — it is an affordance, not decoration — but as a single glass disc tracking the raw pointer values with no lag, no beads, no goo filter and no frame loop, and an instant enter and exit.
Wiring
const finePointer = useFinePointer()
const [hovered, setHovered] = useState<number | null>(null)
const x = useMotionValue(0)
const y = useMotionValue(0)
// Track the pointer only while a card is hovered.
useEffect(() => {
if (!finePointer || hovered === null) return
const onMove = (e: PointerEvent) => { x.set(e.clientX); y.set(e.clientY) }
window.addEventListener('pointermove', onMove)
return () => window.removeEventListener('pointermove', onMove)
}, [finePointer, hovered, x, y])
// On each card: seed the position from the entering event, then show.
<div
onPointerEnter={(e) => { x.set(e.clientX); y.set(e.clientY); setHovered(i) }}
onPointerLeave={() => setHovered(null)}
className={cn(hovered === i && finePointer && 'cursor-none')}
/>
<CursorCta active={finePointer && hovered !== null} x={x} y={y} reduce={reduce} label="View more" />Apply cursor-none conditionally, never in the base class list — if JS never runs or the pointer isn’t fine, the user must keep a real cursor. Pair it with a CSS :hover affordance (the card classes brighten their border) for touch and keyboard.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
active* | boolean | - | Whether the pill is showing. Keep it mounted and toggle this — it fades and scales rather than unmounting, so moving between cards glides. |
x* | MotionValue<number> | - | Pointer X in viewport coordinates (raw event.clientX). Owned by the parent so one pair of values serves every card in a section. |
y* | MotionValue<number> | - | Pointer Y in viewport coordinates (raw event.clientY). |
reduce* | boolean | - | Reduced motion. The pill still appears — it degrades to instant tracking with no scale-in rather than disappearing, because it is an affordance, not decoration. |
label* | string | - | Text under the arrow. Sentence case, short — it sits inside a 104px circle. |
accentClassName | string | - | Accent class for whatever the droplet is currently over, e.g. svc-accent-violet. Passed rather than inherited: the droplet is portalled to document.body, so no cascade from the card reaches it. The class sets --accent-deep, which every circle reads. The last active accent is held through the fade-out, so a parent handing back its default while nothing is hovered never recolours the droplet mid-exit. |
Import
import { CursorCta, useFinePointer } from '@/components/ui/CursorCta'