Scroll choreography: four earned patterns replace a blanket ban

motion.md banned scroll-scrubbed animation 'unless there is a specific
reason' while tier-c sanctioned animation-timeline outright - a live
contradiction with no vocabulary between them. references/
scroll-choreography.md now IS the specific reason: four named patterns
(pinned chapter, scrub-linked headline, progress spine, sticky diptych)
with Baseline-2025 recipes, each inside @supports + reduced-motion +
min-width guards so the base page is always the finished page. Two
correctness rules are baked in: the animation shorthand resets
animation-timeline (declare after), and pinned-pane children cannot drive
their own view() (steps ride a named timeline on the tall track).

Budget: one pattern per page (sloplint F8 will count), never on body
text, linear easing for scrub, hero orchestration untouched, JS fallback
is the IO reveal-once only. motion.md reroutes to the vocabulary, tier-c's
decision row defers to it, macros 14/16 and f2 point at it, and SKILL.md
loads it conditionally.
This commit is contained in:
Youssef
2026-07-31 12:18:22 +01:00
parent 64f84b4f12
commit 0de213db0a
7 changed files with 117 additions and 6 deletions
+1 -1
View File
@@ -236,7 +236,7 @@ A custom system is complete (palette + pairing + axes), never a colour swap; its
**Every build:** [`references/typography.md`](references/typography.md) · [`references/color.md`](references/color.md) · [`references/layout-and-space.md`](references/layout-and-space.md) · [`references/motion.md`](references/motion.md) · [`references/copy.md`](references/copy.md) · [`references/anti-patterns.md`](references/anti-patterns.md).
**Conditionally (be honest, no defensive pre-loads):** [`references/microinteractions.md`](references/microinteractions.md) when anything is interactive (most pages); [`references/interaction-and-states.md`](references/interaction-and-states.md) for stateful UI; [`references/responsive.md`](references/responsive.md) when mobile is in scope; [`references/structure.md`](references/structure.md) only when deviating from a named macrostructure; [`references/assets.md`](references/assets.md) only when an enrichment needs an external asset; [`references/texture.md`](references/texture.md) only when the picked theme earns texture (Riso, Carnival, Arcade, faint Newsprint) or a custom draw has print lineage; [`references/custom-theme.md`](references/custom-theme.md) only on the custom route; [`references/design-md.md`](references/design-md.md) only when the user asks to lock the system; [`references/preview-examples.md`](references/preview-examples.md) only if the Step 5 spec is not scaffolding enough.
**Conditionally (be honest, no defensive pre-loads):** [`references/microinteractions.md`](references/microinteractions.md) when anything is interactive (most pages); [`references/interaction-and-states.md`](references/interaction-and-states.md) for stateful UI; [`references/responsive.md`](references/responsive.md) when mobile is in scope; [`references/structure.md`](references/structure.md) only when deviating from a named macrostructure; [`references/assets.md`](references/assets.md) only when an enrichment needs an external asset; [`references/texture.md`](references/texture.md) only when the picked theme earns texture (Riso, Carnival, Arcade, faint Newsprint) or a custom draw has print lineage; [`references/scroll-choreography.md`](references/scroll-choreography.md) only when the brief asks for scroll story / cinematic pacing or the macro is Feature-stack / Narrative Workflow; [`references/custom-theme.md`](references/custom-theme.md) only on the custom route; [`references/design-md.md`](references/design-md.md) only when the user asks to lock the system; [`references/preview-examples.md`](references/preview-examples.md) only if the Step 5 spec is not scaffolding enough.
**At the end only:** [`references/slop-test.md`](references/slop-test.md) strictly at Step 7 (pre-loading it costs thousands of tokens for nothing; `anti-patterns.md` is the pre-emit list); [`references/contract.md`](references/contract.md) at handoff; [`references/export-formats.md`](references/export-formats.md) only on `design.md` projects.
@@ -1,7 +1,7 @@
### F2 · Sticky-scroll stack
Sticky left pane, scrolling right pane that cycles through related screenshots.
*Use when:* feature has multiple sub-states worth showing in sequence.
*Don't confuse with:* F4 Step sequence (which is linearly numbered, not synced).
*Don't confuse with:* F4 Step sequence (which is linearly numbered, not synced). Scroll-scrub choreography on top of the pin lives in [`scroll-choreography.md`](../scroll-choreography.md) § Sticky diptych.
```html
<section class="sticky-stack">
@@ -79,7 +79,7 @@ The browser handles the cross-fade. No animation libraries needed for state chan
```
Single element, simple motion → CSS keyframes / @property
Multiple elements, orchestrated entrance → Motion (React) or GSAP (vanilla / complex)
Scroll-progress-linked → animation-timeline (CSS) — or GSAP ScrollTrigger if complex
Scroll-progress-linked → ONE pattern from scroll-choreography.md (animation-timeline CSS); GSAP ScrollTrigger only when that pattern genuinely needs JS
State change between two layouts → View Transitions API
A list reflows in React → AutoAnimate
A complex hero narrative with scrubbing → GSAP timeline + ScrollTrigger
@@ -7,7 +7,7 @@ Numbered stages tell the story of how the user uses the product over time. Each
- **Divider:** thick numbered rule between stages.
- **Button:** stage-internal links; one global "Start at stage 1 →" at the foot.
- **Image:** small product capture per stage, often annotated.
- **Reveal:** sweep horizontal as stages enter the viewport.
- **Reveal:** sweep horizontal as stages enter the viewport (a scrub-linked stage sequence may use [`scroll-choreography.md`](../scroll-choreography.md) § Pinned chapter instead - one pattern).
Reach for it for products with explicit workflows — project management, design-to-dev pipelines, writing tools.
@@ -7,7 +7,7 @@ Sticky left pane (label / description) + scroll-synced right pane (screenshots c
- **Divider:** section bands; the sticky pane re-anchors per section.
- **Button:** in the sticky pane, set when the user reaches the section's detail count.
- **Image:** the scrolling-right column is mostly imagery.
- **Reveal:** none of the spatial-fade kind; the sticky/scroll IS the motion.
- **Reveal:** none of the spatial-fade kind; the sticky/scroll IS the motion (choreograph it via [`scroll-choreography.md`](../scroll-choreography.md) § Sticky diptych or § Pinned chapter - one pattern).
Reach for it for premium products, complex feature stories, anything where you want to control pacing as the user scrolls.
+1 -1
View File
@@ -70,7 +70,7 @@ Cap total stagger at ~500ms. Beyond that the page feels slow to settle.
## Scroll-linked motion
- Use IntersectionObserver, **never** `scroll` event listeners.
- Use it only for *reveal once* effects. No parallax. No scroll-scrubbed animations unless there is a specific reason.
- Use it only for *reveal once* effects. No parallax. Scroll-scrubbed motion exists ONLY as the named patterns in [`scroll-choreography.md`](scroll-choreography.md), one per page; anything outside that vocabulary is banned.
- Every scroll-triggered motion must have a reduced-motion fallback.
## State transitions
@@ -0,0 +1,111 @@
# Scroll choreography - the one earned pattern
The default is still none: motion.md's IntersectionObserver reveal-once is the page norm. This file is the exception path - a scroll-linked pattern used ONCE, on structure, when the content genuinely has a sequence worth pinning or scrubbing. sloplint check F8 counts pattern families and flags a second one.
## Budget (non-negotiable)
- **One pattern per page.** Two scroll behaviours compete for the same attention.
- **Never on body text.** Targets are structural: a pinned section, ONE display element, a 2px spine, figures. Prose never moves under the reader.
- **Hero entrance rules unchanged.** The page-load orchestration in motion.md stays as is; scroll choreography never replaces it, and no element carries both.
- **Static end-state mandatory.** Base CSS shows the finished layout. The choreography lives entirely inside `@supports (animation-timeline: view())` + `@media (prefers-reduced-motion: no-preference) and (min-width: 40rem)` - reduced-motion, small screens, and non-supporting browsers get the complete page.
- **Order footgun:** the `animation` shorthand RESETS `animation-timeline` and `animation-range`. Declare them AFTER `animation`, always.
- **`linear` is correct here** - scrubbing maps scroll distance to progress 1:1; an easing curve would make the page feel detached from the thumb.
- A pattern counts as one of the page's three animation primitives (microinteractions.md).
## 1 · Pinned chapter - steps reveal while the section is pinned
The tall track owns a named view-timeline; the pinned pane's steps consume it in slices. (Elements inside a stuck pane cannot drive their own `view()` - they are not moving.)
```html
<section class="chapter">
<div class="chapter__pin">
<h2>How it works</h2>
<ol class="chapter__steps"><li>…</li><li>…</li><li>…</li></ol>
</div>
</section>
```
```css
.chapter { min-height: 280vh; } /* track length = pin duration */
.chapter__pin { position: sticky; top: calc(var(--banner-height, 0px) + var(--space-xl)); }
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) and (min-width: 40rem) {
.chapter { view-timeline: --chapter block; }
.chapter__steps li { animation: chapter-step linear both; animation-timeline: --chapter; }
.chapter__steps li:nth-child(1) { animation-range: contain 5% contain 30%; }
.chapter__steps li:nth-child(2) { animation-range: contain 30% contain 55%; }
.chapter__steps li:nth-child(3) { animation-range: contain 55% contain 80%; }
@keyframes chapter-step { from { opacity: 0.2; transform: translateY(8px); } to { opacity: 1; transform: none; } }
}
}
```
## 2 · Scrub-linked headline - ONE display element, clip wipe
For a mid-page statement head (Ft5 / T3 register), never the hero h1 (that is load-orchestration territory). One element per page, ever.
```css
.statement__head { /* base: fully drawn */ }
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) and (min-width: 40rem) {
.statement__head {
animation: head-wipe linear both;
animation-timeline: view();
animation-range: entry 20% cover 45%;
}
@keyframes head-wipe {
from { clip-path: inset(0 100% 0 0); transform: translateX(-0.25em); }
to { clip-path: inset(0 0 0 0); transform: none; }
}
}
}
```
## 3 · Progress spine - scroll(root) scaling a 2px rule
Reading progress as a hairline, not a chrome bar. A functional indicator, so `linear` is canon. Hidden below 40rem (it eats the mobile gutter).
```css
.spine { position: fixed; inset-block: 0; left: max(1rem, 2vw); width: 2px; background: var(--color-rule); }
.spine::after { content: ""; position: absolute; inset: 0; background: var(--color-accent);
transform-origin: top; transform: scaleY(0); }
@media (max-width: 40rem) { .spine { display: none; } }
@supports (animation-timeline: scroll()) {
@media (prefers-reduced-motion: no-preference) and (min-width: 40rem) {
.spine::after { animation: spine-grow linear both; animation-timeline: scroll(root); }
@keyframes spine-grow { to { transform: scaleY(1); } }
}
}
```
## 4 · Sticky diptych - alternating two-pane pin
Structure and pin mechanics come from [`components/f2-sticky-scroll-stack.md`](components/f2-sticky-scroll-stack.md) - markup, `--banner-height` offset, and mobile collapse are defined there; do not restate them. This recipe adds only the choreography: the scrolling figures fade in on their own `view()` (they move, so self-timelines work), and consecutive diptychs alternate the pinned side.
```css
/* f2 base: .sticky-stack grid + .pane-sticky pin, per the component file */
.sticky-stack:nth-of-type(even) .pane-sticky { order: 2; } /* alternate the pin side */
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) and (min-width: 40rem) {
.pane-scroll figure { animation: fig-in linear both; animation-timeline: view();
animation-range: entry 0% entry 60%; }
@keyframes fig-in { from { opacity: 0.3; transform: translateY(12px); } to { opacity: 1; transform: none; } }
}
}
```
## JS fallback (optional, shared)
When JS is present and `animation-timeline` is not, the one sanctioned fallback is motion.md's reveal-once - never a scroll listener, never a scrub polyfill:
```html
<script>
if (!CSS.supports('animation-timeline: view()')) {
const io = new IntersectionObserver((es) => es.forEach((e) => {
if (e.isIntersecting) { e.target.classList.add('is-seen'); io.unobserve(e.target); }
}), { rootMargin: '0px 0px -20%' });
document.querySelectorAll('[data-reveal]').forEach((el) => io.observe(el));
}
</script>
```
Pair with a 420ms `--ease-out` transition to the end state. Without JS and without support, the base static layout is already the finished page - that is the contract.