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.
| Section | Band |
|---|---|
| Techniques | Scroll-driven, Pointer interaction |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | Footer 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.
More elements
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.