Specimen 01 · Scroll choreography

Motion that runs on no library at all.

A working specimen of a scroll-reveal system built on one IntersectionObserver and CSS transitions. Every card below carries a live readout of the observer's actual state and the exact declarations driving it. Scroll up as well as down — the reveals replay, because the observer is never disconnected.

Runtime cost 0 KB Dependencies none Observers 1, shared Reduced motion honoured

Your system is set to reduced motion, so every transition here is held at its final state. That is the system working, not the demo failing.

01

Rise

out of view

The base reveal. Opacity and a 64px vertical offset, driven by the standalone translate property rather than transform, so it composes with a hover transform instead of fighting it.

translate: 0 64px → 0 0  /  opacity: 0 → 1
.item      { opacity: 0; translate: 0 64px }
.item.in   { opacity: 1; translate: 0 0 }
transition 0.6s cubic-bezier(.25,.46,.45,.94)
02

Lateral

out of view

Opposed horizontal entries for split sections. The page root needs overflow-x: clip — on the root element, not the body, since the viewport scrollbar is driven by the root.

translate: -72px 0
translate: 72px 0
html       { overflow-x: clip }
.left      { translate: -72px 0 }
.right     { translate:  72px 0 }
03

Scale

out of view

A 0.92 start, for panels that should settle rather than travel. Again a standalone property, so a card can still lift on hover.

scale: 0.92 → 1
.panel     { scale: 0.92 }
.panel.in  { scale: 1 }
04

Cascade

out of view

Children stagger from the parent's single state change, so a twenty-card grid still costs one observer entry. Delay is pure CSS, by nth-child.

0ms
70ms
140ms
210ms
.grid.in > *            { opacity: 1; translate: 0 0 }
.grid > *:nth-child(2)  { transition-delay: 70ms }
05

Hinge

out of view

A 3D entrance hinged on the bottom edge. The perspective sits on the container, never the card: on the card it would require transform, and the revealed state would then outrank :hover and kill the lift permanently.

rotate: x -55deg → x 0deg  /  transform-origin: 50% 100%
.stage     { perspective: 2000px }
.card      { rotate: x -55deg; transform-origin: 50% 100% }
.card.in   { rotate: x 0deg }
06

Trigger geometry

The part that is routinely got backwards. A negative bottom rootMargin shrinks the observer's root, so the element must travel 120px past the viewport edge before it counts as visible. A positive value fires early and plays a third of the motion off-screen, where nobody sees it.

viewport observer root edge rootMargin bottom: -120px element fires here dead band — ignored
new IntersectionObserver(cb, {
  threshold: 0,
  rootMargin: "0px 0px -120px 0px"
})
07

Specification

The whole system, as shipped.

ParameterValueWhy
Entrance duration0.6sLong enough to read as deliberate, short enough not to gate scrolling.
Cascade step70msReads as one gesture; above ~120ms the grid reads as separate events.
Easingcubic-bezier(.25,.46,.45,.94)Decelerating. Things arriving should slow into place.
Trigger inset-120pxMeasured, not guessed. Keeps the full motion on screen.
Observer lifetimepersistentNever disconnected, so reveals replay on scroll-up.
Animated propertiesopacity, translate, scale, rotateCompositor-friendly, and free of transform so hover states still work.
Reduced motionhonouredResting states are never applied; content renders complete.