Examples

Text magnet

Flipping magnetic text attraction and repulsion.

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.

Repelling · switches every 5s
View on GitHub

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

  1. Split the paragraph into characters with useSplitText(el, { chars: true }).
  2. Measure each character's getBoundingClientRect() once after the split resolves, and again on resize, scroll and pointerenter, never on pointer move.
  3. Track the pointer coordinates used for hit testing in plain variables on pointermove and pointerleave, since nothing re-renders from them. Keep separate container-relative refs for the radius halo, which does.
  4. Run a continuous createTimer({ onUpdate }) loop rather than reacting to pointermove directly, so the effect keeps evaluating even while the pointer holds still.
  5. 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, pushes z negative so it recedes into the page, and tilts it with rotateX/rotateY, all scaled by the same distance-based force. The second keyframe eases x, y, z, rotate, rotateX, and rotateY all back to 0 on inOutQuad, after a 400ms delay and over 800ms. Give the container a tight CSS perspective (a few hundred pixels) so the depth reads.
  6. Flip a repel and attract mode ref on a useIntervalFn every 5 seconds. Nothing else needs to change, since the push vector's sign already reads from that ref.
Copyright © 2026