Scroll✨ Premium

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.

CSSscroll()Rotation

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

Grind dial of a coffee machine — setting tutorial on the product page — Rotation on Scroll example 1

① Grind dial of a coffee machine — setting tutorial on the product page

WhenProduct page of a grinder: the graduated dial stays at the top, the instructions (espresso, filter, French press) scroll beneath it; the needle turns step by step toward the setting being described.
WhyThe needle's position on the 12 ticks matches a step of the text, and it turns back when you scroll up: it is a setting, not a decoration — fx-0549 spins a square with no reference mark that leaves the screen.
Settings350 × 312 px box to the right of a product column, light background; sticky block at top: 0, laid out as a row (dial + caption), white background and z-index: 2 so the cards slide underneath; spacer replaced by four cards (measured travel 357 px); keyframes reduced to one turn (rotate(360deg)) so that one click is 30°; needle and pivot in brown #8a5a2b, dark ticks on white. The automatic scrolling is a demo device.
Pressure gauge of a diving procedure — a club's online manual — Rotation on Scroll example 2

② Pressure gauge of a diving procedure — a club's online manual

When“Decompression stops” page: the gauge stays pinned, the stops (3 m, 6 m, 9 m) scroll by with their durations; the needle turns as the reading advances.
WhyA diver rereads the stops in both directions and the gauge must stay in view all the way to the surface: the dial is pinned at the top of the box for the whole travel, the needle turns back when you scroll up, the major ticks mark the quarter turns — in fx-0561, the square rises with the list and leaves the frame before the last stop, and its dashed rings carry no graduation.
Settings330 × 314 px box under a 46 px bar, reminder column on the right; sticky block as a row (gauge + heading) on a #082238 background; five stops as a list (measured travel 375 px, 720° kept: 1.9° per pixel); amber needle and pivot #ffb547, cyan majors #5cd6ff; “Pressure gauge” label instead of “SCROLL TO ROTATE”.
Compass of a text adventure game — a chapter read in the browser — Rotation on Scroll example 3

③ Compass of a text adventure game — a chapter read in the browser

WhenA chapter page: the compass stays at the top, the story scrolls; the needle makes two full turns while the hero crosses the forest, back to north on the last line.
Why720° over the whole travel = two exact turns, and the compass stays pinned under the chapter bar from the first paragraph to the last: the reader sees the needle pass north again mid-chapter and settle on it at the last line — fx-0549 and fx-0561 also turn 720°, but their square rises with the text and leaves the frame well before the end of the chapter, and nothing on it marks north.
SettingsFull-width box (540 × 316 px) under a 44 px bar; sticky block as a column like the sold code, top: 0, solid background; compass = the 120 px dial surrounded by N, E, S, W in a 142 px square, red needle #e0563a, brass pivot and border; seven paragraphs in Georgia and two choices (measured travel 327 px, 2.2° per pixel): the needle passes north again mid-chapter and settles on it at the last line.

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 on body, arrows did nothing): add tabindex="0", an aria-label and a :focus-visible style. With a finger, the gesture must start inside the box; overscroll-behavior: contain keeps 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 set aria-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 #6366f1 4.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 with aria-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 empty divs 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.

Chrome 115+✓ Full
Firefox✓ No version: needle frozen on the top tick, dial and texts visible
Safari 26+✓ Full (measured in WebKit 26.5)
Edge 115+✓ Full
Mobile iOS 26+✓ Full (touch scrolling of the box)
Android Chrome 115+✓ Full

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):

index.html — structure
<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>
Unlock the full code — €9.90/mo excl. tax

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 / propertyDefaultEffect
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

Replace the content of .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).
Same angle, same CSS family, three different objects. fx-0549 spins a 100 px square with an indigo-to-fuchsia gradient and an SVG icon, placed in the middle of an 800 px stage inside a 280 px box, on 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.
Because 720° is two full turns: halfway (450 px) the needle is already back on the starting tick, and at the bottom it is there again — measured 0° at 0, 450 and 900 px, 180° at 225 px. The dial alone therefore cannot tell a quarter from three quarters; that is intended for a motion that closes on itself (a compass finding north again, a knob doing two turns), not for a gauge. For an unambiguous reading, bring the final keyframe down to 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.