All components

Text Displacement Cursor Repel

JavaScript animation component · Published 2026-07-27 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

Headings and paragraph text are split into individual letters and words that get pushed away from the cursor within a 150px radius, the displacement force scaling with proximity. Pure requestAnimationFrame with per-element lerp smoothing (no GSAP) drives the elastic follow-and-settle motion via CSS transforms on mousemove.

How it's built

Categorytext
Techvanilla JS
Complexitypage
Performance costlight
Mobile-safedesktop-first

text cursor displacement repel hover lerp raf interactive vanilla-js

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

Text Displacement Cursor Repel (letters & words flee the cursor)

Goal

Build a full-viewport dark typographic page where two giant headings and a justified paragraph are split into individual letters (headings) and words (paragraph), and every fragment is elastically pushed away from the mouse cursor: any fragment whose original center lies within a 150px radius of the cursor gets a displacement force pointing radially away from the mouse, scaling linearly up to 300px at zero distance, and every fragment eases toward its target each frame with per-element lerp smoothing (factor 0.1), so text bulges away from the cursor and springs softly back to place when the mouse leaves. The star effect is this smooth repel-and-settle displacement field over live text.

Tech

Vanilla HTML/CSS/JS with an ES module script (<script type="module" src="./script.js">). No GSAP, no plugins, no Lenis, no libraries at all — the entire effect is manual DOM text splitting, one mousemove listener, per-element linear interpolation (lerp), and an infinite requestAnimationFrame loop writing inline CSS transform: translate(x, y). There is no scroll interaction.

Layout / HTML

div.container                      (full-viewport flex stage)
  h1.anime-header                  "one subscription"
  p.anime-text                     (long justified paragraph, see text below)
  h1.anime-header                  "endless web design"

Use this exact paragraph copy for .anime-text (a single <p>, no line breaks needed):

> Sharing all the sauce behind building dope interactive experiences and the finest websites that truly stand out. Recently, MotionpromptsPRO was introduced, a subscription-based service tailored to the needs of passionate web designers. As a PRO member, you gain exclusive access to source code for each tutorial and monthly website templates. These resources are carefully curated to support and inspire your creativity, helping you take your web design skills to the next level. MotionpromptsPRO opens up a realm of opportunities for professional growth and empowers you to bring your ideas to life with ease. Delve into coding without clutter. Access the source code for every tutorial published on the Motionprompts YouTube channel and build elegant website components effortlessly. Take the fast lane to mastery. Each month, you'll receive a fresh complete responsive website template. Inspired by award winning web experiences, these templates allow you to build standout websites without starting from scratch, serving as the perfect foundation for your next project.

The markup starts as plain text — JS replaces the text content of all three elements with <span> fragments at runtime (see effect section).

Styling

  • * { margin:0; padding:0; box-sizing:border-box; user-select:none; } — text must NOT be selectable (the cursor sweeps across it constantly).
  • body { background-color:#1a1a1a; color:#fff; font-family:"TWK Lausanne", monospace; } — near-black page, white text. "TWK Lausanne" is a commercial font that is not loaded, so keep the declaration exactly as-is and let it fall back to the generic monospace system font (that fallback IS the intended look).
  • .container { position:relative; width:100vw; height:100vh; padding:2em; display:flex; flex-direction:column; justify-content:center; align-items:center; gap:4em; overflow:hidden; } — heading / paragraph / heading stacked and centered with 4em gaps; displaced fragments that overshoot the viewport get clipped.
  • .anime-text { font-size:16px; font-weight:400; line-height:1.25; text-align:justify; -webkit-font-smoothing:antialiased; -moz-osx-font-smoothing:grayscale; }
  • .anime-header { font-size:10vw; font-weight:400; } — the two headings scale with the viewport (huge, roughly one line each).
  • .word { position:relative; margin-right:0.75em; margin-bottom:0.5em; display:inline-block; will-change:transform; } — paragraph fragments.
  • .letter { position:relative; display:inline-block; will-change:transform; } — heading fragments (no margins; real space text nodes separate the words).

The effect (exhaustive — vanilla JS, mousemove + lerp + rAF)

Entry point & calls

On DOM ready, run one generic function twice:

animateTextElements(".anime-text", "words");
animateTextElements(".anime-header", "letters");

animateTextElements(selector, splitBy) loops over ALL elements matching the selector (so both h1.anime-header are handled by the same call) and, per element, performs the split, position capture, mouse tracking and rAF loop described below.

Bootstrapping: since the script is a deferred ES module, use the robust pattern — define an init that does the two calls, then:

if (document.readyState === "loading") {
  document.addEventListener("DOMContentLoaded", init);
} else {
  init();
}
1. Manual text splitting (no SplitText — hand-rolled)

For each target element:

  • splitBy === "words" (the paragraph): element.textContent.trim().split(/\s+/) → array of words. Element type/class: word.
  • splitBy === "letters" (the headings): split into words the same way, then flatten each word into its individual characters, pushing a literal " " entry between words (after every word except the last). Element type/class: letter.

Then clear the container (textContent = "") and rebuild it:

  • For each entry, create a <span> with class word or letter and the fragment as text, append it to the container.
  • Spaces are plain text nodes, never spans, never animated: in letters mode, when the entry is " ", append document.createTextNode(" ") and skip it (no tracking object). In words mode, append a createTextNode(" ") after every word span except the last.
  • For every created span, push a tracking object into an animatedElements array:

``js { element: span, originalX: 0, originalY: 0, currentX: 0, currentY: 0, targetX: 0, targetY: 0 } ``

2. Original-position capture (once, 100ms after split)

Inside a setTimeout(..., 100) (lets layout settle), for each tracked span read getBoundingClientRect() and store its center in viewport coordinates:

originalX = rect.left + rect.width / 2;
originalY = rect.top + rect.height / 2;

and reset currentX/Y and targetX/Y to 0. These originals are measured once and never re-measured — no resize/scroll recalculation (the layout is a static 100vh page).

3. Mouse tracking — the repel field

A document-level mousemove listener updates every element's target each time the mouse moves, using these exact constants:

const radius = 150;           // px — influence radius around the cursor
const maxDisplacement = 300;  // px — force at distance 0

For each tracked element:

const dx = originalX - mouseX;          // vector FROM the cursor TO the element
const dy = originalY - mouseY;
const distance = Math.sqrt(dx * dx + dy * dy);

if (distance < radius) {
  const force = (1 - distance / radius) * maxDisplacement;  // linear falloff: 300 at 0px → 0 at 150px
  targetX = (dx / distance) * force;    // normalized direction × force → pushed AWAY from cursor
  targetY = (dy / distance) * force;
} else {
  targetX = 0;                          // outside the radius → spring back home
  targetY = 0;
}

Key semantics: the distance test uses the original (rest) center, not the displaced position — so an element that has been pushed out of the radius keeps being driven while the cursor stays near its home spot, and elements can never be "chased" indefinitely.

4. rAF render loop — lerp smoothing

An infinite requestAnimationFrame loop (started immediately, per container) eases every element toward its target with lerp factor 0.1:

const lerpFactor = 0.1;
currentX += (targetX - currentX) * lerpFactor;
currentY += (targetY - currentY) * lerpFactor;
element.style.transform = `translate(${currentX}px, ${currentY}px)`;

This single loop produces both halves of the motion: the springy push-away while the cursor is near (targets jump on every mousemove, positions catch up smoothly) and the soft settle back to translate(0, 0) when the cursor leaves the radius (targets snap to 0, positions decay exponentially). No easing curves, no durations — the feel comes entirely from the 0.1 lerp at display refresh rate.

Timeline summary (there is no timeline)

No GSAP, no tweens, no ScrollTrigger, no SplitText, no CustomEase. Trigger is mousemove only; motion model is per-frame exponential smoothing (current += (target − current) × 0.1) applied to transform: translate() on every letter/word span, at all times, forever.

Assets / images

None. The component is pure typography on a flat #1a1a1a background. No images, no icons, no external fonts to load.

Behavior notes

  • Runs on any viewport (no desktop gate), but it is a mouse-driven effect — on touch devices nothing moves. No prefers-reduced-motion branch.
  • The rAF loop never stops; with target 0 and lerp 0.1 the transforms decay to ~0 and idle cost is negligible.
  • Because original centers are captured once at load (viewport coords) and the page is a fixed 100vh/100vw flex layout with overflow:hidden, there is no scrolling and no need to re-measure.
  • The paragraph is text-align: justify with inline-block .word spans (0.75em right / 0.5em bottom margins) plus real spaces — expect a slightly airy, grid-like word spacing; that is intentional.
  • Heading spaces are unstyled text nodes between .letter spans, so word gaps in the headings never displace.