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

tsx
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

PropTypeDefaultDescription
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

tsx
import { CursorCta, useFinePointer } from '@/components/ui/CursorCta'