Copied!
Text

Scroll spring heading

An oversized heading slides sideways with scroll progress through a spring, lagging and settling.

← Library

Element · Vanilla JS + CSSUpdated November 5, 2026

TextElementVanilla JS + CSS

Live preview needs JavaScript. The code below works without any dependencies.

Prompt for your AI assistant

Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.

Build "Scroll spring heading" for my site using the Enrichr Library element at https://enrichr.dev/elements/scroll-spring-heading.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/scroll-spring-heading.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.

Settings:
- text: Keep moving
- distance: 220
- stiffness: 0.06
- damping: 0.82
- manual: 0
- color: #ffffff

Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.

The same spec as JSON, for tools that read it directly: /elements/scroll-spring-heading.json.

Code

Mount it on an element that contains the markup. It returns an object with update(options) and destroy().

HTML

<div class="ef-spring"><div class="ef-spring__h"></div></div>

CSS

.ef-spring { overflow: hidden; }
.ef-spring__h { display: inline-block; white-space: nowrap; font-size: clamp(3rem, 14vw, 9rem); font-weight: 800; line-height: 1; letter-spacing: -0.04em; will-change: transform; }
@media (prefers-reduced-motion: reduce) { .ef-spring__h { transform: none !important; } }

JavaScript

// Scroll spring heading: an oversized heading slides sideways as the page scrolls, following scroll progress through a spring,
// so it lags behind, overshoots slightly and settles.
// mount(root, { text, distance (px each side), stiffness (0.01-0.3), damping (0.5-0.98), manual (0 = follow scroll, 1-100 = fixed progress %), color })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var wrap = root.querySelector(".ef-spring");
  var h = root.querySelector(".ef-spring__h");
  var opts = {}, raf = 0, pos = 0, vel = 0, visible = true, io = null;

  function progress() {
    if (opts.manual > 0) return opts.manual / 100;
    var r = wrap.getBoundingClientRect(), vh = window.innerHeight || 800;
    return Math.min(1, Math.max(0, (vh - r.top) / (vh + r.height)));
  }
  function tick() {
    raf = requestAnimationFrame(tick);
    if (!visible) return;
    var target = (progress() - 0.5) * 2 * opts.distance;
    vel = (vel + (target - pos) * opts.stiffness) * opts.damping;
    pos += vel;
    h.style.transform = "translate3d(" + pos.toFixed(1) + "px,0,0)";
  }
  function update(next) {
    opts = Object.assign({ text: "Keep moving", distance: 220, stiffness: 0.06, damping: 0.82, manual: 0, color: "#ffffff" }, opts, next || {});
    h.textContent = opts.text;
    h.style.color = opts.color;
    root.setAttribute("aria-label", opts.text);
  }
  update(o);
  if (!reduce) {
    if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(wrap); }
    raf = requestAnimationFrame(tick);
  }
  return { update: update, destroy: function () { cancelAnimationFrame(raf); if (io) io.disconnect(); } };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"text":"Keep moving","distance":220,"stiffness":0.06,"damping":0.82,"manual":0,"color":"#ffffff"});

How it works

Scroll progress is the element's position as it travels through the viewport, from 0 to 1. That maps to a target offset from minus to plus the travel distance. Each frame a small spring pulls the current offset toward the target: velocity gains stiffness times the gap, loses a share to damping, and moves the heading. Higher stiffness snaps; lower damping overshoots.

Accessibility and performance

The heading stays real text and the movement is decorative. With reduced motion it does not move. The heading can run partly out of view on narrow screens, so keep the travel small there and do not depend on seeing every letter.

One bounding-rect read and one transform write per frame while on screen; paused off screen. No layout work.

SectionText
TechniquesScroll-driven, Text animation
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested

Find this technique on a real site

Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.

Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns

Licence and provenance

Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.

This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.

Word read highlightWords in a paragraph light up one by one as it scrolls through the viewport. Scroll velocity skewText that leans and splits into two colour fringes in proportion to how fast the page scrolls. Marquee tickerEndless horizontal text that pauses on hover, with speed, gap and direction controls.

FAQ

Why a spring instead of a direct mapping?

A direct mapping feels glued to the scrollbar. A spring adds a little lag and overshoot, which makes the heading feel like an object with weight.

Does it work with smooth scrolling libraries?

Yes. It reads the element's position each frame, so any scroll method that moves the page works.

How do I stop it overshooting?

Raise damping toward 0.95 or lower stiffness. The sliders show the effect immediately.