Scroll✨ Premium

Rotate In

Six SVG-icon tiles in a 2-column grid inside a 280 px box: each starts at opacity: 0, rotate(-15deg) scale(.8) and settles (1, 0°, 1) over the first 46 pixels of its entry from the bottom — animation-timeline: view(), animation-range: entry entry 50%, 2.3 KB of CSS, zero JavaScript. The two tiles of a row settle together: the 40 to 200 ms delays written to stagger them are inert on a scroll timeline (measured).

CSSRotateStagger

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

Badges earned by a student — language-learning app — Rotate In example 1

① Badges earned by a student — language-learning app

When“My badges” screen of a mobile app: header with the badge count and the current streak, then the grid of rewards — 7-day streak, 100 words, first dialogue, sharp ear… — each with its pictogram and date, scrolled with the thumb; the last three badges, not yet earned, are greyed out.
WhyA badge is an object you pin: the −15° → 0 rotation combined with the .8 → 1 scale mimics pinning a button onto a board. The neighbour fx-0552 just lifts the cells 30 px with a fade, which says nothing about the reward.
Settings236 × 330 px screen, 216 px .sdc-scroll-container box under the header, stage at height: auto (the sold 900 px replaced by the content), gap: 8px, ten tiles of 102 × 89 px (30 px icon, 10.5 px title, 9.5 px date) on a light theme: #fffaf0 background, #f1e3c9 border, #ea580c icon, #1c1917 title, unearned badges in grey with a dashed border. The animation rule is the code's: a tile settles in 44.5 px. At rest, row 3 is 11 px in: −11.3°, opacity .25; 287 px of travel. The grid scrolls on its own at 85 px/s until the first gesture — a capture device, not the effect.
Services of a leisure park — touch welcome kiosk — Rotate In example 2

② Services of a leisure park — touch welcome kiosk

WhenHome screen of a kiosk: on the left “Where to find?” and the instruction, on the right the grid of services — car park, food, first aid, lockers, strollers, lost & found, restrooms, shop, info point, shuttle — with pictograms, that the visitor scrolls with a finger.
WhyOn a kiosk viewed from a metre away, a 150 × 99 px tile must move decisively to be noticed: the rotation combined with the scale is visible; fx-0535's fade-up travels with the scroll, so from a distance the tile merely seems to arrive a little faster.
Settings340 × 360 px box (the kiosk's full height), gap: 12px, ten tiles of 150 × 99 px — 40 px #2dd4bf icon at stroke-width: 1.6, 13.5 px title, white background at 6%, border at 10%, radius 16 — on a #07111f background; a tile settles in 49.5 px. At rest, three rows are settled and the fourth, 18 px in, sits at −9.7° and .35; 209 px of travel: the list scrolls on its own at 80 px/s, pauses at the bottom and loops until the first gesture.
Pictograms of a municipal pool's rules — rules page of the town website — Rotate In example 3

③ Pictograms of a municipal pool's rules — rules page of the town website

When“Pool rules” page of a town website: site bar, title, lead, then the rules — shower required, swim cap required, no diving, no running, no food, no glass, children supervised, swim lanes — as pictogram + label tiles, two per line, obligations in blue, prohibitions circled and struck through in red.
WhyA pool rule is a sign fixed to the wall: the tile arrives askew (−15°) and slightly small (.8), then straightens to 0° as it reaches full size — the gesture of a sign being set true; on the prohibitions, the circle and its red bar pivot with the tile, which carries the eye to what is forbidden. A rise (fx-0535) brings the tile in like a list row, straightening nothing; a scale-up (fx-0566) enlarges the circle as is, like a photo being framed.
SettingsLight theme: white tiles, #d3e2ee border, #0f3554 12.5 px title, 36 px #0369a1 icon (prohibitions: #dc2626), 496 × 230 px box, eight tiles of 240 × 92 px, gap: 12px; a tile settles in 46 px. At rest, two lines are settled and the third, 16 px in, sits at −9.8° and .34; 195 px of travel, automatic scrolling at 75 px/s until the first gesture.

How it works

2.3 KB of CSS, 2.2 KB of HTML, an empty JavaScript field. The frame .demo-preview (centered flex, min-height: 300px, position: relative, #0a0a0f background, white text, box-sizing: border-box for everything inside it) wraps the scrolling box .sdc-scroll-container — 100% × 280px, overflow-y: auto, scroll-behavior: smooth, a 4 px scrollbar via ::-webkit-scrollbar — and the hint .sdc-scroll-hint “Scrollez ↓” (absolute, 8 px from the bottom of the frame, 10.4 px, white at 30%, pulsing .3 ↔ .7 over 2 s). Inside the box, the stage .sb-rotate-content: 900 px tall, padding: 40px 12px, 2-column grid, gap: 12px. Six .sb-rotate-card tiles (padding: 16px, 12 px radius, white background at 4%, white border at 8%), each with a 32 px svg (stroke: #8b5cf6, width 1.5) and a 12.8 px h4, weight 700, white. Measured: tiles of 614 × 93 px at 1,280 px wide, 169 × 93 at 390; rows at 40, 145 and 250 px from the top of the stage; 620 px of travel. No id, no site variable, no console error; pasted twice, 12 tiles work. One leftover: an inline style="font-family: Inter, …" on every h4 — Inter is not loaded, the title falls back to the system font.

The mechanism is a single rule. Base state: opacity: 0; transform: rotate(-15deg) scale(.8). The sbRotateIn animation has one keyframe, to { opacity: 1; transform: rotate(0deg) scale(1) }, and is driven not by time but by position: animation-timeline: view() ties its progress to where the tile sits in the nearest scroll container — here .sdc-scroll-container; without it, the page (measured). animation-range: entry entry 50% bounds that progress: 0 when the tile's top edge meets the bottom edge of the box, 1 when it is halfway in — 46.5 px for a 93 px tile, linear, with animation-duration: auto (the range is the duration) and animation-fill-mode: both (base state before, final frame after). Measured with the stage pushed below the fold: 20 px after entry, opacity .43, −8.55°, scale .886; 40 px: .86, −2.1°, .972; 46.5 px: settled. It is a progress, not a trigger: a tile scrolled back below its half-height tilts again. The animation-delays of 40 to 200 ms set by :nth-child(2) to (6) are inert: the two tiles of a row show the same values at every position (.645 and .645 for tiles 5 and 6 at rest) — a delay in milliseconds has no meaning on a timeline graduated in pixels. The “Stagger” tag is not honored; the stagger is the order of the rows.

The sold demo is bounded by its geometry: three 93 px rows starting at 40 px in a 280 px box. Rows 1 and 2 are fully inside at rest, hence settled; row 3 is already 64.5% through its window (opacity .645, −5.32°, scale .929) and finishes settling at 17 px of scrolling; 603 px of empty stage remain. The effect is intact, the demo hides it (see the FAQ). Two details: scroll-behavior: smooth only affects programmatic scrolls (scrollTop = 620 reads back 0 and lands in 392 ms); a tile at −15° scaled to .8 stays inside its column (494 px bounding box for a 614 px tile), no horizontal scrollbar appears.

Accessibility

  • prefers-reduced-motion not in the code: measured under emulation, nothing changes (row 3 at .645 at rest, settled in 17 px, pulsing hint). The motion is driven by the reader — an interaction-triggered animation (WCAG 2.3.3); to neutralize it: @media (prefers-reduced-motion: reduce) { .sb-rotate-card { animation: none; opacity: 1; transform: none } .sdc-scroll-container { scroll-behavior: auto } .sdc-scroll-hint { animation: none } }. opacity and transform are essential: animation: none alone leaves the tile invisible and tilted.
  • Keyboard: measured, Chromium 151 and Firefox 153 focus the box on Tab without a tabindex (Arrow Down: 40 and 51 px; Page Down: 285 and 303); WebKit 26 does not — focus stays on body. Add tabindex="0", an aria-label and a :focus-visible style on .sdc-scroll-container. With a finger, the gesture must start inside the box.
  • Contrast: white title on the tile (#0a0a0f + 4% white) 18.4:1; #8b5cf6 icon 4.3:1 (the 3:1 threshold for graphics, passed). Opacity drops to 0 during the entry — that is the point — but a tile stuck mid-entry at the bottom of a short box stays pale (the demo's row 3, at .645, until the first notch). The hint pulses between 1.2:1 and 1.8:1: decorative, put aria-hidden="true" on it.
  • Screen readers and fallback: a tile at opacity: 0 stays in the accessibility tree — the six titles are read from load, nothing is hidden. Six h4s with no h2/h3 above break the outline: use the level that follows your current heading. The svgs have neither title nor aria-hidden: add aria-hidden="true". “Scrollez ↓” is hard-coded in French. Where view() does not exist, the animation lasts 0 s and fill-mode: both applies the final frame: everything is visible, flat (measured in Firefox).

Browser compatibility

CSS only: animation-timeline: view(), animation-range and animation-duration: auto — the three arrive together in Chrome and Edge 115 and in Safari 26 (iOS 26). Firefox: no version (the module exists behind layout.css.scroll-driven-animations.enabled, off by default). The rest is classic: grid, transform, @keyframes, overflow-y, scroll-behavior. Zero dependencies, zero JavaScript, no CSS variable.

Chrome 115+✓ Full
Firefox (all versions)✓ Flat, readable tiles — no animation
Safari 26+✓ Full (before: flat tiles)
Edge 115+✓ Full
Mobile iOS 26+✓ Full (before: flat tiles)
Android Chrome 115+✓ Full

Measured in Firefox 153, sold code, blank page: CSS.supports('animation-timeline: view()') is false, animation-duration: auto is rejected and computes to 0s, the timeline falls back to the document clock — a 0 s animation with animation-fill-mode: both applies its final frame at once. The six tiles are at opacity: 1, rotate(0) scale(1), 94 px tall, from load and at every position: the Firefox visitor sees the grid settled, flat, readable, in a 280 px box scrolling over 620 px with the native scrollbar; no rotation, no fade. Same in Safari 18 and Chrome 114. The code has no @supports fallback: this result rests on the 0s + both pair. To make it explicit: @supports not (animation-timeline: view()) { .sb-rotate-card { animation: none; opacity: 1; transform: none } }. Without the .demo-preview frame, the white titles become invisible on a light page: keep it or give the tiles a background.

The code

HTML structure to paste into your page (the CSS + JS come with Premium):

index.html — structure
<div class="demo-preview">
  <div class="sdc-scroll-container">
    <div class="sb-rotate-content">
      <div class="sb-rotate-card">
        <svg viewBox="0 0 24 24">…</svg>
        <h4>…</h4>
      </div>
      … × 6
    </div>
  </div>
  <span class="sdc-scroll-hint">…</span>
</div>
Unlock the full code — €9.90/mo excl. tax

The full HTML + CSS + JS of Rotate In, ready to paste — plus all 811 effects. Or €299 once, lifetime access.

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
Starting tilt and scale (CSS — .sb-rotate-card { transform: rotate(-15deg) scale(.8) }) −15° / .8 The final frame is rotate(0deg) scale(1): only the start is adjustable. rotate(-6deg) scale(.92) settles discreetly, rotate(15deg) turns the other way, a translateY(20px) added to both states lifts as well. transform-origin: 0 100% makes it pivot like a page being turned.
Entry window (CSS — animation-range: entry entry 50%) from 0 to 50% of the entry (46.5 px for a 93 px tile) Graduated on the tile's height: entry entry 100% settles the tile once fully in (93 px of travel), entry 20% entry 70% makes it wait before moving, entry 0% cover 30% continues to 30% of the way through the box.
Curve (CSS — animation-timing-function: linear) linear The curve applies to the scroll progress: ease-out settles fast then brakes, cubic-bezier(.34,1.56,.64,1) overshoots 0° then comes back — a small bounce. Only linear follows the finger pixel for pixel.
Scroll container (CSS — .sdc-scroll-container { height: 280px }, .sb-rotate-content { height: 900px }) 280 px box, 900 px stage (620 of travel) view() attaches to the nearest scroll container: without the box, the page takes over (measured). Set the stage to height: auto so the travel follows the content — the sold 900 px leave 603 px of void. An ancestor with overflow: hidden is also a scroll container for view(); prefer overflow: clip.
Grid (CSS — grid-template-columns: repeat(2, 1fr), gap: 12px, padding: 40px 12px) 2 columns, 12 px, tiles of 614 × 93 px at 1,280 px wide The number of columns decides how many tiles settle as a block (three columns, three tiles), not the delays. The padding-top sets how many rows are already settled on load: 40 px leave two in a 280 px box.
Tile (CSS — .sb-rotate-card, svg, h4) 16 px padding, radius 12, white background 4%, border 8%; 32 px #8b5cf6 icon (1.5); 12.8 px 700 white title The icon is an inline stroke SVG (stroke="currentColor" in the HTML, overridden by the CSS stroke): any 24 × 24 stroke icon drops in. On a light background, give the tile an opaque background and border and the title a dark colour — the sold values are designed for #0a0a0f. Remove the inline style on the h4s to inherit your font.
:nth-child delays (CSS — animation-delay: 40ms … 200ms) 40, 80, 120, 160, 200 ms — no effect Measured inert under view(). Delete them, or shift the second column's range for a real offset within the row: .sb-rotate-card:nth-child(2n) { animation-range: entry 15% entry 65% } — measured, at 40 px of entry the first tile is at .43 / −8.5°, the second at .13 / −13°; both are settled at 80 px.
Smooth scrolling and hint (CSS — scroll-behavior: smooth, .sdc-scroll-hint) smooth / “Scrollez ↓” pulsing 2 s smooth only eases positions set by script or anchor (measured: scrollTop = 620 lands in 392 ms); switch to auto if a button drives the box. The hint anchors to the .demo-preview frame: without that frame it drops to the bottom of the page; delete the span and sdcHintPulse with it.

FAQ

Because almost all of its six tiles fit inside the box at rest. Three 93 px rows starting at 40 px in a 280 px box: rows 1 and 2 are fully in, hence settled, and row 3 is already 30 px into its 46.5 px window — 64.5%, measured: opacity .645, −5.32°, scale .929. The first notch finishes it (everything is settled at 17 px) and the remaining 603 px only scroll void. The effect is intact, the demo's geometry hides it: measured with the stage pushed 300 px below the fold, row 1 starts from 0 / −15° / .8 and settles in 46.5 px, then row 2, then row 3. Three fixes, your choice: more tiles (eight or ten, like the scenes on this page), a shorter box, or height: auto on the stage and a padding-top that pushes the grid below the fold. The neighbour fx-0552 has the same geometry and the same limit.
The skeleton is the same: 280 px box, 900 px stage, 2-column grid, six elements, animation-timeline: view(), animation-duration: auto, fill-mode: both, and animation-delays per :nth-child — 60 to 300 ms there, 40 to 200 here — just as inert. Three differences. The motion: fx-0552 animates translateY(30px) → 0 with the opacity (sbFadeUp), a lift; fx-0542 animates rotate(-15deg) scale(.8) → rotate(0) scale(1), a settle. The window: entry entry 40% there, entry entry 50% here — 37 px of travel against 46.5 for a 93 px tile. The content: text cells “Item 1” to “Item 6” against SVG-icon + title tiles. Pick Rotate In when the tile is an object — badge, pictogram, thumbnail — whose settling should be seen at 120 px; Stagger Reveal when it is a list row that simply has to arrive. Neither really staggers the two elements of a row; for that, shift the second column's range (see “Customize”).
Yes: view() takes the nearest scroll container, and without .sdc-scroll-container that is the window. Measured, grid placed 600 px below the top of a 400 px window: the tiles stay at 0 / −15° / .8 below the fold, then each settles in 46.5 px as the page scrolls down. Keep the .sb-rotate-card rule and the @keyframes, drop the box and the stage's 900 px, and check that no ancestor has overflow: hidden — it would become the reference container; overflow: clip has no such effect. As for the re-tilt: the animation is a progress tied to position, not a trigger. When a tile exits through the bottom below its half-height, its progress goes back down and it tilts again (measured on returning to the top of the demo: row 3 goes back to .645). That is how view() behaves, not a bug; for a permanent settle you need a script — an IntersectionObserver that sets a class — which this effect does not have.