ProximityReveal
Reveal content when the user's mouse approaches a target element
Introduction
Use the useProximityReveal hook to detect mouse proximity and the ProximityReveal component to animate content in and out. Attach targetRef to the element you want to detect proximity to.
Editable example() => {
const { isRevealed, targetRef } = useProximityReveal({ distance: 64 });
return (
<Card>
<div ref={targetRef} style={{ padding: "var(--space-m)", minHeight: 80 }}>
<Text>Hover near this card</Text>
<ProximityReveal isRevealed={isRevealed}>
<Text>Boo! 👻</Text>
</ProximityReveal>
</div>
</Card>
);
}
Edge detection
Restrict proximity detection to specific edges using the edges prop. Only the specified edge will trigger the reveal.
Editable example() => {
const { isRevealed, targetRef } = useProximityReveal({ distance: 48, edges: { top: true } });
return (
<Card>
<div ref={targetRef} style={{ padding: "var(--space-m)", minHeight: 100 }}>
<Text>Only the top edge triggers the reveal</Text>
<ProximityReveal isRevealed={isRevealed}>
<Text>Boo! 👻</Text>
</ProximityReveal>
</div>
</Card>
);
}
Hide delay
Use hideDelay to keep content visible for a short time after the mouse leaves the proximity zone. This prevents flickering when the cursor briefly moves away.
Editable example() => {
const { isRevealed, targetRef } = useProximityReveal({ distance: 48, hideDelay: 500 });
return (
<Card>
<div ref={targetRef} style={{ padding: "var(--space-m)", minHeight: 80 }}>
<Text>Content stays visible for 500ms after leaving</Text>
<ProximityReveal isRevealed={isRevealed}>
<Text>Boo! 👻</Text>
</ProximityReveal>
</div>
</Card>
);
}
Force visible
Use forceVisible to override proximity detection and always show content. Useful for states like edit mode, or for keeping a proximity-revealed button visible while the popover it triggers is open — otherwise the button can disappear out from under the user's cursor.
Editable example() => {
const [editMode, setEditMode] = React.useState(false);
const { isRevealed, targetRef } = useProximityReveal({ distance: 48, forceVisible: editMode });
return (
<Card>
<div ref={targetRef} style={{ padding: "var(--space-m)", minHeight: 80 }}>
<Toggle label="Edit mode" value={editMode} onChange={setEditMode} />
<ProximityReveal isRevealed={isRevealed}>
<Text>Boo! 👻</Text>
</ProximityReveal>
</div>
</Card>
);
}
Force visible with delayed hide
Use forceVisibleWithDelay when an external boolean (e.g. a row-level hover, an open popover) should keep the affordance visible. Unlike forceVisible, flipping it back to false defers the hide by hideDelay ms — which prevents flicker when that external boolean toggles momentarily, for example as the cursor crosses gaps in a row whose reveal target is positioned outside the row's bounding rect.
Editable example() => {
const [hovered, setHovered] = React.useState(false);
const { isRevealed, targetRef } = useProximityReveal({
distance: 32,
hideDelay: 500,
forceVisibleWithDelay: hovered,
});
return (
<Card>
<div
ref={targetRef}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{ padding: "var(--space-m)", minHeight: 80 }}
>
<Text>Hovering this row keeps the reveal sticky for 500ms after leaving</Text>
<ProximityReveal isRevealed={isRevealed}>
<Text>Boo! 👻</Text>
</ProximityReveal>
</div>
</Card>
);
}
Handling multiple reveal conditions
When more than one condition is used to trigger visibility — proximity + extra conditions like an open popover or hovering a component — feed those sources into the hook via forceVisible and forceVisibleWithDelay rather than OR-ing them onto <ProximityReveal isRevealed={...}> yourself.
// ❌ Avoid: external sources bypass hideDelay and can flickerconst { isRevealed, targetRef } = useProximityReveal({ distance: 16, hideDelay: 200 });
<ProximityReveal isRevealed={isRevealed || isHovered || isPopoverOpen}>...</ProximityReveal>;
// ✅ Prefer: every source inherits the hook's hide behaviourconst { isRevealed, targetRef } = useProximityReveal({ distance: 16, hideDelay: 200, forceVisible: isPopoverOpen, forceVisibleWithDelay: isHovered,});
<ProximityReveal isRevealed={isRevealed}>...</ProximityReveal>;
Anything OR'd onto isRevealed externally bypasses hideDelay entirely. Routing those signals through forceVisible (synchronous off) or forceVisibleWithDelay (delayed off) keeps a single coherent hide policy.
Custom proximity
Pass an isNear callback for custom proximity shapes. This example uses a circular zone around the center of the target element.
Editable example() => {
const radius = 120;
const ringWidth = 32;
const isNear = React.useCallback((mouse, rect) => {
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const dx = mouse.x - cx;
const dy = mouse.y - cy;
const dist = Math.sqrt(dx * dx + dy * dy);
return dist <= radius && dist >= radius - ringWidth;
}, []);
const { isRevealed, targetRef } = useProximityReveal({ isNear });
return (
<div style={{ position: "relative", width: radius * 2, height: radius * 2 }}>
<div
style={{
position: "absolute",
inset: 0,
borderRadius: "50%",
border: "32px solid rgba(59, 130, 246, 0.08)",
boxSizing: "border-box",
}}
/>
<div
ref={targetRef}
style={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 100,
height: 100,
border: "2px dashed rgba(100, 116, 139, 0.4)",
borderRadius: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<ProximityReveal isRevealed={isRevealed}>
<Text>Boo! 👻</Text>
</ProximityReveal>
</div>
</div>
);
}
Element tracking
Set trackingMode: "element" to scope mouse tracking to the target element instead of the entire document. Spread onMouseMove and onMouseLeave onto the element. This is useful when you only want to detect proximity while the cursor is already over the element.
Editable example() => {
const { isRevealed, targetRef, onMouseMove, onMouseLeave } = useProximityReveal({
distance: 32,
edges: { top: true },
trackingMode: "element",
});
return (
<Card>
<div
ref={targetRef}
onMouseMove={onMouseMove}
onMouseLeave={onMouseLeave}
style={{ padding: "var(--space-m)", minHeight: 100 }}
>
<Text>Mouse events are scoped to this element</Text>
<ProximityReveal isRevealed={isRevealed}>
<Text>Boo! 👻</Text>
</ProximityReveal>
</div>
</Card>
);
}
Accessibility
ProximityReveal keeps children mounted in the DOM at all times, hiding them visually with opacity: 0 and pointer-events: none. This means screen readers can always reach the content.
Keyboard and screen reader support is baked into the CSS recipe:
- Focus reveal — when any child receives keyboard focus, the container automatically becomes visible via
:has(:focus). - Expanded reveal — when a child has
aria-expanded="true"(e.g. an open dropdown), the container stays visible via:has([aria-expanded='true']).
No additional work is needed from consumers — accessible reveal behaviour works out of the box.