Copied!
Band

Footer bounce on scroll speed

A footer block that stretches and squashes from its bottom edge by how fast you scroll, then wobbles back on a damped spring.

← Library

Element · Vanilla JS + CSSUpdated November 6, 2026

ScrollElementVanilla 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 "Footer bounce on scroll speed" for my site using the Enrichr Library element at https://enrichr.dev/elements/footer-bounce-scroll.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/footer-bounce-scroll.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:
- accent: #e6ff55
- text: Let's make it
- strength: 1
- stiffness: 160
- bounce: 0.6

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/footer-bounce-scroll.json.

Code

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

HTML

<div class="ef-fbs">
  <div class="ef-fbs__wrap" tabindex="0" role="group" aria-label="Footer that stretches and bounces with scroll speed. Scroll, drag or use the arrow keys over it.">
    <div class="ef-fbs__foot">
      <span class="ef-fbs__bar" aria-hidden="true"></span>
      <p class="ef-fbs__kick">Say hello</p>
      <p class="ef-fbs__head"></p>
      <p class="ef-fbs__links"><span>hello@studio.example</span><span>Instagram</span><span>Back to top</span></p>
    </div>
  </div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-fbs { width: 100%; }
.ef-fbs__wrap { position: relative; box-sizing: border-box; width: 100%; height: 22rem; overflow: hidden; border-radius: 1rem; display: flex; align-items: flex-end; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; outline: none; overscroll-behavior: contain; }
.ef-fbs__wrap:focus-visible { box-shadow: inset 0 0 0 2px var(--ef-acc, #e6ff55); }
.ef-fbs__foot { --s: 0; position: relative; box-sizing: border-box; width: 100%; height: 13rem; padding: 1.6rem 1.6rem 1.3rem; display: flex; flex-direction: column; justify-content: flex-end; border-radius: 1.6rem 1.6rem 0 0; background: #005236; color: #f2f6f0; transform-origin: 50% 100%; will-change: transform; }
.ef-fbs__bar { position: absolute; left: 0; right: 0; top: 0; height: 0.4rem; border-radius: 1.6rem 1.6rem 0 0; background: var(--ef-acc, #e6ff55); opacity: calc(0.35 + var(--s) * 0.65); }
.ef-fbs__kick { margin: 0 0 0.3rem; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; color: var(--ef-acc, #e6ff55); }
.ef-fbs__head { margin: 0; font-size: clamp(2.4rem, 11vw, 5rem); font-weight: 900; letter-spacing: -0.04em; line-height: 0.95; }
.ef-fbs__links { margin: 1rem 0 0; display: flex; flex-wrap: wrap; gap: 0.4rem 1.4rem; font-size: 0.8rem; opacity: 0.85; }
@media (prefers-reduced-motion: reduce) { .ef-fbs__foot { will-change: auto; } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Footer bounce on scroll speed: the footer block stretches and squashes from its bottom edge by how fast you scroll, then wobbles back on a damped spring.
// Velocity comes from page scroll when the page scrolls, and from wheel, touch/mouse drag and arrow keys on the element itself when it does not (e.g. a clipped preview).
// Reduced motion keeps the block still. mount(root, { background, accent, text, strength (0.2-3), stiffness (40-400), bounce (0.1-0.9) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var wrap = root.querySelector(".ef-fbs__wrap");
  var foot = root.querySelector(".ef-fbs__foot");
  var head = root.querySelector(".ef-fbs__head");
  var opts = {}, offs = [], raf = 0, last = 0, s = 0, v = 0, lastY = 0, drag = null, visible = true;

  function scrollable() { var d = document.documentElement; return d.scrollHeight > window.innerHeight + 2; }
  function impulse(dy) {
    if (reduce) return;
    var d = Math.max(-120, Math.min(120, dy));
    v += d * 0.045 * opts.strength;
    run();
  }
  function paint() {
    var c = Math.max(-0.35, Math.min(0.9, s));
    foot.style.transform = reduce ? "none" : "scale(" + (1 - c * 0.12).toFixed(4) + ", " + (1 + c).toFixed(4) + ")";
    foot.style.setProperty("--s", Math.min(1, Math.abs(c)).toFixed(3));
  }
  function run() { if (!raf) { last = performance.now(); raf = requestAnimationFrame(tick); } }
  function tick(t) {
    raf = 0;
    var dt = Math.min(0.05, (t - last) / 1000); last = t;
    var k = opts.stiffness, ratio = Math.max(0.12, 1.05 - opts.bounce), c = 2 * ratio * Math.sqrt(k);
    var n = 4, h = dt / n;
    for (var i = 0; i < n; i++) { v += (-k * s - c * v) * h; s += v * h; }
    paint();
    if (Math.abs(s) < 0.0006 && Math.abs(v) < 0.006) { s = 0; v = 0; paint(); return; }
    run();
  }
  function onWheel(e) {
    if (scrollable()) return;
    impulse(e.deltaY * (e.deltaMode === 1 ? 32 : e.deltaMode === 2 ? 400 : 1) * 0.6);
  }
  function onScroll() {
    var y = window.pageYOffset || document.documentElement.scrollTop || 0, dy = y - lastY;
    lastY = y;
    if (visible) impulse(dy * 0.8);
  }
  function onDown(e) { drag = { y: e.clientY, id: e.pointerId }; try { wrap.setPointerCapture(e.pointerId); } catch (x) {} }
  function onMove(e) {
    if (!drag || e.pointerId !== drag.id) return;
    var dy = e.clientY - drag.y; drag.y = e.clientY;
    impulse(-dy * 1.2);
  }
  function onUp(e) { if (drag && e.pointerId === drag.id) { try { wrap.releasePointerCapture(e.pointerId); } catch (x) {} drag = null; } }
  function onKey(e) {
    var map = { ArrowDown: 40, ArrowUp: -40, PageDown: 110, PageUp: -110 };
    if (!(e.key in map)) return;
    if (scrollable()) return;
    e.preventDefault();
    impulse(map[e.key]);
  }
  function touchMode() { wrap.style.touchAction = scrollable() ? "pan-y" : "none"; }
  function bind() {
    lastY = window.pageYOffset || 0;
    wrap.addEventListener("wheel", onWheel, { passive: true });
    wrap.addEventListener("pointerdown", onDown);
    wrap.addEventListener("pointermove", onMove);
    wrap.addEventListener("pointerup", onUp);
    wrap.addEventListener("pointercancel", onUp);
    wrap.addEventListener("keydown", onKey);
    window.addEventListener("scroll", onScroll, { passive: true });
    window.addEventListener("resize", touchMode);
    var io = null;
    if (window.IntersectionObserver) { io = new IntersectionObserver(function (en) { visible = en[0].isIntersecting; }); io.observe(wrap); }
    touchMode();
    offs.push(function () {
      wrap.removeEventListener("wheel", onWheel); wrap.removeEventListener("pointerdown", onDown);
      wrap.removeEventListener("pointermove", onMove); wrap.removeEventListener("pointerup", onUp);
      wrap.removeEventListener("pointercancel", onUp); wrap.removeEventListener("keydown", onKey);
      window.removeEventListener("scroll", onScroll); window.removeEventListener("resize", touchMode);
      if (io) io.disconnect();
    });
  }
  function update(next) {
    opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", text: "Let's make it", strength: 1, stiffness: 160, bounce: 0.6 }, opts, next || {});
    opts.strength = Math.max(0.2, Math.min(3, +opts.strength));
    opts.stiffness = Math.max(40, Math.min(400, +opts.stiffness));
    opts.bounce = Math.max(0.1, Math.min(0.9, +opts.bounce));
    wrap.style.background = opts.background;
    wrap.style.setProperty("--ef-acc", opts.accent);
    head.textContent = String(opts.text);
    paint();
  }
  bind();
  update(o);
  return {
    update: update,
    destroy: function () {
      offs.forEach(function (f) { f(); }); offs = [];
      if (raf) cancelAnimationFrame(raf);
      raf = 0; s = 0; v = 0; foot.style.transform = "none";
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","text":"Let's make it","strength":1,"stiffness":160,"bounce":0.6});

How it works

The script keeps one stretch value s and a velocity v for a damped spring. Each scroll movement adds an impulse to v that is proportional to the scroll distance, so faster scrolling kicks harder. A short animation loop integrates the spring in a few sub-steps per frame and writes scale(1 - s * 0.12, 1 + s) on the footer with its origin at the bottom edge, so it grows upward and thins slightly, then overshoots and settles. Page scroll supplies the impulse when the page scrolls; when it does not (a clipped preview, or a short page) the wheel, a pointer drag and the arrow and page keys on the element supply it instead. The loop stops when the spring is at rest.

Accessibility and performance

The element is a focusable group so keyboard users can trigger the bounce with the arrow and page keys when the page itself does not scroll; when it does, those keys scroll the page as usual. Reduced motion turns the stretch off entirely and leaves a static footer. Keep real links in the footer as normal anchors; the stretch is purely visual and does not move focus targets far, but avoid putting tiny controls near the stretched top edge.

Only a single transform is written per frame on one element, and only while the spring is moving. The scroll listener is passive and ignores events when the footer is off screen. No layout properties are animated.

SectionBand
TechniquesScroll-driven, Pointer interaction
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byFooter Bounce Based on Scroll Speed by Codrops on Codrops. Original code, written from scratch.

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.

Image compare on scrollA before and after slider whose divider is scrubbed by scroll position and can also be dragged or keyboard-operated. Kinetic type warpA giant canvas headline sliced into strips that ripple with scroll speed and swell under the pointer. Unroll image on scrollA poster that unrolls like a sheet of paper from a spiral roll as you scroll or hover.

FAQ

Why does the footer only bounce on scroll speed, not position?

The impulse is the change in scroll position per event, not the position. Slow scrolling barely moves it and a hard flick stretches it a lot, which is what makes it feel physical.

Will it work if the page is not scrollable?

Yes. It listens to wheel, drag and arrow keys on the element itself, so it still reacts inside a fixed-height preview or a short page.

How do I make it springier?

Raise the bounciness control for more wobble, or the stiffness for a quicker, tighter return. Strength changes how far it stretches for a given scroll speed.