Rotation on Scroll
A 120 px dial with 12 tick marks, pinned 50 px from the top of a 300 px box that scrolls 900 px: its needle (::before) turns 720° on a named timeline — scroll-timeline: --rotationScroll y on the box, animation-timeline: --rotationScroll on the needle. Measured 0.8° per pixel, one turn every 450 px, back on the starting tick at the bottom. Zero JavaScript; Firefox: needle still, dial and texts visible.
Updated
This effect is part of Effect.Labs — 811 vanilla effects, some free, some premium. Scroll has 70 effects, including 12 free. Explore the category →
3 usage examples



How it works
The sold HTML stacks a .demo-preview frame (centered flex, min-height: 300px, position: relative, #0a0a0f background, white text, sans-serif), the .rotation-container box (100% × 300 px, overflow: hidden auto, scroll-timeline: --rotationScroll y), an empty .rotation-spacer of 400% (1,200 px, padding-top: 20px) and the .rotation-sticky block (position: sticky; top: 50px, centered column, gap: 12px) holding the .rotation-dial — 120 × 120 px, 3 px border rgba(99,102,241,.3) — and the .rotation-label “SCROLL TO ROTATE” (12 px, weight 600, white at 50%). Inside the dial, twelve 2 × 8 px .dial-ticks placed at top: 4px and rotated from 0 to 330° in 30° steps (:nth-child(1) to (12)) around transform-origin: 50% 56px; the 1st, 4th, 7th and 10th carry .dial-major (12 px, white at 60%) and mark the quarter turns. The needle is the ::before pseudo-element: 3 px wide, 45% tall (51 px), transparent → #6366f1 gradient, anchored bottom: 50%; left: 50%, transform-origin: center bottom; ::after sets the pivot, a 10 px disc. Below the box, .slr-scroll-hint “Scrollez pour tourner” (11.2 px, white at 40%, absolute 8 px from the bottom of the frame) bounces 4 px every 2 s. Measured travel: 1,200 − 300 = 900 px. No JavaScript, no site variable, no console error.
The motion is two declarations. scroll-timeline: --rotationScroll y on the box creates a named timeline worth 0 at the top and 1 at the bottom of its travel; animation-timeline: --rotationScroll on ::before attaches the dialRotate animation to it (translateX(-50%) rotate(0deg) → rotate(720deg), linear, animation-range: normal = the whole travel, fill-mode: both, animation-duration: auto). The browser swaps the clock for the scroll position: the angle is 720° × scrollTop ÷ 900, with no listener and no math. Measured in Chromium in 150 px wheel notches: 120°, 240°, 360°, 480°, 600°, 720° — 0.8° per pixel, one turn every 450 px, one tick every 37.5 px — progress read back at 0.1667 per notch; same values in WebKit 26.5. Arrow down (40 px) gives 32°, PageDown (302 px) 241.6°, End 720°. Scrolling back up turns the needle backwards, with no inertia and no easing. The timeline name is resolved by walking up the pseudo-element's ancestors: two copies of the code pasted in the same page each follow their own box (measured: 0° and 180° with the second one at 225 px), and one box can drive several dials.
The dial itself does not travel: top: 50px pushes the sticky block to 50 px from the top of the box at rest already (its natural place, 20 px, is above the threshold) and holds it there across the 900 px — measured: block at 50 px at every position, 146 px tall, needle and label always in the frame. That is what sets it apart from Rotate on Scroll (fx-0561) and Rotate On Scroll (fx-0549), where the turning object is in the flow and crosses the box. The box was sold as height: 100%: under a parent with no fixed height — a blank page, the site's preview — it took the height of its content (166 px), the percentage spacer collapsed and there was nothing to scroll (measured travel 0, needle frozen). It is now a hard 300 px; change that value and the spacer follows.
Accessibility
- prefers-reduced-motion not in the code: measured under emulation, the needle still follows the scroll (progress 0.5 at 450 px) and the hint keeps bouncing (animation measured
running). The rotation is acceptable as is: it only moves by the reader's gesture (WCAG 2.3.3 targets autonomous motion). The bounce, though, is continuous: add@media (prefers-reduced-motion: reduce) { .slr-scroll-hint { animation: none } }. - Keyboard: measured, Chromium and Firefox give the box focus on Tab without a
tabindex(activeElement=.rotation-container); arrow down moves 40 px (32°) in Chromium, 51 px in Firefox; PageDown 302 px (241.6°), End goes all the way. WebKit 26.5 does not focus it (focus stayed onbody, arrows did nothing): addtabindex="0", anaria-labeland a:focus-visiblestyle. With a finger, the gesture must start inside the box;overscroll-behavior: containkeeps the page from being dragged along at the end of the travel. - Contrast on
#0a0a0f: the label (white at 50%, 12 px) reads 5.3:1; the hint (white at 40%, 11.2 px) 3.8:1, below the 4.5:1 threshold — raise it to 60% or setaria-hidden="true", it is decorative. The ticks at 30% read 2.6:1, below the 3:1 for graphical elements (the majors at 60%, 7.3:1); needle and pivot#6366f14.4:1; the dial border 1.4:1, decorative. If the needle's position must be read, raise the ticks to 50%. - Screen readers and fallback: the dial is made of empty
divs, nothing is read and the angle is not exposed — if the needle carries information (a setting, a position in the text), write it as text next to it, the effect has no script to do so, and hide the drawing witharia-hidden="true". “SCROLL TO ROTATE” and “Scrollez pour tourner” are two hard-coded texts, one in English, one in French, read as is whatever the page language. Without scroll-driven animations (Firefox), the dial is a fixed drawing and the box scrolls; without CSS, a stack of emptydivs and two lines of text.
Browser compatibility
CSS only: scroll-timeline (named timeline), animation-timeline, animation-range, position: sticky, overflow: hidden auto, a gradient and two @keyframes. Zero dependencies, zero JavaScript. Scroll-driven animations exist in Chrome and Edge 115+, Safari and iOS 26+ (measured in WebKit 26.5: 120° per 150 px notch, values identical to Chromium). Firefox: no version. Measured in Firefox 153: CSS.supports('animation-timeline: scroll()') returns false, the declaration and animation-duration: auto are dropped (computed duration 0s), dialRotate runs on the document clock and finishes at once (playState: finished, progress 1, fill-mode: both): the needle takes its final keyframe, rotate(720deg) — two exact turns, hence visually identical to rest — and never moves again. The box scrolls normally (900 px travel), the dial stays pinned at 50 px, ticks, label and hint are shown; no error. A Firefox visitor sees a fixed dial with the needle on the top tick, under an invitation to scroll they cannot verify.
The sold code has no @supports fallback and needs none to stay readable: the final keyframe is the starting state, nothing is hidden or moved. In Firefox, Chrome before 115 and Safari before 26, the dial is a fixed drawing and the box a scrolling area. If the needle carries information, provide it another way for those browsers, or remove the invitation that promises a rotation: @supports not (animation-timeline: scroll()) { .rotation-label, .slr-scroll-hint { display: none } }. For a real rotation everywhere you need a scroll listener writing the transform, as Zoom on Scroll (fx-0580) does for its scale. Where overflow is not honored (some mail clients), the spacer stretches to 1,200 px and the dial sits at the top, still.
The code
HTML structure to paste into your page (the CSS + JS come with Premium):
<div class="demo-preview">
<div class="rotation-container">
<div class="rotation-spacer">
<div class="rotation-sticky">
<div class="rotation-dial">
<div class="dial-ticks">
<div class="dial-tick dial-major"></div><div class="dial-tick"></div><div class="dial-tick"></div>
<div class="dial-tick dial-major"></div><div class="dial-tick"></div><div class="dial-tick"></div>
<div class="dial-tick dial-major"></div><div class="dial-tick"></div><div class="dial-tick"></div>
<div class="dial-tick dial-major"></div><div class="dial-tick"></div><div class="dial-tick"></div>
</div>
</div>
<span class="rotation-label">…</span>
</div>
</div>
</div>
<span class="slr-scroll-hint">…</span>
</div>
The full HTML + CSS + JS of Rotation on Scroll, ready to paste — plus all 811 effects. Or €299 once, lifetime access.
Customize
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
Total angle (CSS — @keyframes dialRotate, to { rotate(720deg) }) |
720° (two turns, 0.8° per pixel over 900 px) | The needle lands back on the starting tick at the bottom. 360deg for one turn per reading, 330deg to go from the first to the twelfth tick without coming back, 270deg for a gauge with a dead quarter. Keep the translateX(-50%) in both keyframes: it centers the needle on the pivot. |
Box height and travel (CSS — .rotation-container { height: 300px }, .rotation-spacer { height: 400% }) |
300 px / 1,200 px, 900 px travel | Travel = spacer − box; angle per pixel = total angle ÷ travel. 200% gives 300 px of travel and 2.4° per pixel; 800%, 2,100 px and 0.34°. A percentage height on the box only works under a parent with a fixed height — which is what made it inert when pasted alone. |
Pin point (CSS — .rotation-sticky { top: 50px }, .rotation-spacer { padding-top: 20px }) |
50 px (146 px sticky block) | The block is pushed to 50 px at rest already because its natural place (20 px) is above the threshold; top: 20px leaves it in place and only pins it later. The block must fit in the box: 50 + 146 = 196 out of 300. |
Tick marks and dial size (HTML + CSS — twelve .dial-tick, :nth-child(n) in 30° steps, transform-origin: 50% 56px, 120 px .rotation-dial) |
12 ticks, 4 majors at 0°, 90°, 180°, 270°, 120 px dial | For 8 ticks, eight divs and :nth-child rules every 45°. The 56 px origin aims 3 px below the center (4 + 56 = 60, inner box center at 57; measured: top tick 47 px from the center, bottom tick 53) — 50% 53px fixes it. The needle follows the dial size as a percentage, the pivot and ticks do not: for 160 px, origin 50% 73px and a 186 px sticky block, hence top ≤ 114 px. |
Colors (CSS — #6366f1 in the ::before gradient, ::after and the border; white at .3 and .6 on the ticks) |
indigo, ticks at 30 and 60% | The needle is a transparent → #6366f1 gradient: its base fades under the pivot, its tip is solid. Ticks at .3 = 2.6:1 on #0a0a0f, .5 passes 3:1. On a light background, also change the .demo-preview frame background and the white of the texts. |
Scroll source (CSS — scroll-timeline: --rotationScroll y) |
the 300 px box | Move the declaration to html to tie the needle to the page scroll (measured on a 3,000 px page: 180° at 600 px, 720° at the bottom), or replace animation-timeline with scroll(root). The dial must remain a descendant of the element carrying the name, sticky or fixed to stay in view. |
Label and hint (HTML — .rotation-label “SCROLL TO ROTATE”, .slr-scroll-hint “Scrollez pour tourner”) |
hard-coded English and French, 4 px bounce every 2 s | Two hard-coded texts in two languages: replace them or delete the spans. The hint anchors to the .demo-preview frame (position: relative): without that frame it aligns to the first screen of the page, no longer to the box; hintBounce goes with it. |
FAQ
.rotation-spacer with your text, keeping .rotation-sticky first: it stays pinned while the text passes underneath. Give it a background and a z-index, otherwise the following paragraphs paint over the dial; top: 0 if the dial must sit flush. The travel becomes content height − 300 px and the 720° spread over it on their own: 1,500 px of text gives 1,200 px of travel, 0.6° per pixel. Two limits: the dial must stay inside .rotation-container, because --rotationScroll is resolved by walking up its ancestors — a dial placed next to the box does not turn (timeline-scope on a common ancestor lifts that limit, Chrome 116+ and Safari 26); and the box keeps a fixed height, to replace with a vh value or a parent that has one. The three scenes on this page are built that way (measured travels 357, 375 and 327 px).animation-timeline: scroll() — the anonymous timeline of the nearest scroller. fx-0561 does the same with a 120 px violet-to-cyan square and two dashed rings that are animated too (−360° for one, +720° for the other). In both, the object is in the flow: it rises with the content, crosses the box and leaves through the top, and nothing tells you where you are. fx-0571 declares a named timeline (scroll-timeline: --rotationScroll y) and pins an instrument: the dial stays 50 px from the top for the whole travel, its twelve ticks give a reading (one tick every 37.5 px), and only the pseudo-element turns. The name also lets you choose the scroller — a specific parent box, or the page via html — where scroll() always takes the nearest one. Pick fx-0549 or fx-0561 for a decorative object passing by, fx-0571 when the rotation must read a position (a setting, a progress, a heading) and stay in view.rotate(360deg) (one tick = 1/12 of the travel) or rotate(330deg) (the needle goes from the first to the twelfth tick and stops there). The position is then read on the ticks: with 720° over 900 px, one tick is 37.5 px and a quarter turn 112.5 px; a 40 px arrow down moves the needle 32°, a 302 px PageDown 241.6° (measured). Nothing is exposed to screen readers: if the position matters, write it as text too.