SPEC-105
Setting up your dashboard 0 entities found · 8/33 branches scanned
ID:SPEC-105Status:draft

Scroll-reveal motion — a token-driven entrance dimension

refrakt can compose elaborate backdrops (bg, substrate, cover) but has no vocabulary for choreography — how a section arrives. Add a motion dimension: a small, closed, author-facing reveal vocabulary that declares the intent of an entrance (a section fades in; feature items slide in as they scroll into view), with the theme owning the choreography and an IntersectionObserver behaviour owning the timing trigger. Motion is opt-in, token-driven, and enhancement-gated so the static page is always complete.

Target: next minor.

Branches 2

Motivation

You can build a live three.js backdrop, but you can't fade a hero in or stagger a feature grid as it scrolls into view. The pieces to do it coherently already exist:

  • The dimensions/ system — refrakt already models cross-cutting, token-driven, data-*-keyed concerns as "dimensions" (packages/lumina/styles/dimensions/state.css toggles open/closed/active off data-state). Motion is a new dimension, not a per-rune feature.
  • The reduced-motion baseline (WORK-352)global.css already neutralises all animation/transition durations to 0.01ms under prefers-reduced-motion (not none, so transitionend/animationend still fire for awaiting JS). New motion inherits this guard for free.
  • The progressive-enhancement behaviours — tabs/accordion/datatable/form already establish the framework-agnostic JS home; a reveal observer joins them.

Principle

The division that keeps motion from leaking author concerns into theme concerns:

  • Author declares intentreveal="fade", stagger — a coarse, per-section character. Never how far, how fast, or which part moves.
  • Theme owns choreography — what moves, in what order, distance, easing, sync vs offset — expressed in CSS over the rune's named BEM anatomy (.rf-feature__content, __media, …) it already styles. Two themes render the same reveal="fade" differently (whole-section in sync vs content/media slightly offset) with no author change.
  • The behaviour owns when — an IntersectionObserver flips one data-in-view state on the container. Theme-agnostic; JS = when, CSS = how.
  • The shared vocabulary is the contract — a closed named set with a documented character per value; themes vary the choreography but must preserve the character (a fade reads as a fade everywhere).

Design

1. The reveal vocabulary (closed, author-facing)

A bounded matches set on the reveal modifier — an unknown value is a build error, keeping the author↔theme contract enforceable.

ValueCharacter the theme must preserveTheme is free to vary
noneNo entrance (the default / opt-out).
fadeOpacity-led: 0 → 1.Subtle movement, per-part sync/offset, duration/easing
slideMovement-led: translation into place + opacity.Axis, direction, distance (often layout-derived), easing, offset
scaleScale-led: slightly <1 → 1 + opacity.Start scale, transform-origin, easing, offset
blurFocus-led: blur → sharp + opacity.Blur amount, easing; may downgrade to fade under perf/reduced constraints
  • Direction is deliberately not in the vocabulary. slide means "enters from a theme-chosen offset"; the theme derives the axis (often from the rune's layout — media from its side, content from its side, reading-direction aware). A future optional logical reveal-from="bottom|top|start|end" hint (theme may honour or override) is a v2 escalation, not v1.
  • blur carries a documented downgrade — it animates filter (not a compositor-cheap property), so a theme or a low-power/reduced path may render it as fade.

2. stagger — an orthogonal timing modifier

stagger is not a character; it composes with any value (reveal="slide" stagger) and governs whether a container's children arrive together or in a cascade.

  • The engine stamps --rf-reveal-index (0,1,2,…) on each child; the theme turns it into a per-child delay against a stagger-interval token (animation-delay: calc(var(--rf-reveal-index) * var(--rf-reveal-stagger))), measured from the container's single in-view trigger (not per-child observation — see Non-goals).
  • Multi-child only — meaningful on feature/bento/steps/pricing/playlist tracks; a silent no-op on single-child runes (hero). The theme owns the rhythm and order (a grid theme may read the index as a diagonal/column sweep, not strict source order).

3. Layer 1 — engine config (the intent → data attributes)

A shared reveal/stagger modifier on section-level runes (hero, feature, bento, cta, card, steps, pricing, testimonial, playlist, …) — a cross-cutting facet like media-position/tint, configured once, not per rune. It emits:

  • data-reveal="<value>" on the rune root,
  • data-stagger when set,
  • --rf-reveal-index on each enumerated child (the engine already enumerates children for numbered sequences, so index assignment is free).

No new structure is required: the theme choreographs over the named anatomy the engine already emits, so enabling theme choreography costs only these attributes.

4. Layer 2 — the motion dimension CSS (the choreography)

A new dimensions/motion.css (+ motion tokens in tokens/) defines, per character, the keyframes/transitions keyed on data-reveal × data-in-view, plus the stagger delay. The theme owns the physics tokens — duration, easing, travel distance, scale start, stagger interval — so a "calm" theme and a "punchy" theme differ purely by retuning tokens, like spacing/radius scales. Choreography across named parts (__content/__media offset) lives here too.

One global stylesheet, not per-rune edits. The dimension covers every section rune without touching any rune's own CSS, because it keys on generic hooks, not structure:

  • Root entrance keys on [data-reveal][data-in-view] on the rune root — no knowledge of internals, so fade/slide/scale/blur work for all section runes from one rule.
  • Stagger targets the index marker the engine stamps[data-stagger][data-in-view] [style*="--rf-reveal-index"]not a structural > *. The "which children cascade" decision lives in each rune's config (the child enumeration that stamps the index), so the CSS never needs per-rune wrapper-depth knowledge.
  • Transform composition (the critical rule). Lumina already uses transform in ~48 files (hover-lifts on card/cta/feature/pricing, frame displacement, drawer/nav slides). Reveal therefore animates the individual translate/scale properties, never the transform shorthand — so a reveal translate composes with an existing transform: scale(…) hover instead of clobbering it. This is what lets the dimension stay global rather than auditing and patching every rune that already transforms.
  • Per-part choreography (__content/__media offset) is opt-in theme polish on top of the working global default — never a per-rune requirement.

5. Layer 3 — the reveal behaviour (the trigger)

A tiny IntersectionObserver behaviour (joining tabs/accordion/datatable/form):

  • Observes each [data-reveal]:not([data-reveal="none"]) container; on first intersection it sets data-in-view and unobserves it.
  • Honours matchMedia('(prefers-reduced-motion: reduce)') — marks everything in-view immediately (belt-and-braces with the global reset).
  • Theme-agnostic: it sets one state attribute and nothing else.

6. Enhancement gating — the static page is always complete

The cardinal rule (and the classic scroll-reveal footgun avoided): SSR / no-JS / crawler / reduced-motion render the fully visible final state. Never bake opacity: 0 into SSR HTML that only JS removes.

  • The hide-then-reveal CSS is scoped under a root data-animate flag the behaviour adds on boot. No flag (no JS) → every section fully rendered, no motion. With the flag, [data-reveal]:not([data-in-view]) holds the pre-entrance state until the observer fires.
  • Reuses the WORK-352 global reduced-motion reset; animates compositor-only properties (opacity/transform), blur being the documented exception that may downgrade.

7. Defaults

reveal defaults to none — no surprise motion; the author opts a section in. A theme may set per-rune defaults via config (e.g. herofade), but the safe baseline is opt-in.

Acceptance Criteria

  • A closed reveal modifier (none|fade|slide|scale|blur, default none, unknown = build error) is available on section-level runes as a shared engine facet; it emits data-reveal on the root and --rf-reveal-index on enumerated children.
  • stagger is an orthogonal modifier composing with any character; it emits data-stagger, is a silent no-op on single-child runes, and drives a per-child delay from --rf-reveal-index against a theme stagger-interval token.
  • A dimensions/motion.css + motion tokens define each character keyed on data-reveal × data-in-view; the theme owns duration/easing/distance/scale/stagger as tokens, and may choreograph across a rune's named parts (sync or offset) without author input.
  • Global coverage, not per-rune: the dimension covers all section runes from one stylesheet — root entrance keys on [data-reveal][data-in-view], stagger on the engine's --rf-reveal-index marker (not a structural > *); no rune's own CSS file gains a motion block for the baseline (per-part choreography is opt-in).
  • Transform composition: reveal animates the individual translate/scale properties, never the transform shorthand, so it composes with the ~48 Lumina files that already use transform (hover-lifts, frame displacement, drawer/nav) instead of clobbering them; verified against at least one hover-transform rune (card/cta).
  • An IntersectionObserver reveal behaviour sets data-in-view on first intersection and unobserves; under prefers-reduced-motion it marks all in-view immediately.
  • Enhancement gating: SSR/no-JS/crawler render the fully visible final state (no opacity:0 baked in); the pre-entrance state is scoped under a root data-animate flag the behaviour adds on boot; the WORK-352 reduced-motion reset still neutralises motion.
  • blur documents (and a theme may apply) a downgrade to fade; all other characters animate compositor-only properties.
  • Docs: a theme-authoring motion dimension page (the vocabulary, the character contract, the physics tokens, choreography over named parts) and an author-facing reveal/stagger reference with a feature-stagger example; contracts regenerated and CSS coverage passes for the new selectors.

Non-goals

  • A timeline / keyframe authoring system in Markdown, or raw inline CSS animation — the author surface is the closed vocabulary + stagger, nothing finer.
  • Animate-by-default — opt-in only (theme may set per-rune defaults).
  • Direction in the vocabularyslide is theme/layout-derived; an optional reveal-from hint is a deferred v2 escalation.
  • Per-part scroll triggers — one container-level trigger + CSS delay covers "slightly out of sync"; parts triggering at genuinely different scroll positions is deferred.
  • CSS scroll-driven animation (animation-timeline: view()) — IO is the v1 baseline (universal, controllable); scroll-driven CSS is a later zero-JS progressive upgrade where supported.
  • Page/route transitions, parallax, scroll-linked continuous animation — this spec is discrete entrance reveals only.

Work breakdown (provisional)

  1. Engine facet (§3) — shared reveal/stagger modifier, data-reveal/data-stagger, --rf-reveal-index child enumeration; matches validation.
  2. Motion dimension CSS + tokens (§4) — dimensions/motion.css, per-character keyframes/transitions, motion tokens, stagger delay; global hooks only (generic attrs + --rf-reveal-index marker) and individual translate/scale properties so it composes with existing rune transforms — no per-rune motion CSS.
  3. reveal behaviour + enhancement gating (§5–§6) — IO observer, data-in-view, root data-animate boot flag, reduced-motion path.
  4. Docs + showcase (§7) — theme-authoring motion-dimension page, author reveal reference, a feature-stagger demo.

References

  • The dimension precedent (data-state toggled by behaviours, read by the theme): packages/lumina/styles/dimensions/state.css; theme-authoring dimensions doc site/content/extend/theme-authoring/dimensions.md.
  • Reduced-motion baseline this builds on: WORK-352; packages/lumina/styles/global.css.
  • Behaviour-package home + progressive-enhancement pattern: packages/behaviors/src/ (tabs/accordion/datatable/form).
  • Engine modifier + child enumeration: packages/transform/src/engine.ts, packages/runes/src/config.ts.
  • Named-set / token-discipline convention this follows: SPEC-088 (bounded facets vs escape hatch), SPEC-086 (named scales).