Mobile-First Animations — Complete Guide
In this tutorial, you will learn about Mobile. We cover key concepts, practical examples, and best practices to help you master this topic.
Mobile-first animations use CSS transforms and opacity for 60fps performance, will-change hints, reduced motion queries, and spring physics for smooth UI motion.
What You'll Learn
- GPU-accelerated properties (transform, opacity)
- CSS transitions vs animations
- Will-change and performance hints
- Reduced motion Accessibility
- Spring animations and easing curves
- Scroll-triggered animations
- Animation timing and choreography
Why It Matters
- Poor animations drop frames and feel janky
- GPU-accelerated properties ensure 60fps
- Unnecessary animations cause motion sickness
- Animations convey state and guide attention
Real-World Use
- A card that scales on press (feedback)
- A modal that slides up with spring easing
- A progress bar that animates smoothly
- A list with staggered fade-in on scroll
flowchart LR A[Mobile-First Animations] --> B[GPU Properties] A --> C[Performance] A --> D[Accessibility] A --> E[Easing] B --> F[Transform + opacity] C --> G[will-change] D --> H[prefers-reduced-motion] E --> I[Spring easing]
GPU-Accelerated Properties
Only transform and opacity are GPU-accelerated. Animating other properties (width, height, top, left) triggers expensive layout recalculations.
Code Example: GPU-Accelerated Animations
<div class="animation-demo">
<div class="demo-card" id="demo-card">
<h3>Tap to Animate</h3>
<p>This card animates using GPU-accelerated properties only.</p>
</div>
<div class="demo-controls">
<button class="btn btn-primary" id="animate-btn">Animate Card</button>
</div>
<div class="animation-stats" id="animation-stats">
FPS: <span id="fps-count">--</span>
</div>
</div>
<script>
const card = document.getElementById('demo-card');
const btn = document.getElementById('animate-btn');
const fpsEl = document.getElementById('fps-count');
let frameId = null;
// FPS counter
function startFPS() {
let frames = 0;
let lastTime = performance.now();
function count() {
frames++;
const now = performance.now();
if (now - lastTime >= 1000) {
fpsEl.textContent = frames;
frames = 0;
lastTime = now;
}
frameId = requestAnimationFrame(count);
}
frameId = requestAnimationFrame(count);
}
// Stop FPS when animation ends
function stopFPS() {
if (frameId) {
cancelAnimationFrame(frameId);
frameId = null;
}
}
startFPS();
// GPU-accelerated animation (transform + opacity only)
let isAnimating = false;
btn.addEventListener('click', () => {
if (isAnimating) return;
isAnimating = true;
// Good: animating transform and opacity
card.style.transform = 'scale(1.1) rotate(3deg)';
card.style.opacity = '0.5';
card.style.transition = 'transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.4s ease';
setTimeout(() => {
card.style.transform = 'scale(1) rotate(0deg)';
card.style.opacity = '1';
isAnimating = false;
}, 600);
});
// Bad (DO NOT): animating layout properties would cause repaints
// card.style.width = '120%'; ← triggers layout
// card.style.height = '120%'; ← triggers layout
// card.style.marginLeft = '20px'; ← triggers layout
</script>
<style>
.animation-demo {
max-width: 400px;
}
.demo-card {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1rem;
text-align: center;
transform: scale(1);
opacity: 1;
will-change: transform, opacity;
}
.demo-card h3 {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.demo-card p {
font-size: 0.875rem;
color: #6b7280;
}
.animation-stats {
font-size: 0.75rem;
color: #6b7280;
font-family: monospace;
margin-top: 0.75rem;
}
</style>
Expected output: The card scales down and rotates slightly with smooth 60fps animation. The FPS counter shows the frame rate. Only transform and opacity are animated, ensuring GPU acceleration and no layout thrashing.
CSS Transitions and Easing
Choose the right easing curve for the right animation type. Spring easing creates natural-feeling motion.
Code Example: Easing Curves
<div class="easing-demo">
<div class="easing-card" id="ease-linear">
<span>linear</span>
<div class="easing-ball" style="transition: transform 1s linear;"></div>
</div>
<div class="easing-card" id="ease-ease">
<span>ease</span>
<div class="easing-ball" style="transition: transform 1s ease;"></div>
</div>
<div class="easing-card" id="ease-spring">
<span>spring</span>
<div class="easing-ball" style="transition: transform 1s cubic-bezier(0.34, 1.56, 0.64, 1);"></div>
</div>
<div class="easing-card" id="ease-bounce">
<span>bounce</span>
<div class="easing-ball" style="transition: transform 1s cubic-bezier(0.68, -0.55, 0.265, 1.55);"></div>
</div>
<button class="btn btn-primary" id="easing-btn">Animate All</button>
</div>
<script>
const buttons = document.querySelectorAll('.easing-card');
const animateBtn = document.getElementById('easing-btn');
animateBtn.addEventListener('click', () => {
buttons.forEach(card => {
const ball = card.querySelector('.easing-ball');
const isRight = ball.style.transform === 'translateX(200px)';
ball.style.transform = isRight ? 'translateX(0)' : 'translateX(200px)';
});
});
// Interactive: click individual card to toggle
buttons.forEach(card => {
card.addEventListener('click', () => {
const ball = card.querySelector('.easing-ball');
const isRight = ball.style.transform === 'translateX(200px)';
ball.style.transform = isRight ? 'translateX(0)' : 'translateX(200px)';
});
});
</script>
<style>
.easing-demo {
max-width: 400px;
}
.easing-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
margin-bottom: 0.5rem;
border: 1px solid #e5e7eb;
border-radius: 8px;
cursor: pointer;
background: #fff;
}
.easing-card span {
font-size: 0.8125rem;
font-weight: 600;
color: #6b7280;
min-width: 60px;
}
.easing-ball {
width: 24px;
height: 24px;
background: #3b82f6;
border-radius: 50%;
flex-shrink: 0;
}
.easing-card:nth-child(2) .easing-ball { background: #16a34a; }
.easing-card:nth-child(3) .easing-ball { background: #f59e0b; }
.easing-card:nth-child(4) .easing-ball { background: #ef4444; }
</style>
Expected output: Four balls animate with different easing curves: linear (constant speed), ease (decelerating), spring (overshoot and settle), bounce (bounce at end). The spring easing feels most natural for UI motion.
Reduced Motion Accessibility
Respect prefers-reduced-motion to prevent triggering motion sickness in users.
Code Example: Reduced Motion
<div class="parallax-container" id="parallax-container">
<div class="parallax-bg" id="parallax-bg"></div>
<div class="parallax-content">
<h2>Parallax Effect</h2>
<p>This parallax effect is disabled for users who prefer reduced motion.</p>
</div>
</div>
<div class="reduced-motion-demo">
<p>Toggle motion preference:</p>
<button class="btn btn-secondary" id="toggle-motion">Simulate prefers-reduced-motion</button>
<span class="motion-status" id="motion-status">Reduced motion: OFF</span>
</div>
<style>
.parallax-container {
position: relative;
height: 200px;
max-width: 400px;
overflow: hidden;
border-radius: 12px;
margin-bottom: 1rem;
}
.parallax-bg {
position: absolute;
inset: -20px;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
transform: translateY(0);
transition: transform 0.1s linear;
will-change: transform;
}
.parallax-content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #fff;
text-align: center;
padding: 1rem;
}
.parallax-content h2 {
font-size: 1.25rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.parallax-content p {
font-size: 0.8125rem;
opacity: 0.9;
max-width: 300px;
}
/* Respect reduced motion preference */
@media (prefers-reduced-motion: reduce) {
.parallax-bg {
transform: none !important;
transition: none !important;
}
}
.motion-status {
display: inline-block;
margin-left: 0.75rem;
font-size: 0.8125rem;
font-weight: 600;
}
/* Utility class for testing */
.reduced-motion .parallax-bg {
transform: none !important;
transition: none !important;
}
</style>
<script>
const bg = document.getElementById('parallax-bg');
const container = document.getElementById('parallax-container');
const toggleBtn = document.getElementById('toggle-motion');
const statusEl = document.getElementById('motion-status');
// Parallax (only if not reduced motion)
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)');
let isReduced = prefersReduced.matches;
container.addEventListener('scroll', () => {}, { passive: true });
// In a real app, use scroll listener:
// window.addEventListener('scroll', () => {
// if (isReduced) return;
// const scrollY = window.scrollY;
// bg.style.transform = `translateY(${scrollY * 0.3}px)`;
// }, { passive: true });
// Toggle for testing
toggleBtn.addEventListener('click', () => {
isReduced = !isReduced;
if (isReduced) {
document.body.classList.add('reduced-motion');
bg.style.transform = 'none';
statusEl.textContent = 'Reduced motion: ON';
statusEl.style.color = '#16a34a';
} else {
document.body.classList.remove('reduced-motion');
statusEl.textContent = 'Reduced motion: OFF';
statusEl.style.color = '#6b7280';
}
});
</script>
Expected output: The parallax background moves with scroll. When prefers-reduced-motion is active (or toggled via the button), the parallax effect stops and the background stays static. The transition properties are also disabled.
Scroll-Triggered Animations
Use Intersection Observer to trigger animations when elements enter the viewport.
Code Example: Scroll Animations
<div class="scroll-animations">
<div class="scroll-item fade-in" data-delay="0">
<h3>Item 1</h3>
<p>Fades in with no delay</p>
</div>
<div class="scroll-item fade-in" data-delay="100">
<h3>Item 2</h3>
<p>Fades in with 100ms delay (staggered)</p>
</div>
<div class="scroll-item fade-in" data-delay="200">
<h3>Item 3</h3>
<p>Fades in with 200ms delay</p>
</div>
<div class="scroll-item fade-in" data-delay="300">
<h3>Item 4</h3>
<p>Fades in with 300ms delay</p>
</div>
<div class="scroll-item slide-up" data-delay="0">
<h3>Item 5 (Slide Up)</h3>
<p>Slides up from below with fade</p>
</div>
<div class="scroll-item slide-left" data-delay="100">
<h3>Item 6 (Slide Left)</h3>
<p>Slides in from the right</p>
</div>
</div>
<script>
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const el = entry.target;
const delay = parseInt(el.dataset.delay) || 0;
el.style.transitionDelay = `${delay}ms`;
el.classList.add('visible');
observer.unobserve(el);
}
});
}, {
rootMargin: '0px 0px -50px 0px', // Trigger when 50px from bottom
threshold: 0.1
});
document.querySelectorAll('.scroll-item').forEach(el => observer.observe(el));
</script>
<style>
.scroll-animations {
max-width: 400px;
}
.scroll-item {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1rem;
opacity: 0;
transform: translateY(20px);
transition: opacity 0.5s ease, transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.scroll-item h3 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.375rem;
}
.scroll-item p {
font-size: 0.8125rem;
color: #6b7280;
}
.scroll-item.visible {
opacity: 1;
transform: translateY(0);
}
.scroll-item.slide-up {
transform: translateY(40px);
}
.scroll-item.slide-up.visible {
transform: translateY(0);
}
.scroll-item.slide-left {
transform: translateX(40px);
}
.scroll-item.slide-left.visible {
transform: translateX(0);
}
@media (prefers-reduced-motion: reduce) {
.scroll-item {
opacity: 1;
transform: none;
transition: none;
}
}
</style>
Expected output: Items animate in as they scroll into view. Items 1-4 fade in with staggered delays. Item 5 slides up. Item 6 slides from the right. The animation only triggers once per element. Reduced motion disables all scroll animations.
Common Mistakes
- Animating layout properties — Animating width, height, top, left, margin, or padding triggers layout recalculations every frame, causing jank. Only animate transform and opacity.
- No will-change hint — Browsers optimize elements marked with will-change. Use will-change: transform, opacity on elements that will animate, but remove it when the animation ends.
- Animating too many elements simultaneously — Animating 30 elements at once overwhelms the GPU. Limit simultaneous animations to 5-10 at most.
- No reduced motion support — Users with vestibular disorders experience motion sickness from parallax, scale, and movement animations. Always respect prefers-reduced-motion.
- Wrong easing for the animation type — Linear easing looks robotic. Use cubic-bezier curves: ease-out for entrances, ease-in-out for UI transitions, spring (0.34, 1.56, 0.64, 1) for playful interactions.
- Animation too long — Animations longer than 500ms feel slow on mobile. UI transitions should complete in 200-300ms. Delight animations (celebrations) can be 400-600ms.
- No transition on initial render — Elements that should animate in on page load need the animation class set after a small delay or via the DOM ready event, not in the initial HTML.
Practice Questions
- Which CSS properties are GPU-accelerated? transform and opacity. These properties can be composited by the GPU without triggering layout or paint recalculations.
- What does will-change do? It hints to the browser that an element will change, allowing it to optimize rendering by promoting the element to its own compositor layer.
- How do you detect reduced motion preference? Use the CSS media query @media (prefers-reduced-motion: reduce) or JavaScript's window.matchMedia('(prefers-reduced-motion: reduce)').
- What is the best easing for UI entrances? cubic-bezier(0.34, 1.56, 0.64, 1) for spring-like overshoot. cubic-bezier(0.16, 1, 0.3, 1) for smooth ease-out without overshoot.
- How do you stagger multiple element animations? Set different transition-delay values (e.g., 0ms, 100ms, 200ms, 300ms) to create a ripple effect.
Challenge
Build an animated onboarding flow with 4 steps. Each step has: (1) a heading that fades in and slides up, (2) an illustration that scales in from 0.8 to 1, (3) a description that fades in with a staggered delay, (4) a "Next" button that appears after the content finishes animating. Implement: (1) spring easing on the illustration, (2) staggered delays (100ms between each element), (3) scroll-triggered animation for step 1, (4) reduced motion support disabling all animations, (5) performance monitoring showing FPS during the animation.
FAQ
Mini Project
Build an animated product showcase page with: (1) a hero section with a fade-in background image (opacity), a title that scales in (transform: scale), and a CTA button that bounces in (spring easing), (2) a features section with 4 cards that animate in on scroll with staggered delays (fade + slide up), (3) a testimonial carousel that auto-plays with cross-fade transition (opacity + transform), (4) a back-to-top button that fades in when scrolling down (opacity + transform), (5) reduced motion support disabling all entrance animations, (6) will-change hints on animated elements, (7) spring easing on hover states, (8) FPS counter for debugging.
What's Next
Continue with Lesson 18: Mobile-First Testing to learn testing strategies for mobile web development.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro