Cursor CTA
The badge that replaces the cursor while the pointer is over a card that is wholly a link — a circle carrying a diagonal arrow over a short label. Used by the service cards and the home page case study cards.
Live
Hover me (desktop, fine pointer only)
The pill is position: fixed and portalled to document.body, so it is never clipped by the card it belongs to.
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.
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. |
Import
import { CursorCta, useFinePointer } from '@/components/ui/CursorCta'