Text magnet
Move the cursor over the paragraph and nearby characters react like iron filings near a magnet, pushed away and back into the page, then eased back to their resting spot. Every five seconds the polarity flips, so what was repelling starts attracting and vice versa. The cursor doesn't need to move for the change to show.
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
What's happening
Every letter in the paragraph is its own little element, and each one only reacts once the cursor gets close enough. When it does, the letter gets pushed away (or pulled in, depending on the mode) with a bit of random jitter added, so a row of letters doesn't move in a stiff, identical line. Rather than sliding flat, it also tumbles and recedes into the page, like it's being knocked backward away from the cursor. Once it's drifted, it eases back to where it started, at its original size and depth.
How it's built
useSplitText splits the paragraph into one
element per character first, since each one needs to move on its own. A
continuous timer, not the pointermove event, drives the distance check, so
it keeps running even while the cursor sits still. That matters since the
mode flips on its own 5-second timer whether or not the pointer is moving.
On every tick, it walks every character and skips two kinds: anything
outside the radius, and anything whose current animation is less than 40%
complete (progress < 0.4). That second rule stops a held-still cursor from
restarting the same letter's animation on every single tick. A new push can
only interrupt one that's already more than 40% through, not one that just
started.
Each push is a two-step animation: an immediate displacement away from or
toward the pointer, then a return to the character's original spot on
inOutQuad, held back by a 400ms delay and eased over 800ms. Because that
return step always targets the same rest position, a character never drifts
no matter how many times it gets pushed again mid-settle.
The depth comes from adding z (Anime.js's shorthand for translateZ)
alongside x and y, pushed to a negative value so the character moves
away from the viewer, plus rotateX/rotateY for the tumble. All three
scale off the same distance-based force value already used for the push
itself, and all ease back to 0 in the same return keyframe. None of this
reads as depth without a tight CSS perspective on the container. A large
perspective value flattens the effect toward a 2D skew. A smaller one (this
demo uses 400px) puts the viewer closer to the scene and exaggerates it.
Character positions are measured once with getBoundingClientRect() after
the split resolves, then re-measured only on resize, on scroll and when the
pointer enters the container, since a pointer move alone can't change the
layout.
The halo that marks the radius is separate from all of this. The hit test runs off plain, non-reactive pointer variables, while the halo is positioned from container-relative refs, so the reactive work is limited to the one element that needs to re-render.
Building it yourself
- Split the paragraph into characters with
useSplitText(el, { chars: true }). - Measure each character's
getBoundingClientRect()once after the split resolves, and again on resize, scroll andpointerenter, never on pointer move. - Track the pointer coordinates used for hit testing in plain variables on
pointermoveandpointerleave, since nothing re-renders from them. Keep separate container-relative refs for the radius halo, which does. - Run a continuous
createTimer({ onUpdate })loop rather than reacting topointermovedirectly, so the effect keeps evaluating even while the pointer holds still. - In
onUpdate, for each character: skip it if it's outside a fixed radius from the pointer, skip it if its current animation's progress is less than 40% (progress < 0.4), otherwise animate it with a two-keyframe motion. The first keyframe displaces it along the line to the pointer, pushesznegative so it recedes into the page, and tilts it withrotateX/rotateY, all scaled by the same distance-based force. The second keyframe easesx,y,z,rotate,rotateX, androtateYall back to0oninOutQuad, after a 400ms delay and over 800ms. Give the container a tight CSSperspective(a few hundred pixels) so the depth reads. - Flip a repel and attract mode ref on a
useIntervalFnevery 5 seconds. Nothing else needs to change, since the push vector's sign already reads from that ref.
Using the nanime Nuxt module, build a Vue 3 component with a paragraph of
text that reacts to cursor proximity like a magnet. Split the paragraph
into character elements with useSplitText(el, { chars: true }).
Track pointer position on pointermove and pointerleave over a container.
Run a continuous timer with createTimer({ onUpdate }) that, on each tick,
walks every split character and, for any character within a fixed pixel
radius of the pointer, animates it with a two-keyframe animation: the first
keyframe displaces it away from (repel mode) or toward (attract mode) the
pointer along the vector between them, scaled by how close the pointer is.
The first keyframe also pushes z (Anime.js's shorthand for translateZ) to a
negative value so the character recedes away from the viewer, and tilts it
with rotateX and rotateY, all scaled by the same distance-based force used
for the push. The second keyframe eases x, y, z, rotate, rotateX, and
rotateY all back to 0 with ease: 'inOutQuad', delay: 400 and duration: 800.
Use Anime.js's own easing names like 'inOutQuad', not the 'easeInOutQuad'
convention other libraries use, since an unrecognized ease string fails
silently and falls back to linear. Give the container a tight CSS perspective (a few hundred pixels,
not a thousand-plus) so the depth reads instead of flattening
into a skew. Skip characters whose current animation is less than 40%
complete (progress < 0.4), so a new push can only interrupt one that has
already progressed past that point, not one that started a frame ago.
Toggle a repel and attract mode ref automatically every 5 seconds with
useIntervalFn. Measure character positions with getBoundingClientRect()
once after the split resolves, and re-measure only on window resize and
scroll.