Zero-Jank Scroll
Build scroll-driven interfaces as resilient interaction systems, not animation demos.
The goal is not maximum motion. The goal is intentional motion that remains smooth, accessible, responsive, maintainable, and correct when content, viewport, input method, or browser behavior changes.
Non-negotiable rules
- Preserve native scrolling by default.
- Choose the simplest architecture that expresses the interaction.
- Use real semantic content to create scroll distance.
- Never hide layout coupling behind arbitrary
300vh, 400vh, or percentage ranges when real steps can represent the sequence. - Never drive framework state on every scroll frame.
- Never read layout and write layout repeatedly in the same scroll path.
- Animate
transform and opacity by default; justify paint-heavy or layout-changing animation. - Provide a normal-flow or reduced-motion experience.
- Do not claim smoothness without runtime evidence.
- Keep content understandable if animation, sticky positioning, or JavaScript enhancement fails.
Step 1: Inspect before choosing
Before editing code, inspect:
- the requested visual behavior,
- the current DOM and component boundaries,
- existing animation dependencies,
- existing scroll containers,
- sticky ancestors and their
overflow, contain, and transform properties, - header behavior and offsets,
- target browsers and devices,
- viewport width and height constraints,
- reduced-motion requirements,
- whether media, canvas, WebGL, or videos run inside the sticky region,
- and whether the task is creation, repair, or audit.
Do not add a new animation library before checking what the project already uses.
Step 2: Classify the interaction
Choose exactly one primary class.
A. Discrete activation
Examples:
- four capability steps,
- active chapter changes,
- one panel per section,
- navigation item follows the current section.
Default architecture:
- real step elements,
- CSS
position: sticky for visual persistence, - one
IntersectionObserver for active-step changes, - one active state update only when the identity changes,
scrollIntoView() for click-to-scroll.
Do not use continuous progress math for a discrete state machine.
B. Continuous mapping
Examples:
- a progress bar,
- parallax proportional to scroll,
- drawing a path as the user scrolls,
- continuously scrubbing a simple visual property.
Default architecture:
- native CSS scroll/view timelines when the support matrix permits,
- otherwise a library or a single requestAnimationFrame-coalesced controller,
- values carried outside framework render state,
- transform/opacity/custom-property outputs where possible.
Treat CSS scroll-driven animation as progressive enhancement unless all required browsers support the needed behavior.
C. Cinematic choreography
Examples:
- multi-scene pinned storytelling,
- coordinated scrubbed timelines,
- complex SVG/canvas sequences,
- reversible sequences with precise start/end control.
Default architecture:
- GSAP ScrollTrigger when GSAP is acceptable in the project,
- explicit lifecycle cleanup,
- refresh behavior for fonts, media, and responsive layout,
- simplified fallback for small or short viewports.
Do not use GSAP merely to highlight four steps.
Read references/architecture-matrix.md when the choice is ambiguous.
Step 3: Design the document structure
Prefer this conceptual structure for sticky step stories:
text
section
├── steps
│ ├── step
│ ├── step
│ └── step
└── stage
└── sticky-stage
├── panel
├── panel
└── panel
Requirements:
- Each step is a real element with meaningful content or labeling.
- The rendered number of steps naturally determines section height.
- Adding or removing a step must not require recalculating a track height or percentage boundaries.
- The sticky stage has a stable size or aspect ratio.
- Overlaid panels do not change the stage's layout when activated.
- Hidden interactive panels are not focusable or clickable.
- Sticky offsets derive from shared layout variables, not duplicated magic numbers.
- Step targets use
scroll-margin-block-start when a fixed header exists.
Use svh for stable viewport-sized layout. Use dvh only where live browser-chrome resizing is intentionally desired.
Step 4: Implement activation without scroll churn
For discrete steps:
- Create one observer, not one global listener per component.
- Observe step elements.
- Align the activation line with the intended sticky top offset through
rootMargin or explicit sentinels. Use a center activation band only when the design explicitly calls for center-based switching. - Update active state only when the active step actually changes.
- Disconnect the observer during cleanup.
- Preserve DOM order and content without JavaScript.
Click behavior:
- Use real buttons or links.
- Call the matching real control or trigger element's
scrollIntoView() so it aligns with the same activation line. - Respect reduced motion by switching smooth behavior to instant/auto.
- Do not calculate absolute document coordinates unless a proven requirement prevents element-based scrolling.
- Do not move keyboard focus merely because scrolling changed the active step.
If a scroll listener is genuinely required:
- coalesce work with one requestAnimationFrame,
- avoid repeated layout reads,
- cache stable references,
- use passive listeners when the handler never calls
preventDefault(), - write values directly to animation primitives rather than framework state,
- and remove the listener during cleanup.
Step 5: Keep animation compositing-friendly
Default animated properties:
Use caution with:
- filters,
- backdrop filters,
- masks,
- clip paths,
- large blurred shadows,
- gradients that repaint large regions,
- fixed backgrounds,
- canvas or WebGL rendering,
- and many independently promoted layers.
Avoid scroll-linked animation of:
- width,
- height,
- top,
- left,
- margins,
- grid tracks,
- or properties that repeatedly trigger layout.
Do not add will-change globally. Apply it narrowly only after profiling demonstrates a benefit, and remove it when it is no longer needed.
Pause inactive videos, canvas loops, WebGL renders, Lottie animations, and timers.
Read references/performance.md before optimizing or diagnosing jank.
Step 6: Build responsive and accessibility fallbacks
Enable sticky choreography only when the viewport has enough width and height. Width-only breakpoints are insufficient.
For narrow or short viewports:
- use normal document flow,
- place the relevant visual near its content,
- shorten or remove non-essential movement,
- preserve every piece of information,
- and avoid empty artificial scroll distance.
For reduced motion:
- remove smooth programmatic scrolling,
- remove or substantially reduce parallax and scrubbing,
- use immediate panel changes or gentle opacity-only transitions,
- never remove content.
Accessibility requirements:
- Use native buttons and links.
- Keep visible focus styles.
- Use
aria-current="step" for step navigation when appropriate. - Use full ARIA tab semantics only when implementing the complete tab keyboard pattern.
- Mark inactive hidden panels
aria-hidden="true". - Use
inert for inactive panels containing interactive descendants when supported by the project. - Do not use color alone to communicate the active state.
- Do not trap the user's scroll or keyboard.
Read references/accessibility.md for the complete review.
Step 7: Handle framework lifecycle correctly
Framework-independent rule: continuous animation values must not cause component-tree rerenders per frame.
For React:
- create observers and animation controllers in effects,
- clean them up,
- store DOM refs and mutable continuous values outside React state,
- use state only for discrete identity changes,
- account for development-mode effect re-execution,
- do not combine Motion and GSAP control of the same property.
For Vue and Svelte:
- keep continuous progress outside broad reactive state,
- dispose observers/listeners/actions on unmount,
- avoid reactive writes on every native scroll event.
For SSR frameworks:
- isolate browser APIs to client-only lifecycle boundaries,
- render meaningful server HTML,
- avoid hydration-dependent layout shifts.
Read references/frameworks.md for framework-specific checks.
Step 8: Diagnose existing jank
When repairing an implementation, investigate in this order:
- Confirm the jank with the same input method and viewport where it occurs.
- Check layout shifts and sticky containment failures.
- Check long main-thread tasks during scrolling.
- Check repeated component renders.
- Check scroll handlers for layout reads and synchronous work.
- Check large paint areas, filters, shadows, and layer count.
- Check active media/canvas/WebGL loops.
- Check asset loading, font swaps, and image dimensions.
- Check mobile viewport-unit behavior and browser chrome.
- Check library refresh/cleanup behavior.
- Change one cause at a time and remeasure.
Run the bundled source heuristic when useful:
bash
node scripts/audit-scroll-source.mjs <project-path>
Static findings are leads, not proof.
Step 9: Verify with evidence
When browser tooling is available, test:
- slow wheel scrolling,
- rapid wheel scrolling,
- trackpad flicks,
- keyboard Page Up/Page Down,
- Home/End where applicable,
- scrolling upward across every boundary,
- clicking controls during and after smooth scrolling,
- resize and orientation changes,
- 125% and 200% zoom,
- reduced-motion mode,
- small width,
- short height,
- and at least one lower-performance emulation.
Collect:
- a performance trace around the problematic section,
- long-task evidence,
- layout-shift evidence,
- render-count evidence for framework components,
- console errors,
- and screenshots or recordings at boundary states.
Do not invent FPS, Core Web Vitals, or trace results. If runtime tools are unavailable, state exactly what was not measured.
Step 10: Report using this contract
Return:
Architecture
- Interaction class
- Selected approach
- Why it fits
- Rejected alternatives and why
Implementation
- Files changed
- State model
- Responsive behavior
- Reduced-motion behavior
- Cleanup and lifecycle behavior
Verification
- Tests performed
- Evidence collected
- Before/after observations
- Remaining unverified risks
Red flags remaining
List any known:
- arbitrary scroll tracks,
- per-frame state updates,
- layout-linked animations,
- heavy paint effects,
- sticky containment hazards,
- inactive rendering loops,
- missing fallbacks,
- or unsupported-browser assumptions.
Completion gate
Do not mark the work complete unless:
- ] Native scrolling remains usable.
- ] The architecture matches the interaction class.
- ] Content creates the scroll structure naturally.
- ] Adding a step does not require percentage arithmetic.
- ] No framework state updates occur per scroll frame.
- ] Scroll work avoids repeated forced layout.
- ] Sticky offsets and viewport units are resilient.
- ] Narrow, short, zoomed, and reduced-motion layouts remain usable.
- ] Hidden interactive content cannot receive accidental input.
- ] Observers, listeners, timelines, and media are cleaned up.
- ] Runtime evidence is reported, or missing evidence is explicitly disclosed.