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).
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
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 } }.opacityandtransformare essential:animation: nonealone 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 onbody. Addtabindex="0", anaria-labeland a:focus-visiblestyle 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;#8b5cf6icon 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, putaria-hidden="true"on it. - Screen readers and fallback: a tile at
opacity: 0stays in the accessibility tree — the six titles are read from load, nothing is hidden. Sixh4s with noh2/h3above break the outline: use the level that follows your current heading. Thesvgs have neithertitlenoraria-hidden: addaria-hidden="true". “Scrollez ↓” is hard-coded in French. Whereview()does not exist, the animation lasts 0 s andfill-mode: bothapplies 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.
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):
<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>
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 / property | Default | Effect |
|---|---|---|
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
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.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”).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.