MynduiMyndui
0

Anatomy of Elastic Text

How a variable-font weight spotlight sweeps across characters on a spring — auto or pointer-driven — without layout thrash.

01/04Characters become segments

[font-variation-settings:'wght'_var(--et-wght)]

Segment
one character · data-elastic-segment
Weight axis
'wght' via --et-wght · min ↔ max
tsx
const textContent = getTextContent(children);const segments = textContent ? [...textContent] : null;// …<spanclassName="… [font-variation-settings:'wght'_var(--et-wght,400)]"style={{ "--et-wght": weight }}>{segment}</span>
01

Characters become segments

ElasticText doesn't animate font-size or scale. It animates the wght axis of a variable font — one CSS custom property per character — chased by a spring. The "elastic" feel is that spring settling as a spotlight (or your pointer) moves across the string.

getTextContent(children) flattens the tree to a string, then [...textContent] splits it into one segment per character (including spaces). Each segment is its own motion.span writing --et-wght into font-variation-settings. On Geist (or any wght-axis sans) the weight interpolates continuously; on a static font it snaps to the nearest cut.

The container itself is an inline-block so the per-character spans stay on one baseline. Whitespace segments get aria-hidden so screen readers don't announce empty nodes.

02/04Auto spotlight

animate(spotlight, [0, last], { repeatType: "mirror" })

Spotlight
MotionValue · starts at −AUTO_SPREAD
Influence
clamp(1 − |i − pos| / 2.5, 0, 1)
tsx
// Rest off the left edge so the first paint is uniform weight —// position 0 would flash the leading character at maxWeight.spotlight.set(-AUTO_SPREAD);loop? animate(spotlight, [0, last], {    duration,    repeat: Number.POSITIVE_INFINITY,    repeatType: "mirror",    ease: "easeInOut",  }): animate(spotlight, [-AUTO_SPREAD, last + AUTO_SPREAD], {    duration,    ease: "easeInOut",  });
02

Auto spotlight

In mode="auto" (the default), a single MotionValue called spotlight is the index of the emphasis. Each segment computes influence from its distance to that index:

tsx
 
const AUTO_SPREAD = 2.5;

const autoWeight = useTransform(spotlight, (position) => {
  const distance = Math.abs(index - position);
  const influence = clamp(1 - distance / AUTO_SPREAD, 0, 1);
  return lerp(minWeight, maxWeight, influence);
});

AUTO_SPREAD = 2.5 means roughly five characters feel the bump at once — full weight under the spotlight, falling off over ±2.5 indices. The spotlight itself animates with Framer's animate.

Starting at -AUTO_SPREAD (not 0) avoids the "big first letter" flash before the sweep begins. A one-shot run pads both ends the same way so weight settles back to minWeight everywhere instead of parking on the trailing characters. With startOnView (default true), an IntersectionObserver at threshold 0.3 holds the sweep until the text is on screen, then disconnects.

mode="hover" swaps the spotlight for pointer distance. Segment centers are cached from getBoundingClientRect on enter/resize; on mousemove each segment maps |pointerX − center| through the same clamp/lerp with radius (default 120px) instead of AUTO_SPREAD:

tsx
 
const hoverWeight = useTransform([pointerX, pointerActive], (latest) => {
  const [x, active] = latest as [number, number];
  if (!active) return minWeight;
  const distance = Math.abs(x - getCenter(index));
  const influence = clamp(1 - distance / radius, 0, 1);
  return lerp(minWeight, maxWeight, influence);
});

pointerActive is 0 until mouseenter and back to 0 on leave, so leaving the text relaxes every character to minWeight through the same spring — no separate exit timeline.

03/04The spring between target and paint

useSpring(rawWeight, { stiffness: 150, damping: 18, mass: 1 })

Raw target
useTransform(spotlight → influence)
Spring
stiffness 150 · damping 18 · mass 1
tsx
const SPRING = { stiffness: 150, damping: 18, mass: 1 } as const;const weight = useSpring(rawWeight, SPRING);
03

The spring between target and paint

Raw influence is the target. What you see is a spring chasing it.

Stiffness 150 / damping 18 / mass 1 is deliberately a bit underdamped — weight overshoots the target slightly and settles, which is why characters feel rubbery instead of rigidly locked to the spotlight. Because --et-wght is a CSS variable on font-variation-settings, the browser repaints glyphs without touching layout geometry.

04/04Result
DesignforHumansMoveYourMouse
04

The result

Auto sweep on top; hover on the muted line underneath. Same spring, two drivers.

Accessibility

useReducedMotion() short-circuits the whole motion path: no springs, no spotlight animation, every segment pinned to minWeight as a static --et-wght. Hover listeners aren't attached either. The text remains readable as ordinary variable-font (or fallback) weight — not a faded or stripped-out version of itself.

tsx
 
if (reducedMotion) {
  return (
    <span
      style={{ "--et-wght": minWeight }}
      className={SEGMENT_CLASS}
    >
      {segment}
    </span>
  );
}

Motion Score

Elastic TextCCPaint-triggering
Cfont-variation-settingsWeight morph re-rasterizes text each frame
Each property is graded by how the browser runs it, from S (composited off the main thread) down to F (layout thrashing); the component takes the worst. MotionScore methodology →