directional-persistence
v0.2.0
Published
React hooks for directional persistence animations — tracking pills, hover cards, and popovers that follow user movement
Maintainers
Readme
directional-persistence
React hooks for directional persistence animations — tracking pills, hover cards, and popovers that follow the user's movement through a sequence.
Zero dependencies. React 18+. SSR-safe. GPU-composited.
Install
npm install directional-persistenceTwo Strategies
| | useTrackingPill | DirectionalGroup |
|---|---|---|
| Pattern | Single persistent element moves between positions | Content mounts/unmounts with directional context |
| Use cases | Tab pills, hover highlights, nav indicators | Popovers, hover cards, detail panels |
| Animation | CSS transitions on transform | CSS animations via data attributes |
| Re-renders | None (direct DOM manipulation) | Only on active item change |
useTrackingPill
A single highlight/pill element that tracks the user's position across a set of items.
Modes
glide(default) — CSS transitions on position. Smooth "Vercel tabs" feel.snap— Teleport with directional offset, then slide into place. Snappier.
Usage
import { useTrackingPill } from 'directional-persistence';
function Tabs({ items }) {
const pill = useTrackingPill({ mode: 'glide' });
return (
<nav ref={pill.containerRef} onMouseLeave={pill.hide} style={{ position: 'relative' }}>
<div
ref={pill.pillRef}
style={{
position: 'absolute',
background: 'rgba(0, 0, 0, 0.05)',
borderRadius: 8,
pointerEvents: 'none',
}}
/>
{items.map(item => (
<button
key={item.id}
onMouseEnter={e => pill.track(e.currentTarget)}
>
{item.label}
</button>
))}
</nav>
);
}Options
useTrackingPill({
mode: 'glide', // 'glide' | 'snap' — default: 'glide'
duration: 150, // transition duration in ms — default: 150
easing: 'ease-out', // CSS easing — default: 'ease-out'
snapOffset: 8, // snap mode offset in px — default: 8
})Returns
| Property | Type | Description |
|---|---|---|
| containerRef | RefObject<HTMLElement> | Attach to the wrapping container |
| pillRef | RefObject<HTMLElement> | Attach to the pill/highlight element |
| track(target) | (el: HTMLElement) => void | Move the pill to a target element |
| hide() | () => void | Hide the pill (e.g. on mouse leave) |
| isVisible | boolean | Whether the pill is currently showing |
DirectionalGroup
Context provider + hooks for content that mounts/unmounts with directional awareness. Perfect for popovers and hover cards that need to animate based on which direction the user came from.
Usage
import { DirectionalGroup, useGroupItem, useGroupContent } from 'directional-persistence';
function NavList({ items }) {
return (
<DirectionalGroup openDelay={100} closeDelay={100}>
{items.map((item, i) => (
<NavItem key={item.id} index={i} item={item} />
))}
</DirectionalGroup>
);
}
function NavItem({ index, item }) {
const { triggerProps, isActive } = useGroupItem(index);
return (
<div style={{ position: 'relative' }}>
<button {...triggerProps}>{item.label}</button>
{isActive && <Popover index={index} content={item.content} />}
</div>
);
}
function Popover({ index, content }) {
const { direction, isTransition, contentProps } = useGroupContent(index);
return (
<div {...contentProps} className="popover">
{content}
</div>
);
}Styling with data attributes
The contentProps include data-direction and data-transition attributes:
/* Vanilla CSS */
[data-direction='forward'] {
animation: slide-forward 150ms ease-out;
}
[data-direction='backward'] {
animation: slide-backward 150ms ease-out;
}
[data-direction='none'] {
animation: fade-in 150ms ease-out;
}{/* Tailwind */}
<div
{...contentProps}
className="data-[direction=forward]:animate-slide-right data-[direction=backward]:animate-slide-left"
/>Default CSS
Import the optional stylesheet for sensible default animations:
import 'directional-persistence/styles';Props & API
<DirectionalGroup>
| Prop | Type | Default | Description |
|---|---|---|---|
| openDelay | number | 100 | Delay before opening on first hover (ms) |
| closeDelay | number | 100 | Grace period before closing on leave (ms) |
useGroupItem(index) returns:
| Property | Type | Description |
|---|---|---|
| triggerProps | object | Spread onto the trigger element |
| isActive | boolean | Whether this item is currently active |
useGroupContent(index) returns:
| Property | Type | Description |
|---|---|---|
| direction | 'forward' \| 'backward' \| null | Direction relative to previous item |
| isTransition | boolean | true when switching between items (vs. first open) |
| contentProps | object | Spread onto content for gap bridging + data attributes |
How it works
Tracking Pill — Direct DOM manipulation via refs. No React re-renders on hover. Uses translate3d for GPU-composited transforms. In snap mode: disables transition → teleports with offset → forces reflow → re-enables transition → animates to final position.
Directional Group — Two independent timers manage open/close behavior. First entry has a configurable delay; subsequent switches are instant. A close grace period allows the cursor to move between trigger and content without flickering. Direction is computed by comparing the current index to the previous one.
License
MIT
