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:- CSS scroll-driven (
animation-timeline) for anything swipe-driven - drawers, sheets, parallax, page stacks, carousels. Zero JS per frame. Compositor-only. - CSS transitions and keyframes for everything else - tap feedback, modals, toasts, loaders. Still compositor-safe on
transformandopacity. - JS
requestAnimationFrameonly when CSS cannot express the result, and only as a fallback when scroll-driven is not supported.
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.
:hover with an input capability query. Screen width tells you nothing about input type.
CSS Transitions
| Use case | Duration |
|---|---|
| Tap feedback | 0.1s to 0.15s |
| Standard state change | 0.2s to 0.3s |
| Large movements | 0.3s to 0.5s |
| Maximum | 0.6s |
CSS Keyframe Animations
Will-Change
Addwill-change: transform to position: fixed or position: sticky elements before they animate. Remove it after.
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
| API | Available |
|---|---|
CSS animation-timeline: scroll() | Yes |
CSS animation-timeline: view() | Yes |
new ScrollTimeline() in JS | No - throws ReferenceError |
new ViewTimeline() in JS | No - throws ReferenceError |
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:
animation-timeline: view() is unsupported, items stay at opacity: 0 forever.
CSS.supports():
| Environment | animation-timeline support |
|---|---|
| WKWebView - iOS 18+ | Yes |
| WKWebView - iOS 17 and below | No - use @supports fallback |
| Android WebView - Chrome 115+ | Yes |
| Android WebView - older | No - use @supports fallback |
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.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.
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.Carousel with Indicator Dots
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
Modal and Sheet Animations
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
UserequestAnimationFrame only when CSS cannot express the result - physics simulations, canvas, procedural effects - or as a fallback for animation-timeline on unsupported browsers.
Accessibility
Platform Notes
iOS (WKWebView)- JS animations throttle to ~30fps under Low Power Mode
- CSS scroll-driven animations are not throttled by Low Power Mode
transformandopacitystay smooth across all power states- Avoid animating
box-shadow- triggers paint every frame
- Test on mid-range hardware (4GB RAM)
- Be conservative with simultaneous animations
Quick Reference
- 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
:activefor touch feedback, never:hoverwill-changeonly 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.offsetHeightbetween 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