/* Dolly — scroll-driven motion, in CSS.
   https://usedolly.dev · MIT

   Three rules govern everything in this file:

   1. NEVER hide content in a base rule. The animation's keyframes own the
      hidden state, never the element. A browser without scroll timelines
      drops `animation-timeline`, falls back to the default 0s duration, and
      `animation-fill-mode: both` lands the element on its `to` state
      immediately — visible. The failure mode is "didn't animate", never
      "didn't appear". This is the bug every scroll library ships.

   2. Longhands only, never the `animation` shorthand. The shorthand resets
      `animation-timeline` to `auto`, which silently converts a scroll-driven
      animation into a time-driven one that fires once on load.

   3. Unknown attribute value = no animation = visible. `animation-name`
      defaults to `none`; only a preset we actually ship turns it on. */

/* ---------- moves ---------- *

   Named for what a camera does, because that is the vocabulary the moves
   already have. `from` only: the `to` state is the element as authored, so
   every keyframe block describes where the shot starts, never where the
   page ends up. */

@keyframes dolly-fade      { from { opacity: 0 } }
@keyframes dolly-tilt-up   { from { opacity: 0; translate: 0 2.5rem } }
@keyframes dolly-tilt-down { from { opacity: 0; translate: 0 -2.5rem } }
@keyframes dolly-pan-left  { from { opacity: 0; translate: 3rem 0 } }
@keyframes dolly-pan-right { from { opacity: 0; translate: -3rem 0 } }
@keyframes dolly-in        { from { opacity: 0; scale: 0.9 } }
@keyframes dolly-out       { from { opacity: 0; scale: 1.1 } }
@keyframes dolly-crane     { from { opacity: 0; translate: 0 5rem; scale: 0.94 } }
@keyframes dolly-rack      { from { opacity: 0; filter: blur(14px) } }
@keyframes dolly-whip      { from { opacity: 0; translate: 4rem 0; filter: blur(8px) } }
@keyframes dolly-reveal    { from { clip-path: inset(0 100% 0 0) } }
@keyframes dolly-wipe-up   { from { clip-path: inset(100% 0 0 0) } }
@keyframes dolly-roll      { from { opacity: 0; rotate: -5deg; scale: 0.96 } }

/* Continuous, not an entrance: these read the whole scrollport. */
@keyframes dolly-progress  { from { scale: 0 1 } to { scale: 1 1 } }
@keyframes dolly-parallax  { from { translate: 0 var(--dolly-depth, 4rem) }
                             to   { translate: 0 calc(-1 * var(--dolly-depth, 4rem)) } }
@keyframes dolly-zoom      { from { scale: 1 } to { scale: var(--dolly-zoom, 1.12) } }

/* ---------- the engine ---------- */

/* Non-inheriting on purpose: a range meant for one beat must not leak to
   its descendants. That was the only real justification for gating these
   behind an attribute, and @property expresses it directly. */
@property --dolly-range { syntax: "*"; inherits: false }
@property --dolly-ease  { syntax: "*"; inherits: false }

[data-dolly] {
  animation-name: none;
  animation-fill-mode: both;
  animation-timing-function: var(--dolly-ease, linear);
  /* view(var(--dolly-axis)) so a horizontal scroller works: the block-axis
     default never makes progress there, leaving entrances stuck invisible. */
  animation-timeline: view(var(--dolly-axis, block));
  animation-range: var(--dolly-range, entry 0% cover 35%);
}

[data-dolly="fade"]      { animation-name: dolly-fade }
[data-dolly="tilt-up"]   { animation-name: dolly-tilt-up }
[data-dolly="tilt-down"] { animation-name: dolly-tilt-down }
[data-dolly="pan-left"]  { animation-name: dolly-pan-left }
[data-dolly="pan-right"] { animation-name: dolly-pan-right }
[data-dolly="dolly-in"]  { animation-name: dolly-in }
[data-dolly="dolly-out"] { animation-name: dolly-out }
[data-dolly="crane"]     { animation-name: dolly-crane }
[data-dolly="rack"]      { animation-name: dolly-rack }
[data-dolly="whip"]      { animation-name: dolly-whip }
[data-dolly="reveal"]    { animation-name: dolly-reveal }
[data-dolly="wipe-up"]   { animation-name: dolly-wipe-up }
[data-dolly="roll"]      { animation-name: dolly-roll }

/* Scroll-linked rather than element-linked: these track the scrollport
   itself, so they run the whole length of the page instead of resolving
   once on entry. */
[data-dolly="progress"] {
  animation-name: dolly-progress;
  animation-timeline: scroll();
  animation-range: var(--dolly-range, normal);
  transform-origin: 0 50%;
}

/* No will-change. It cannot be removed after the animation, so every
   instance holds a compositing layer for the life of the page — and it
   creates a containing block, which silently un-fixes any position:fixed
   descendant. Chrome composites these properties without the hint. */
/* The counterpart to `progress`: that fills a bar, this rides one. A marker
   travelling its own height along the page — a scroll indicator, or the
   truck on Dolly's own logo. */
@keyframes dolly-travel { from { translate: 0 var(--dolly-travel-from, 0) }
                          to   { translate: 0 var(--dolly-travel-to, 100%) } }
[data-dolly="travel"] {
  animation-name: dolly-travel;
  animation-timeline: scroll();
  animation-range: var(--dolly-range, normal);
}

[data-dolly="parallax"] {
  animation-name: dolly-parallax;
  animation-range: var(--dolly-range, cover);
}

/* Ken Burns: a slow push that runs the whole time the element is on screen,
   rather than resolving on entry. Pair with overflow:clip on the parent —
   never overflow:hidden, which would make it its own scrollport. */
[data-dolly="zoom"] {
  animation-name: dolly-zoom;
  animation-range: var(--dolly-range, cover);
}

/* ---------- reduced motion ---------- *

   Not a suppression hack: the element renders exactly as authored, because
   the authored state was never the hidden one. */


/* ---------- the third axis ---------- *

   A dolly move is physically a move through Z — the camera travels toward
   the subject. Doing that with translateZ under a perspective, rather than
   with scale, is not decoration: layered children separate at different
   rates and the frame edges distort the way a lens distorts. Scale cannot
   produce either.

   Everything here uses the INDEPENDENT transform properties. `translate`
   takes a third length and `rotate` takes an axis, both verified to render
   identically to the `transform` shorthand — which matters because the
   shorthand would wipe out an author's own transform.

   <div data-dolly-space>            the camera: perspective lives here
     <figure data-dolly="fly" style="--dolly-z-from:-2200px">…
     <figure data-dolly="fly" style="--dolly-z-from:-1400px">…            */

/* The number that decides cinematic from cheap is perspective ÷ element
   width, not the angle: 2.5-4 reads like a lens, below 1.5 is a fisheye.
   A 320px card wants roughly 900-1300px. Note also that geometry past the
   camera plane is NOT culled — it renders mirrored at enormous scale — so
   keep any z-to below about 0.8 × perspective. */
[data-dolly-space] {
  perspective: var(--dolly-perspective, 1400px);
  perspective-origin: var(--dolly-vanish, 50% 46%);
  transform-style: preserve-3d;
}

@keyframes dolly-push   { from { opacity: 0; translate: 0 0 var(--dolly-z-from, -620px) }
                          to   { opacity: 1; translate: 0 0 var(--dolly-z-to, 0px) } }
@keyframes dolly-pull   { from { opacity: 0; translate: 0 0 var(--dolly-z-from, 380px) }
                          to   { opacity: 1; translate: 0 0 var(--dolly-z-to, 0px) } }
@keyframes dolly-swing  { from { opacity: 0; rotate: y var(--dolly-angle, -24deg);
                                 translate: 0 0 var(--dolly-z-from, -260px) }
                          to   { opacity: 1; rotate: y 0deg; translate: 0 0 0 } }
@keyframes dolly-tumble { from { opacity: 0; rotate: x var(--dolly-angle, 20deg);
                                 translate: 0 0 var(--dolly-z-from, -200px) }
                          to   { opacity: 1; rotate: x 0deg; translate: 0 0 0 } }

/* The camera travels PAST this one. Atmospheric perspective at the far end
   (dim, cool, slightly soft), full presence in the middle, and it fades out
   before it reaches the camera plane — crossing it produces the pop-through
   artifact where a layer inverts through the viewer. */
@keyframes dolly-fly {
  0%   { opacity: 0;
         translate: var(--dolly-x-from, 0) var(--dolly-y-from, 0) var(--dolly-z-from, -2400px) }
  14%  { opacity: 1 }
  76%  { opacity: 1 }
  100% { opacity: 0;
         translate: var(--dolly-x-to, 0) var(--dolly-y-to, 0) var(--dolly-z-to, 520px) }
}

/* Three different camera behaviours, so three acts do not read as one
   idea repeated. `fly` rushes past the sides; `drop` falls through the
   frame; `pass` tracks laterally and REVERSES its yaw as it crosses, which
   is exactly what a shop window does as you walk past it. */
@keyframes dolly-drop {
  0%   { opacity: 0; rotate: x var(--dolly-angle, 24deg);
         translate: 0 var(--dolly-y-from, -52vh) var(--dolly-z-from, -1500px) }
  15%  { opacity: 1 }
  74%  { opacity: 1 }
  100% { opacity: 0; rotate: x calc(var(--dolly-angle, 24deg) * -.6);
         translate: 0 var(--dolly-y-to, 58vh) var(--dolly-z-to, 380px) }
}
@keyframes dolly-pass {
  0%   { opacity: 0; rotate: y var(--dolly-angle, 36deg);
         translate: var(--dolly-x-from, 62vw) 0 var(--dolly-z-from, -500px) }
  16%  { opacity: 1 }
  78%  { opacity: 1 }
  100% { opacity: 0; rotate: y calc(var(--dolly-angle, 36deg) * -1);
         translate: var(--dolly-x-to, -62vw) 0 var(--dolly-z-to, -500px) }
}

[data-dolly="drop"]   { animation-name: dolly-drop;
                        animation-range: var(--dolly-range, contain 0% contain 100%) }
[data-dolly="pass"]   { animation-name: dolly-pass;
                        animation-range: var(--dolly-range, contain 0% contain 100%) }
[data-dolly="push"]   { animation-name: dolly-push }
[data-dolly="pull"]   { animation-name: dolly-pull }
[data-dolly="swing"]  { animation-name: dolly-swing }
[data-dolly="tumble"] { animation-name: dolly-tumble }
/* Lateral divergence is what makes a layer LEAVE through a frame edge
   rather than fade in mid-air. Under a perspective the screen displacement
   is x * P/(P-z), so one shared x delta produces every layer's speed for
   free — the near ones tear past, the far ones crawl. Nobody authors that
   differential; the divide does. */
[data-dolly="fly"]    { animation-name: dolly-fly;
                        animation-range: var(--dolly-range, contain 0% contain 100%) }

/* Depth reads through BLUR and SCALE, never through darkening. A layer
   dimmed toward the background stops being visible as motion at all —
   movement is only perceptible against contrast. Far layers are softer,
   not darker; on a dark ground they are if anything brighter, the way a
   lit subject reads across a dark room.

   The other half of the rule: blur is only free on a layer whose Z is NOT
   moving. A blurred layer that travels in Z must be
   re-rasterised every frame, because its rasterisation scale changes
   continuously; that is the most expensive thing you can do here. So:
   blur the parked layers, and damp the travelling ones with brightness
   alone, which is a compositor-only property. */
[data-dolly-depth="far"]  { filter: blur(6px) brightness(1.05) saturate(.85) }
[data-dolly-depth="mid"]  { filter: blur(2.5px) brightness(1.15) }
/* Near layers stay sharp — blurring them reads as mush, not depth.
   Subordinate to the hero by brightness alone. */
[data-dolly-depth="near"] { filter: brightness(1.3) contrast(1.05) }

/* Decorative depth layers are not content. With no timeline they would
   park at opacity 1 with no translate — a stack of plates piled at dead
   centre, which is exactly the disappearance-by-pile this library says it
   never causes. They are removed instead, leaving the authored page. */
@supports not (animation-timeline: view()) {
  [data-dolly-plate] { display: none }
}
@media (prefers-reduced-motion: reduce), print {
  [data-dolly-plate] { display: none }
}

/* ---------- the states that are NOT the element as authored ---------- *

   Rule 1 at the top of this file holds for the 13 entrances, whose `to`
   state is the element exactly as written. It does NOT hold for the
   continuous and cinematic moves, which carry an explicit `to`. Without
   timeline support the declaration is dropped, duration resolves to 0s,
   and fill-mode:both lands the element on that `to`: zoom renders 12%
   larger forever, iris leaves a permanent circular clip, and track shifts
   a row -60% inside overflow:clip, which deletes it. That is the exact
   failure this library promises never happens, so these turn themselves
   off where there is no timeline to drive them. */
@supports not (animation-timeline: view()) {
  [data-dolly="parallax"], [data-dolly="zoom"], [data-dolly="track"],
  [data-dolly="letter"],   [data-dolly="mask"], [data-dolly="iris"],
  /* beat and beat-in END on opacity 0 — they are enter/hold/LEAVE. With no
     timeline that is where they park, so they hide their own content. */
  [data-dolly="beat"],     [data-dolly="beat-in"],
  /* fly ends past the camera at opacity 0 */
  [data-dolly="fly"], [data-dolly="drop"], [data-dolly="pass"],
  [data-dolly="travel"],
  /* sequence ends on the LAST frame of the strip. With no timeline that is
     where it parks, showing the end of a sequence nobody watched. Off, and
     the authored background-position-x (frame 1) stands. */
  [data-dolly="sequence"] {
    animation-name: none;
  }
}

/* Printing has no scrollport, so an entrance stuck at `from` prints blank. */
@media (prefers-reduced-motion: reduce), print {
  [data-dolly] { animation-name: none !important }
  /* `track` parks at its untranslated state — a row wider than the
     viewport. Inside overflow:clip that content is unreachable, and clip
     makes no scrollbar. The library promises it never hides content, so
     the pin has to release its clip when the motion is off. */
  [data-dolly-pin] { overflow: visible }
}

/* ---------- scenes: pinned, scrubbed sequences ---------- *

   The move that makes scroll libraries look impressive, and the one they
   all need JavaScript for: pin an element to the viewport and let scroll
   scrub a transformation through it. GSAP calls this ScrollTrigger pin.
   Here it is position:sticky plus a named view timeline, and nothing else.

   <div data-dolly-scene>                  tall track, owns the timeline
     <div data-dolly-pin>                  sticks for the scene's length
       <div data-dolly="zoom" data-dolly-on="scene">…</div>
     </div>
   </div>

   Children opt in with data-dolly-on="scene" so an ordinary entry move
   inside a scene still behaves like an entry move. */

[data-dolly-scene] {
  position: relative;
  min-height: var(--dolly-scene, 300vh);
  view-timeline-name: --dolly-scene;
  view-timeline-axis: block;
  /* No view-timeline-inset here. It was tried and reverted: it only
     configures THIS named timeline (not the anonymous view() timelines the
     entrance moves use), its sign inverts for `contain`, and a positive
     inset desyncs the scrub from the pin — the scene starts before the
     element sticks and is still unfinished when it releases. Every staged
     --dolly-range is authored against the pin-synced mapping. */
}

[data-dolly-pin] {
  position: sticky;
  top: 0;
  height: 100svh;
  overflow: clip;   /* clip, never hidden — see the note above */
  display: grid;
  place-items: center;
}

/* `contain` spans exactly the time the track is pinned: from the track's
   top meeting the viewport top to its bottom meeting the viewport bottom. */
/* Scoped to a descendant of an actual scene. Unscoped, a stray or
   copy-pasted data-dolly-on="scene" binds to a named timeline that does
   not exist; an inactive timeline holds the `from` state, so the element
   renders at opacity 0 permanently, in a fully supporting browser, with
   no error anywhere. Scoped, it falls through to the base view(). */
[data-dolly-scene] [data-dolly-on="scene"] {
  animation-timeline: --dolly-scene;
  animation-range: var(--dolly-range, contain 0% contain 100%);
}

/* ---------- beats: enter, hold, leave ---------- *

   An entry move only knows how to arrive. Inside a pinned scene you need
   copy that arrives, stays legible long enough to read, and then leaves so
   the next beat has the stage. That is a three-part keyframe, and it is the
   difference between a sequence and a slideshow. */

@keyframes dolly-beat {
  0%          { opacity: 0; translate: 0 2.2rem }
  16%, 72%    { opacity: 1; translate: 0 0 }
  100%        { opacity: 0; translate: 0 -2.2rem }
}
@keyframes dolly-beat-in {
  0%          { opacity: 0; scale: 1.06; filter: blur(8px) }
  20%, 74%    { opacity: 1; scale: 1;    filter: blur(0) }
  100%        { opacity: 0; scale: .98;  filter: blur(4px) }
}
@keyframes dolly-hold {      /* arrives, then simply stays */
  0%          { opacity: 0; translate: 0 1.6rem }
  22%, 100%   { opacity: 1; translate: 0 0 }
}

[data-dolly="beat"]    { animation-name: dolly-beat }
[data-dolly="beat-in"] { animation-name: dolly-beat-in }
[data-dolly="hold"]    { animation-name: dolly-hold }

/* Scenes deserve weight. Linear is right for a continuous push; a beat
   that arrives should decelerate like something with mass. */
[data-dolly="beat"],
[data-dolly="beat-in"],
[data-dolly="hold"] { animation-timing-function: cubic-bezier(.22,1,.36,1) }

/* ---------- the cinematic three ---------- *

   These three are why the page exists. Each is a shot a film crew would
   name, and each needs no more than a keyframe block.

   iris   — the aperture opening. A circle clip growing from nothing.
   track  — a dolly track: vertical scroll driving horizontal travel.
            Put a row wider than the viewport inside a pinned scene.
   mask   — footage running inside letterforms. The element must set its
            own background-image and background-clip:text; this move
            pushes the picture inside the type. */

@keyframes dolly-iris  { from { clip-path: circle(0% at 50% 50%) }
                         to   { clip-path: circle(85% at 50% 50%) } }
@keyframes dolly-track { from { translate: var(--dolly-track-from, 0) 0 }
                         to   { translate: var(--dolly-track-to, -60%) 0 } }
@keyframes dolly-mask  { from { background-size: var(--dolly-mask-from, 150%) auto }
                         to   { background-size: var(--dolly-mask-to, 210%) auto } }
/* letter-spacing changes the element's WIDTH. On a line that can wrap,
   that reflows mid-scrub — two lines snapping to one — which reads as a
   jerk and is layout work on every frame. Apply this to a `white-space:
   nowrap` line sized to fit at its widest tracking. */
@keyframes dolly-letter{ from { letter-spacing: var(--dolly-letter-from, .12em); opacity: 0 }
                         to   { letter-spacing: var(--dolly-letter-to, -.05em); opacity: 1 } }

[data-dolly="iris"]   { animation-name: dolly-iris }
[data-dolly="track"]  { animation-name: dolly-track;
                        animation-range: var(--dolly-range, contain 0% contain 100%) }
[data-dolly="mask"]   { animation-name: dolly-mask }
[data-dolly="letter"] { animation-name: dolly-letter }

/* ---------- the sprite sequence ---------- *

   background-position percentages resolve against the OVERFLOW (image width
   minus element width), not against pixels. Size the strip to exactly N
   element-widths and 0% is frame 1, 100% is frame N — no frame width to
   hardcode, nothing that breaks at 2x or at a fractional element width.
   `steps(N, jump-none)` emits exactly N values including both ends, so no
   frame is ever caught half-tweened.

   The one move here whose animated property is not compositor-friendly. It
   repaints N times across the scroll, not once a frame, so it stays cheap.
   Strip authoring — size budget, 2x — is in the README. */

@property --dolly-cols { syntax: "<integer>"; inherits: false; initial-value: 6 }
@property --dolly-rows { syntax: "<integer>"; inherits: false; initial-value: 1 }

/* Two stepped animations, one per axis, so the sheet can be a GRID. A single
   row is not merely inconvenient past a dozen frames — the background is
   rendered at FRAMES x element-width, and an animated background that wide
   stops rasterising and paints NOTHING, with no error and no fallback. A 6x6
   grid renders six element-widths across instead of thirty-six.

   jump-none, not jump-end. `steps(n, jump-none)` emits exactly n values,
   0 to 100%, jumping at k/n — so the last cell is the END state and the x
   jumps land on the row changes. jump-end instead returns 1 at t=1, which
   pushes background-position a whole cell PAST the sheet: the element goes
   blank at the end of the scene and, because fill-mode is both, stays blank
   for as long as the act is still on screen. */
@keyframes dolly-sequence-x {
  from { background-position-x: 0% }
  to   { background-position-x: 100% }
}
@keyframes dolly-sequence-y {
  from { background-position-y: 0% }
  to   { background-position-y: 100% }
}

[data-dolly="sequence"] {
  animation-name: dolly-sequence-x, dolly-sequence-y;
  /* stepping IS the move, so this is the one move that does not route
     through --dolly-ease: an eased sprite sheet reads as a stutter. */
  animation-timing-function: steps(var(--dolly-cols), jump-none),
                             steps(var(--dolly-rows), jump-none);
  animation-iteration-count: var(--dolly-rows), 1;
  animation-range: var(--dolly-range, cover 0% cover 100%);
  background-repeat: no-repeat;
  background-size: calc(var(--dolly-cols) * 100%) calc(var(--dolly-rows) * 100%);
}

/* ---------- the two that draw and count ---------- *

   Both are from-only, so the `to` state is the element as authored: with no
   timeline the path lands finished and the number lands real. That is why
   neither belongs in the @supports block — adding one would park `count` at
   zero forever, which is worse than the thing the guard protects against.

   draw  — the path must carry pathLength="1", an SVG attribute with no CSS
           equivalent, which normalises it to length 1 so one dash covers it
           exactly. Without it the path renders dashed, never invisible.
           Never combine it with vector-effect:non-scaling-stroke: that moves
           the dash computation into screen space, where pathLength means
           nothing, and the stroke comes apart into disconnected pieces that
           read as a broken path rather than a broken dash.

   count — counter() is the only way CSS can print a number it computed, and
           the counter must be reset on the ELEMENT, read on ::after. Going
           through a counter rather than reading --dolly-n in the pseudo
           directly is what lets the property stay inherits:false: a non-
           inheriting registered property never reaches a pseudo-element,
           but counters do. Digit grouping and the a11y naming rule are in
           the README. */

@property --dolly-n { syntax: "<integer>"; inherits: false; initial-value: 0 }

@keyframes dolly-draw  { from { stroke-dashoffset: 1 } }
@keyframes dolly-count { from { --dolly-n: var(--dolly-count-from, 0) } }

[data-dolly="draw"] {
  animation-name: dolly-draw;
  /* a draw is meant to be watched, so it scrubs longer than an entrance */
  animation-range: var(--dolly-range, entry 0% cover 60%);
  /* with pathLength="1" one dash spans the path, so dashoffset is the
     fraction still undrawn. Not a hidden state: at the authored dashoffset
     of 0 the path is whole. */
  stroke-dasharray: 1;
}

[data-dolly="count"] {
  animation-name: dolly-count;
  animation-range: var(--dolly-range, entry 0% cover 45%);
  /* the authored state, and therefore the no-timeline state: the real number */
  --dolly-n: var(--dolly-count-to, 100);
  counter-reset: dolly-n var(--dolly-n);
  /* digits are not equal-width in most faces, so a number counting through
     1 and 8 reflows its own line on nearly every tick. */
  font-variant-numeric: tabular-nums;
}
[data-dolly="count"]::after { content: counter(dolly-n) }

/* ---------- riding the page instead of the viewport ---------- *

   Every move defaults to view(): progress is the element's own trip across
   the viewport. `progress` and `travel` opt out of that and read the page
   scroller, because a progress bar that fills as it crosses the viewport is
   not a progress bar. This opts any OTHER move into the same thing — one
   long draw down the side of the page, a parallax keyed to the document
   rather than to its own box.

     <path data-dolly="draw" data-dolly-on="page" pathLength="1" d="…">

   `normal` on a scroll timeline is the full length of the scroller, which
   is what "top to bottom" means here. entry/exit/cover/contain are view-
   timeline phases and mean nothing on this timeline, so a --dolly-range
   copied off an entrance will not do what it says.

   PLACEMENT IS LOAD-BEARING: this carries the same specificity as a move
   rule, so it only wins by being declared after every one of them. Moving
   it above a move takes that move's range back, silently. There is a guard
   for exactly this — it is the third time this file has needed one. */

[data-dolly-on="page"] {
  animation-timeline: scroll();
  animation-range: var(--dolly-range, normal);
}

/* ---------- stagger ---------- *

   The single most-requested scroll feature, and the one gap worth closing:
   a row of cards all cross `entry 0%` at the same instant and fire as one
   block. Offsetting each child's range along the SAME timeline cascades
   them. Three curves, because "which one arrives first" is a design
   decision: from the start, from the centre outward, or from both edges in.

     <div data-dolly-stagger="center" style="--dolly-step:5%">
       <div data-dolly="tilt-up">…                                        */

[data-dolly-stagger] > [data-dolly] {
  /* still routed through --dolly-range, so a per-element override inside a
     stagger container keeps working rather than silently doing nothing */
  animation-range: var(--dolly-range,
    entry calc(var(--dolly-i, 0) * var(--dolly-step, 6%))
    cover calc(35% + var(--dolly-i, 0) * var(--dolly-step, 6%)));
}
[data-dolly-stagger] > :nth-child(1) { --dolly-i: 0 }
[data-dolly-stagger] > :nth-child(2) { --dolly-i: 1 }
[data-dolly-stagger] > :nth-child(3) { --dolly-i: 2 }
[data-dolly-stagger] > :nth-child(4) { --dolly-i: 3 }
[data-dolly-stagger] > :nth-child(5) { --dolly-i: 4 }
[data-dolly-stagger] > :nth-child(6) { --dolly-i: 5 }
[data-dolly-stagger] > :nth-child(7) { --dolly-i: 6 }
[data-dolly-stagger] > :nth-child(8) { --dolly-i: 7 }
[data-dolly-stagger] > :nth-child(n+9) { --dolly-i: 8 }

/* centre outward: 4 3 2 1 0 1 2 3 */
[data-dolly-stagger="center"] > :nth-child(1),
[data-dolly-stagger="center"] > :nth-last-child(1) { --dolly-i: 3 }
[data-dolly-stagger="center"] > :nth-child(2),
[data-dolly-stagger="center"] > :nth-last-child(2) { --dolly-i: 2 }
[data-dolly-stagger="center"] > :nth-child(3),
[data-dolly-stagger="center"] > :nth-last-child(3) { --dolly-i: 1 }
[data-dolly-stagger="center"] > :nth-child(4),
[data-dolly-stagger="center"] > :nth-last-child(4) { --dolly-i: 0 }

/* edges in: the outermost pair lands last */
[data-dolly-stagger="edges"] > :nth-child(4),
[data-dolly-stagger="edges"] > :nth-last-child(4) { --dolly-i: 3 }
[data-dolly-stagger="edges"] > :nth-child(3),
[data-dolly-stagger="edges"] > :nth-last-child(3) { --dolly-i: 2 }
[data-dolly-stagger="edges"] > :nth-child(2),
[data-dolly-stagger="edges"] > :nth-last-child(2) { --dolly-i: 1 }
[data-dolly-stagger="edges"] > :nth-child(1),
[data-dolly-stagger="edges"] > :nth-last-child(1) { --dolly-i: 0 }

/* ---------- named easings ---------- *

   One bezier and an escape hatch is not a vocabulary. These are the curves
   worth naming; anything else still goes through --dolly-ease directly. */

[data-dolly-ease="out"]       { --dolly-ease: cubic-bezier(.22,1,.36,1) }
[data-dolly-ease="out-back"]  { --dolly-ease: cubic-bezier(.34,1.56,.64,1) }
[data-dolly-ease="out-expo"]  { --dolly-ease: cubic-bezier(.16,1,.3,1) }
[data-dolly-ease="in-out"]    { --dolly-ease: cubic-bezier(.65,0,.35,1) }
[data-dolly-ease="linear"]    { --dolly-ease: linear }

/* ---------- authoring knobs ---------- *

   One property, not a property plus an attribute. Every default above is
   written `var(--dolly-range, <default>)`, so setting the custom property
   is sufficient and nothing depends on source order any more — which is
   what the two deleted rules here used to require, and what made adding a
   move below them silently disable the knob.

     style="--dolly-range: contain 10% contain 38%"
     style="--dolly-ease: linear"
     style="--dolly-axis: inline"      (inside a horizontal scroller)

   Caveat worth knowing: an INVALID value is invalid-at-computed-value-time
   and resolves to `normal`, never to the default written beside it. */
