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 "One element scroll" for my site using the Enrichr Library element at https://enrichr.dev/elements/one-element-scroll.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/one-element-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:
- title: Piece Together
- accent: #e6ff55
- hold: 0.5
- ease: sine
- parallax: 1
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/one-element-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-oes">
<svg class="ef-oes__defs" width="0" height="0" aria-hidden="true" focusable="false">
<symbol id="efoes-pp1" viewBox="1513.7 1453.9 322.3 322.3"><path fill="currentColor" d="M1821 1761.2h-292.3v-111.6c0-8 6.5-14.5 14.5-14.5 4.2 0 8.2 1.9 11 5.1 7.8 8.6 19.7 13.6 32.7 11.9 16.8-2.3 30.1-16 32-32.7 2.5-22.6-15.1-41.6-37.1-41.6-11 0-20.9 4.7-27.7 12.3-2.8 3.1-6.7 4.9-10.9 4.9-8 0-14.5-6.4-14.5-14.4v-111.7h292.3v111.7c0 8-6.4 14.4-14.5 14.4-4.2 0-8.1-1.8-10.9-5-7.9-8.6-19.7-13.6-32.7-11.9-16.8 2.2-30.1 15.9-32 32.7-2.5 22.6 15.1 41.6 37.1 41.6 11 0 20.8-4.8 27.7-12.3 2.8-3.1 6.7-5 10.8-5 8.1 0 14.5 6.5 14.5 14.5z"/></symbol>
<symbol id="efoes-pp4" viewBox="1423.36 860.8 503.07 322.3"><path fill="currentColor" d="M1821 875.8v111.6c0 8 6.5 14.5 14.5 14.5 4.2 0 8.1-1.9 10.9-5 6.8-7.5 16.7-12.3 27.7-12.3 22 0 39.6 19 37.1 41.6-1.9 16.7-15.2 30.5-32 32.7-13 1.7-24.8-3.3-32.7-11.9-2.8-3.2-6.8-5-11-5-8 0-14.5 6.4-14.5 14.4v111.7h-111.6c-8 0-14.5-6.5-14.5-14.5 0-4.2 1.9-8.2 5-11 8.7-7.9 13.7-19.7 12-32.7-2.3-16.8-16-30.1-32.8-32-22.5-2.5-41.5 15.1-41.5 37.1 0 11 4.7 20.9 12.3 27.7 3.1 2.8 4.9 6.7 4.9 10.8v0.1c0 8-6.4 14.5-14.5 14.5h-111.6v-111.7c0-8-6.4-14.4-14.5-14.4-4.1 0-8 1.8-10.8 4.9-6.9 7.6-16.7 12.3-27.7 12.3-22 0-39.6-19-37.1-41.6 1.9-16.7 15.2-30.4 32-32.7 13-1.7 24.8 3.3 32.7 11.9 2.8 3.2 6.7 5.1 10.9 5.1 8.1 0 14.5-6.5 14.5-14.5v-111.6z"/></symbol>
<symbol id="efoes-pp8" viewBox="1513.7 166.86 322.3 503.06"><path fill="currentColor" d="M1528.7 564.5v-111.6c0-8 6.5-14.5 14.5-14.5 4.2 0 8.2 1.9 11 5 7.8 8.7 19.7 13.7 32.7 11.9 16.8-2.2 30.1-15.9 32-32.7 2.5-22.5-15.1-41.5-37.1-41.5-11 0-20.9 4.7-27.7 12.3-2.8 3.1-6.7 4.9-10.9 4.9-8 0-14.5-6.5-14.5-14.5v-111.6h111.6c8.1 0 14.5-6.5 14.5-14.5 0-4.1-1.8-8.1-4.9-10.9-7.6-6.8-12.3-16.6-12.3-27.6 0-22 19-39.6 41.5-37.1 16.8 1.8 30.5 15.2 32.8 32 1.7 12.9-3.3 24.8-12 32.7-3.1 2.8-5 6.7-5 10.9 0 8 6.5 14.5 14.5 14.5h111.6v111.6c0 8-6.4 14.5-14.5 14.5-4.2 0-8.1-1.9-10.9-5-7.9-8.7-19.7-13.6-32.7-11.9-16.8 2.2-30.1 15.9-32 32.7-2.5 22.5 15.1 41.6 37.1 41.6 11 0 20.8-4.8 27.7-12.3 2.8-3.1 6.7-5 10.8-5 8.1 0 14.5 6.5 14.5 14.5v111.6h-111.6c-8 0-14.5 6.5-14.5 14.5 0 4.2 1.9 8.1 5 10.9 7.5 6.8 12.3 16.7 12.3 27.7 0 22-19 39.5-41.6 37.1-16.8-1.9-30.5-15.3-32.7-32-1.7-13 3.3-24.9 11.9-32.7 3.1-2.9 5-6.8 5-11 0-8-6.4-14.5-14.5-14.5z"/></symbol>
</svg>
<div class="ef-oes__wrap">
<div class="ef-oes__bar" aria-hidden="true"><span class="ef-oes__lab">One element, many layouts</span><span class="ef-oes__hint">Scroll</span></div>
<div class="ef-oes__scroll" tabindex="0" role="region" aria-label="Scrollable demo. Scroll to move one picture between seven layouts.">
<div class="ef-oes__doc">
<div class="ef-oes__one" aria-hidden="true">
<svg viewBox="0 0 322.3 322.3"><use href="#efoes-pp1" width="322.3" height="322.3"/></svg>
</div>
<section class="ef-oes__sec ef-oes__sec--home" data-home></section>
<section class="ef-oes__sec ef-oes__sec--center">
<h3 class="ef-oes__title"><span data-w="0">Piece</span><span data-step class="ef-oes__img ef-oes__portrait"></span><span data-w="1">Together</span></h3>
</section>
<section class="ef-oes__sec ef-oes__sec--column">
<div class="ef-oes__img ef-oes__img--v1" data-par><svg viewBox="0 0 322.3 322.3" aria-hidden="true"><use href="#efoes-pp1" width="322.3" height="322.3"/></svg></div>
<div class="ef-oes__img ef-oes__img--v2" data-par><svg viewBox="0 0 322.3 503.06" aria-hidden="true"><use href="#efoes-pp8" width="322.3" height="503.06"/></svg></div>
<div data-step class="ef-oes__img ef-oes__mid"></div>
<div class="ef-oes__img ef-oes__img--v3" data-par><svg viewBox="0 0 503.07 322.3" aria-hidden="true"><use href="#efoes-pp4" width="503.07" height="322.3"/></svg></div>
<div class="ef-oes__img ef-oes__img--v4" data-par><svg viewBox="0 0 322.3 322.3" aria-hidden="true"><use href="#efoes-pp1" width="322.3" height="322.3"/></svg></div>
</section>
<section class="ef-oes__sec ef-oes__sec--lines">
<h4 class="ef-oes__line"><span>Pieces</span><span data-step class="ef-oes__img ef-oes__inline"></span><span>that fit</span></h4>
<h4 class="ef-oes__line"><span>Made with</span><span data-pop class="ef-oes__img ef-oes__inline ef-oes__img--v5"></span><span>care</span></h4>
<h4 class="ef-oes__line"><span>and</span><span data-pop class="ef-oes__img ef-oes__inline ef-oes__img--v6"></span><span>craft</span></h4>
</section>
<section class="ef-oes__sec ef-oes__sec--sides">
<div data-step class="ef-oes__img ef-oes__side"></div>
<div class="ef-oes__text" data-ptext><p><strong>One element,</strong> many places. The picture on the left is the same box that opened the page. As you scroll it travels from layout to layout, resizing to fit each slot it lands in.</p></div>
</section>
<section class="ef-oes__sec ef-oes__sec--tall">
<div data-step class="ef-oes__img ef-oes__tall"></div>
<div class="ef-oes__text ef-oes__text--c"><p>Every slot is just an empty placeholder. The script measures each one and glides the single element between them.</p></div>
</section>
<section class="ef-oes__sec ef-oes__sec--grid">
<div data-pop class="ef-oes__img ef-oes__img--v2"><svg viewBox="0 0 322.3 322.3" aria-hidden="true"><use href="#efoes-pp1" width="322.3" height="322.3"/></svg></div>
<div data-step class="ef-oes__img ef-oes__cell"></div>
<div data-pop class="ef-oes__img ef-oes__img--v3"><svg viewBox="0 0 503.07 322.3" aria-hidden="true"><use href="#efoes-pp4" width="503.07" height="322.3"/></svg></div>
<div data-pop class="ef-oes__img ef-oes__img--v5"></div>
<div data-pop class="ef-oes__img ef-oes__img--v1"><svg viewBox="0 0 322.3 503.06" aria-hidden="true"><use href="#efoes-pp8" width="322.3" height="503.06"/></svg></div>
<div data-pop class="ef-oes__img ef-oes__img--v6"></div>
<div data-pop class="ef-oes__img ef-oes__img--v4"><svg viewBox="0 0 322.3 322.3" aria-hidden="true"><use href="#efoes-pp1" width="322.3" height="322.3"/></svg></div>
<div data-pop class="ef-oes__img ef-oes__img--v3"></div>
<div data-pop class="ef-oes__img ef-oes__img--v2"><svg viewBox="0 0 503.07 322.3" aria-hidden="true"><use href="#efoes-pp4" width="503.07" height="322.3"/></svg></div>
</section>
</div>
</div>
</div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
/* Adapted from https://github.com/codrops/OneElementScroll (MIT) — Copyright (c) 2009 - 2024 [Codrops](https://tympanus.net/codrops) */
.ef-oes { --ef-h: 26rem; --ef-acc: #e6ff55; width: 100%; }
.ef-oes__defs { position: absolute; width: 0; height: 0; overflow: hidden; }
.ef-oes__wrap { box-sizing: border-box; width: 100%; padding: 0.7rem; border-radius: 1rem; background: #0c1e18; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.ef-oes__bar { display: flex; justify-content: space-between; align-items: center; gap: 0.6rem; margin-bottom: 0.55rem; font-size: 0.68rem; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: rgba(251, 248, 241, 0.7); }
.ef-oes__hint { color: var(--ef-acc); white-space: nowrap; }
.ef-oes__scroll { position: relative; height: var(--ef-h); overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; border-radius: 0.7rem; background: #fbf8f1; scrollbar-width: thin; scrollbar-color: #2f7d5b rgba(0, 0, 0, 0.08); outline: none; }
.ef-oes__scroll:focus-visible { box-shadow: 0 0 0 2px var(--ef-acc); }
.ef-oes__doc { position: relative; width: 100%; color: #0c1e18; }
.ef-oes__one { position: absolute; left: 0; top: 0; z-index: 1; width: 100%; height: var(--ef-h); overflow: hidden; pointer-events: none; will-change: transform, width, height, filter; background: radial-gradient(circle at 72% 24%, var(--ef-acc) 0 11%, transparent 12%), linear-gradient(165deg, #0a6b46, #0c1e18); }
.ef-oes__one svg { position: absolute; left: 50%; top: 50%; width: 58%; height: 58%; transform: translate(-50%, -50%); color: rgba(251, 248, 241, 0.88); }
.ef-oes__sec { position: relative; z-index: 2; box-sizing: border-box; height: var(--ef-h); display: grid; place-items: center; }
.ef-oes__sec--home { z-index: 0; }
.ef-oes__img { position: relative; display: block; overflow: hidden; border-radius: 0.3rem; background: #2f7d5b; will-change: transform, opacity, filter; }
.ef-oes__img svg { position: absolute; left: 50%; top: 50%; width: 56%; height: 56%; transform: translate(-50%, -50%); color: rgba(251, 248, 241, 0.85); }
.ef-oes__img--v1 { background: linear-gradient(160deg, #005236, #0c1e18); }
.ef-oes__img--v1 svg { color: #e6ff55; }
.ef-oes__img--v2 { background: radial-gradient(circle at 30% 30%, #e6ff55 0 18%, transparent 19%), linear-gradient(200deg, #0a6b46, #0c1e18); }
.ef-oes__img--v3 { background: linear-gradient(135deg, #c6f26b, #2f7d5b); }
.ef-oes__img--v3 svg { color: #0c1e18; }
.ef-oes__img--v4 { background: conic-gradient(from 200deg at 40% 60%, #005236, #2f7d5b, #c6f26b, #005236); }
.ef-oes__img--v5 { background: repeating-linear-gradient(120deg, #005236 0 8px, #0c1e18 8px 16px); }
.ef-oes__img--v6 { background: linear-gradient(180deg, #e6ff55, #0a6b46); }
.ef-oes__img[data-step] { background: none; border-radius: 0; }
.ef-oes__title { margin: 0; display: flex; flex-direction: column; align-items: center; gap: 0.5rem; text-align: center; text-transform: uppercase; line-height: 0.9; font-size: clamp(2.1rem, 10vw, 3.2rem); font-weight: 900; letter-spacing: -0.02em; }
.ef-oes__portrait { height: calc(var(--ef-h) * 0.34); aspect-ratio: 0.8; }
.ef-oes__sec--column { grid-template-columns: repeat(5, 1fr); gap: 0.55rem; padding: 0 0.9rem; align-content: center; justify-items: center; }
.ef-oes__sec--column .ef-oes__img { width: 100%; max-width: 84px; aspect-ratio: 0.8; }
.ef-oes__sec--lines { display: flex; flex-direction: column; justify-content: center; gap: 0.4rem; }
.ef-oes__line { margin: 0; display: flex; flex-wrap: nowrap; align-items: center; justify-content: center; gap: 0.5rem; font-size: clamp(1.5rem, 7.4vw, 2.3rem); line-height: 0.95; text-transform: uppercase; font-weight: 900; letter-spacing: -0.02em; white-space: nowrap; }
.ef-oes__inline { flex: none; height: 0.725em; aspect-ratio: 16 / 9; }
.ef-oes__sec--sides { grid-template-columns: 38% 1fr; gap: 1.1rem; padding: 0 1.2rem; }
.ef-oes__side { width: 100%; height: calc(var(--ef-h) * 0.5); }
.ef-oes__text p { margin: 0; font-size: 0.82rem; line-height: 1.55; color: #005236; }
.ef-oes__text strong { color: #0c1e18; }
.ef-oes__sec--tall { align-content: center; gap: 1.1rem; padding: 0 1.4rem; }
.ef-oes__tall { height: calc(var(--ef-h) * 0.3); aspect-ratio: 0.8; }
.ef-oes__text--c { text-align: center; max-width: 30ch; }
.ef-oes__text--c p { font-size: 0.95rem; }
.ef-oes__sec--grid { grid-template-columns: repeat(3, 1fr); grid-template-rows: repeat(3, 1fr); gap: 0.45rem; padding: 0.6rem 0.9rem; place-items: stretch; }
.ef-oes__sec--grid .ef-oes__img { width: 100%; height: 100%; }
@media (prefers-reduced-motion: reduce) { .ef-oes__one { will-change: auto; } }JavaScript
// Enrichr Library · https://enrichr.dev
// Adapted from https://github.com/codrops/OneElementScroll (MIT) — Copyright (c) 2009 - 2024 [Codrops](https://tympanus.net/codrops)
// One element scroll: a single picture travels through every layout of a long page. Each layout holds an empty placeholder; the script
// measures them, and as you scroll it glides the one element from rect to rect (width, height and position, the way Flip.fit does).
// A vanilla port: no GSAP, Flip or ScrollTrigger. Scroll position is turned into a timeline by hand, with a hold between steps. Words
// also slide in, thumbnails pop in, columns drift and a text block rides a parallax, all as pure functions of scrollTop.
// The demo scrolls inside its own container, so it works in an overflow:hidden host. Reduced motion jumps the element between slots.
// mount(root, { title, accent, hold (0-1.5), ease, parallax (0-2) })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var scroller = root.querySelector(".ef-oes__scroll");
var doc = root.querySelector(".ef-oes__doc");
var one = root.querySelector(".ef-oes__one");
var hintEl = root.querySelector(".ef-oes__hint");
var host = root.querySelector(".ef-oes");
var wEls = [].slice.call(root.querySelectorAll("[data-w]"));
var opts = {}, raf = 0, ro = null, offs = [];
var H = 0, W = 0, R = [], S1 = 1, slides = [], pops = [], pars = [], ptext = null;
var NAMES = ["Scroll", "Portrait", "Column", "Inline", "Side", "Centre", "Grid"];
var lastHint = "", lastFilter = "";
var EASE = {
sine: function (u) { return 0.5 - 0.5 * Math.cos(Math.PI * u); },
quad: function (u) { return u < 0.5 ? 2 * u * u : 1 - 2 * (1 - u) * (1 - u); },
cubic: function (u) { return u < 0.5 ? 4 * u * u * u : 1 - 4 * Math.pow(1 - u, 3); },
linear: function (u) { return u; }
};
function clamp(v, a, b) { return v < a ? a : v > b ? b : v; }
function outSine(p) { return Math.sin(p * Math.PI / 2); }
function box(el) {
var r = el.getBoundingClientRect(), d = doc.getBoundingClientRect();
return { x: r.left - d.left, y: r.top - d.top, w: r.width, h: r.height };
}
// measure every placeholder and every animated element once per layout (doc coordinates, so scrolling never re-measures)
function measure() {
H = scroller.clientHeight; W = doc.clientWidth;
if (!H || !W) return;
resetFx();
var steps = [].slice.call(root.querySelectorAll("[data-step]"));
R = [box(root.querySelector("[data-home]"))].concat(steps.map(box));
var last = R[R.length - 1];
var maxScroll = Math.max(1, scroller.scrollHeight - H);
S1 = clamp(last.y + last.h / 2 - H / 2, 1, maxScroll);
var kids = [].slice.call(root.querySelectorAll("[data-w], .ef-oes__line > span:not(.ef-oes__img)"));
slides = kids.map(function (el, i) {
var trig = el.hasAttribute("data-w") ? el.parentNode : el, b = box(trig);
return { el: el, top: b.y, dir: i % 2 === 0 ? -1 : 1 };
});
pops = [].slice.call(root.querySelectorAll("[data-pop]")).map(function (el) { return { el: el, top: box(el).y }; });
var pe = [].slice.call(root.querySelectorAll("[data-par]"));
pars = pe.map(function (el, i) { var b = box(el); return { el: el, top: b.y, h: b.h, k: Math.abs(i - (pe.length - 1) / 2) * 34 }; });
var t = root.querySelector("[data-ptext]");
ptext = t ? { el: t, top: box(t).y } : null;
need();
}
function paintOne(x, y, w, h, tau, st) {
one.style.transform = "translate3d(" + x.toFixed(1) + "px," + y.toFixed(1) + "px,0)";
one.style.width = w.toFixed(1) + "px";
one.style.height = h.toFixed(1) + "px";
one.style.borderRadius = (8 * clamp(tau / 0.5, 0, 1)).toFixed(1) + "px";
var f = reduce ? "none" : "brightness(" + (0.8 + 0.2 * clamp(st / H, 0, 1)).toFixed(3) + ")";
if (f !== lastFilter) { one.style.filter = f; lastFilter = f; }
}
function render() {
raf = 0;
if (!H || !R.length) return;
var st = scroller.scrollTop, n = R.length - 1, hold = opts.hold, T = n + (n - 1) * hold;
var tau = clamp(st / S1, 0, 1) * T, cur = R[0], idx = 0, k, u, e, a, b, ease = EASE[opts.ease] || EASE.sine;
for (k = 0; k < n; k++) {
var start = k * (1 + hold);
if (tau < start) break;
u = clamp(tau - start, 0, 1);
e = reduce ? (u >= 0.5 ? 1 : 0) : (k === 0 ? u : ease(u));
a = R[k]; b = R[k + 1];
cur = { x: a.x + (b.x - a.x) * e, y: a.y + (b.y - a.y) * e, w: a.w + (b.w - a.w) * e, h: a.h + (b.h - a.h) * e };
if (u >= 0.5) idx = k + 1;
}
paintOne(cur.x, cur.y, cur.w, cur.h, tau, st);
var name = NAMES[idx] || "";
if (name !== lastHint) { hintEl.textContent = idx ? "Step " + idx + ": " + name : "Scroll"; lastHint = name; }
if (reduce) return;
var i, p, it, amp = W * 0.16, pk = opts.parallax;
for (i = 0; i < slides.length; i++) {
it = slides[i]; p = clamp((st + H - it.top) / (0.45 * H), 0, 1);
it.el.style.transform = p >= 1 ? "" : "translate3d(" + (it.dir * amp * (1 - outSine(p))).toFixed(1) + "px,0,0)";
}
for (i = 0; i < pops.length; i++) {
it = pops[i]; p = clamp((st + H - it.top) / (0.45 * H), 0, 1);
if (p >= 1) { it.el.style.transform = ""; it.el.style.opacity = ""; it.el.style.filter = ""; continue; }
var q = outSine(p);
it.el.style.transform = "scale(" + q.toFixed(3) + ")";
it.el.style.opacity = q.toFixed(3);
it.el.style.filter = "brightness(" + (1.8 - 0.8 * q).toFixed(2) + ") saturate(" + q.toFixed(2) + ")";
}
for (i = 0; i < pars.length; i++) {
it = pars[i]; p = clamp((st + H - it.top) / (H + it.h), 0, 1);
it.el.style.transform = "translate3d(0," + (it.k * pk * (1 - 2 * outSine(p))).toFixed(1) + "px,0)";
}
if (ptext) { p = clamp((st + H - ptext.top) / H, 0, 1); ptext.el.style.transform = "translate3d(0," + (60 * pk * (1 - 2 * p)).toFixed(1) + "px,0)"; }
}
function need() { if (!raf) raf = requestAnimationFrame(render); }
function resetFx() {
slides.concat(pops, pars, ptext ? [ptext] : []).forEach(function (it) { it.el.style.transform = ""; it.el.style.opacity = ""; it.el.style.filter = ""; });
}
scroller.addEventListener("scroll", need, { passive: true });
offs.push(function () { scroller.removeEventListener("scroll", need); });
if (window.ResizeObserver) { ro = new ResizeObserver(measure); ro.observe(scroller); }
else { window.addEventListener("resize", measure); offs.push(function () { window.removeEventListener("resize", measure); }); }
function update(next) {
opts = Object.assign({ title: "Piece Together", accent: "#e6ff55", hold: 0.5, ease: "sine", parallax: 1 }, opts, next || {});
opts.hold = clamp(+opts.hold, 0, 1.5);
opts.parallax = clamp(+opts.parallax, 0, 2);
host.style.setProperty("--ef-acc", opts.accent);
var words = String(opts.title || "").trim().split(/\s+/);
wEls[0].textContent = words[0] || "";
wEls[1].textContent = words.slice(1).join(" ");
wEls[1].style.display = words.length > 1 ? "" : "none";
if (reduce) resetFx();
measure();
need();
}
update(o);
return {
update: update,
destroy: function () {
if (raf) cancelAnimationFrame(raf);
raf = 0;
if (ro) ro.disconnect();
offs.forEach(function (f) { f(); });
offs = [];
resetFx();
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"title":"Piece Together","accent":"#e6ff55","hold":0.5,"ease":"sine","parallax":1});How it works
A vanilla port of Codrops' One Element Scroll with no GSAP, Flip or ScrollTrigger. One absolutely positioned element sits above the page. Empty placeholders mark each slot, and on layout the script measures their rectangles once, in page coordinates. On scroll it maps scrollTop onto a timeline: one move per slot, each lasting one unit with a hold between moves, scrubbed from the top of the page until the last slot is centred. Each move interpolates the element's position, width and height between two measured rectangles with an easing curve, which is what Flip.fit does by hand. Around that, the same scrollTop drives words that slide in from alternating sides, thumbnails that pop in with a brightness and saturation fade, a column that drifts apart and a text block on a parallax. Everything is a pure function of scroll position, so scrolling back reverses it exactly.
Accessibility and performance
The scrolling frame is a focusable region, so arrow keys, Page Up and Page Down, Home and End all work. The travelling element is decorative and hidden from assistive tech; real content lives in the placeholders' sections. With reduced motion the element jumps from slot to slot at the midpoint of each move, and the slides, pops and parallax are switched off so the page is static apart from scrolling itself. Check text contrast where the moving element passes behind copy.
Per scroll event, one transform and size write on the travelling element, a transform on a handful of others, and no layout reads, because every rectangle is measured once per resize. Writes are coalesced into one requestAnimationFrame. Width and height change on one absolutely positioned element only, so reflow stays small; avoid putting heavy children inside it.
| Section | Full page |
|---|---|
| Techniques | Scroll-driven, Parallax and depth |
| Stack | Vanilla JS, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| On CodePen | Open the live pen on our CodePen account; fork it to experiment. |
| Inspired by | One Element Scroll by Codrops on github.com. 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 animate width and height instead of using scale?
Scaling stretches whatever is inside the element. Changing the size lets a cover-fit background re-crop at each slot, which is how the original Flip.fit behaves, and it only affects one absolutely positioned box.
How do I add another slot?
Add an empty element with a data-step attribute where the picture should land. Slots are read in document order, and the timeline length and the last-slot end point update on the next measure.
Can it run on a full page instead of an inner scroller?
Yes. Replace scroller.scrollTop with the window scroll offset, measure rectangles against the document, and listen for scroll on window. Keep the travelling element absolutely positioned in the document.