Skip to main content

Philosophy

CSS animations run on the compositor thread. They stay smooth under Low Power Mode, thermal throttling, and every other constraint a mobile device can throw at them. JavaScript animations run on the main thread and compete with everything else the app is doing. The hierarchy is:
  1. CSS scroll-driven (animation-timeline) for anything swipe-driven - drawers, sheets, parallax, page stacks, carousels. Zero JS per frame. Compositor-only.
  2. CSS transitions and keyframes for everything else - tap feedback, modals, toasts, loaders. Still compositor-safe on transform and opacity.
  3. JS requestAnimationFrame only when CSS cannot express the result, and only as a fallback when scroll-driven is not supported.
Only animate transform and opacity. Everything else triggers layout or paint on the main thread.

Touch Interaction Feedback

The input is always touch. Use :active, never :hover. :active fires on press and clears on release. :hover fires on tap and sticks with nothing to clear it.
If a component also renders in a browser, guard :hover with an input capability query. Screen width tells you nothing about input type.

CSS Transitions


CSS Keyframe Animations


Will-Change

Add will-change: transform to position: fixed or position: sticky elements before they animate. Remove it after.
Never apply will-change globally. Each declaration allocates a compositor layer.

Scroll-Driven Animations

For any swipe-driven UI, scroll-driven CSS is the preferred approach. The scroll position drives the animation directly on the compositor. No JS per frame, no touch event listeners, smooth under every power state.

WebView Compatibility

Browser Support and Fallbacks

animation-timeline requires Chromium 115+ / Safari 18+. Use @supports to layer it on top of a working CSS fallback. Write the fallback first, enhance inside @supports:
Always start items visible for scroll-reveal. If animation-timeline: view() is unsupported, items stay at opacity: 0 forever.
Branch in JS with CSS.supports():
Support matrix:

The Hidden Scroller Pattern

The base technique for all swipe-driven UI. An invisible scroll container captures gestures. Its scroll position becomes the CSS timeline for the content below.
No touchstart. No touchmove. No requestAnimationFrame. Native scroll engine handles physics, momentum, and snap.

Programmatic Open and Close

scrollend

scrollend fires once when scrolling settles. Update state here, not on every pixel.
Fallback for iOS 16.3 and below:

Scroll-Driven Patterns

Side Drawer

Bottom Sheet (Gesture-Driven)

Detent Sheet (Multiple Snap Points)

A sheet that snaps to peek, half, and full open.

Full-Screen Page Swipe Stack

Full-screen vertical snap-scroll. Each page scales up as it enters and scales down as it exits.

Parallax Hero with Fading Title

Large Title Navigation Bar

Bar title and border fade in as the large title scrolls away.

Swipe Card Stack

Scroll-Reveal List

Strip Viewer

Horizontal snap viewer for comics, onboarding, or photo strips. Progress bar requires no JS.

Context Menu Entrance

Scales up from the tap point. Removes itself from DOM on dismiss.

Tab Bar with Sliding Indicator

Notification Banner

Slides down from the top, auto-dismisses, removes from DOM on exit.

DOM Lifecycle

Keep off-screen elements out of the DOM. Remove them after exit animations. Animate them in after inserting.

Remove After Exit

Animate In After Insert

Inserting and immediately adding the active class in the same tick skips the transition. Force a reflow between the two.

Full Lifecycle


Fade and Scale

Slide from Bottom (Class-Toggled)

For sheets that do not need gesture tracking. Use the Hidden Scroller Pattern when gesture progress matters.

Page Transitions


Common Patterns

Button Tap Feedback

Toggle Switch

Loading Skeleton

Spinner


JS Fallback Animations

Use requestAnimationFrame only when CSS cannot express the result - physics simulations, canvas, procedural effects - or as a fallback for animation-timeline on unsupported browsers.
Read all layout values before writing. Interleaved reads and writes force repeated reflows.

Accessibility

Wrap scroll-driven keyframes so items do not get stuck invisible on reduced motion:

Platform Notes

iOS (WKWebView)
  • JS animations throttle to ~30fps under Low Power Mode
  • CSS scroll-driven animations are not throttled by Low Power Mode
  • transform and opacity stay smooth across all power states
  • Avoid animating box-shadow - triggers paint every frame
Android (WebView)
  • Test on mid-range hardware (4GB RAM)
  • Be conservative with simultaneous animations

Quick Reference

Checklist
  • CSS scroll-driven first for drawers, sheets, parallax, page stacks
  • @supports (animation-timeline: scroll()) fallback for all scroll-driven CSS
  • Start scroll-reveal items visible so they do not get stuck on unsupported browsers
  • :active for touch feedback, never :hover
  • will-change only on actively animating elements, remove after
  • { passive: true } on all scroll listeners
  • State updates in scrollend, not per pixel
  • Remove elements from DOM after exit animations
  • void el.offsetHeight between insert and animate-in
  • Wrap scroll-driven keyframes in prefers-reduced-motion: no-preference
  • Test on mid-range Android and under iOS Low Power Mode