Take a standard website and make it loop — so you can scroll forever.
The plugin clones the host element's content N times and watches window scroll. When you'd hit the bottom edge, it silently teleports you back one unit-height; same for the top. Every teleport lands on visually identical content, so the seam reads as nothing — just an endless scroll.
goboldlyforward.github.io/hamsterwheel — scroll the framed sample site, watch the loop counter tick.
npm install @goboldlyforward/hamsterwheelOr grab the files directly:
<link rel="stylesheet" href="path/to/hamsterwheel.css">
<script src="path/to/hamsterwheel.js"></script>Drop a single attribute on the element you want to loop. The plugin auto-mounts on DOMContentLoaded.
<main data-hamsterwheel data-clones="4">
<!-- your normal page content -->
</main>Or instantiate it programmatically:
const wheel = new Hamsterwheel('main', {
clones: 6,
autoscroll: true,
speed: 120, // px per second
direction: 'down', // or 'up'
});
wheel.start(); // begin (or resume) autoscroll
wheel.pause(); // stop autoscroll (manual loop still works)
wheel.reverse(); // flip direction
wheel.setOptions({ speed: 200 });
wheel.destroy(); // tear it all down- Wrap. All of the host's current children move into one
.hamsterwheel__unitdiv. - Clone. The plugin appends
clonesdeep copies of the unit (ids stripped,aria-hiddenadded). - Watch. A passive
window.scrolllistener tracks direction. - Teleport. When the viewport bottom touches the wheel bottom while scrolling down, jump
scrollY -= unitHeight. Mirror for upward. Visible content is identical before and after — so it reads as continuous scroll. - Autoscroll. A
requestAnimationFrameloop addsspeed × dtpixels toscrollYeach frame.wheelevents with|deltaY| ≥ flipThresholdagainst the direction will flip it (so a hard upward flick reverses an autoscroll-down).
| Option | Default | Notes |
|---|---|---|
clones |
4 |
extra copies appended after the original |
autoscroll |
false |
start scrolling on its own |
speed |
60 |
autoscroll speed in CSS pixels per second |
direction |
'down' |
'down' or 'up' |
flipOnReverseScroll |
true |
a hard wheel against the autoscroll flips it |
flipThreshold |
40 |
single wheel-event ` |
hideScrollbar |
false |
suppress the scrollbar (selling the illusion) |
autoStart |
true |
call init() in the constructor |
The same options read as data-* attributes on the auto-mounted host: data-clones, data-speed, data-direction, data-autoscroll, data-hide-scrollbar, data-flip-threshold.
Hamsterwheel.initAll(); // scan for [data-hamsterwheel]; idempotent
new Hamsterwheel(target, options?); // mount one element (selector or node)
wheel.start(); // begin autoscroll
wheel.pause(); // stop autoscroll; manual loop still works
wheel.resume(); // alias for start
wheel.stop(); // alias for pause
wheel.reverse(); // flip autoscroll direction
wheel.setDirection('up' | 'down');
wheel.setOptions(patch); // live-tune anything in the options table
wheel.destroy(); // remove clones, unwrap, detach listenersThe plugin assigns itself to el.__hamsterwheel after auto-mount, so you can fetch it later: document.querySelector('[data-hamsterwheel]').__hamsterwheel.
The original hamsterwheel shipped in 2016 as $.fn.hamsterWheel from Polar Notion. The concept hasn't changed; the implementation has. If you're migrating:
$('main').hamsterWheel(opts)→new Hamsterwheel('main', opts)scrollSpeed(ms per pixel) →speed(pixels per second). Multiply:speed ≈ 1000 / scrollSpeed.scrollDelta→flipThreshold(applies per wheel event instead of cumulative scroll).scrollbar: false(v1 default) →hideScrollbar: false(v2 default; opt in if you want it).autoscroll: true(v1 default) →autoscroll: false(v2 default; opt in).- v1 cloned only the host's first child. v2 wraps all children together and clones that — usually what you wanted.
- It owns
windowscroll. The plugin reads and writeswindow.scrollYon its page. For a contained loop, mount it inside an iframe. - Anchor links die at the seams.
#sectionhash links scroll to the original, but the user may have been teleported into a clone since. - Heavy DOM blows up. Final DOM size is
(clones + 1) × unit. Keepclonesbetween 2 and 6 on real sites. - Form state and timers duplicate. An
<input>shows upclones+1times; inlinesetIntervalruns that many times too.
HTML, CSS, and ~5KB of JavaScript. No framework, no build step. Uses requestAnimationFrame, ResizeObserver, and passive scroll listeners.
MIT — see LICENSE. Original 2016 copyright (Polar Notion) preserved alongside the 2026 rewrite.