What motion is for
Good motion is never decoration for its own sake — it has a job. Before you animate anything, ask which of these it's doing. If the answer is "none", leave it still.
Feedback
Confirm an action happened — a button presses in, a toggle slides, a "Saved!" fades in.
Continuity
Connect states so a change makes sense — a card grows into a full page instead of teleporting.
Guidance
Draw the eye to what matters — a gentle pulse on the primary button, a reveal as a section arrives.
Personality
A little delight — a bouncy success check, a playful hover — makes a product feel crafted.
Transitions — animate a change of state
A transition smoothly animates a property when it changes — most often on :hover
or :focus. It's the simplest, most useful motion on the web. Hover these:
<div class="lift-row"> <div class="lift">Hover me</div> <div class="lift">And me</div> <div class="lift">Me too</div> </div> <p class="hint">Each card lifts with a springy easing curve</p>
.lift-row { display: flex; flex-wrap: wrap; gap: 16px; }
/* transition lives on the BASE state so it animates in AND out */
.lift {
width: 130px;
height: 90px;
border-radius: 12px;
background: linear-gradient(135deg, #06b6d4, #6366f1);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: .85rem;
cursor: pointer;
transition: transform .25s cubic-bezier(.34, 1.56, .64, 1),
box-shadow .25s;
}
.lift:hover {
transform: translateY(-8px); /* lift up */
box-shadow: 0 16px 34px rgba(6, 182, 212, .4);
}
.hint { font-size: .78rem; color: #8b8b94; margin-top: 14px; }
transition on the base state (so it animates both in and out), and the change on :hover. List the exact properties instead of all for better performance.Transforms — move without reflowing
transform moves, scales, rotates, and skews an element without touching layout,
so it's smooth and cheap. These are the four you'll use most — hover each:
<div class="tf-row"> <div class="tf translate">translate</div> <div class="tf scale">scale</div> <div class="tf rotate">rotate</div> <div class="tf skew">skew</div> </div>
.tf-row { display: flex; flex-wrap: wrap; gap: 16px; }
.tf {
width: 110px;
height: 110px;
border-radius: 14px;
background: linear-gradient(135deg, #22d3ee, #ec4899);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 700;
font-size: .8rem;
text-align: center;
transition: transform .4s ease;
}
/* each box does one kind of transform on hover */
.tf.translate:hover { transform: translateX(16px); } /* slide */
.tf.scale:hover { transform: scale(1.18); } /* grow */
.tf.rotate:hover { transform: rotate(12deg); } /* turn */
.tf.skew:hover { transform: skewX(-10deg); } /* slant */
/* combine: transform: translateY(-8px) scale(1.05); */
top/left/width forces the browser to recalculate layout every frame (janky). transform and opacity are handled by the GPU and stay buttery at 60fps. Animate those two whenever you can.Easing & timing
Easing is how speed changes over the animation — it's what makes motion feel natural instead of robotic. Nothing in the real world starts and stops instantly. Hit play and watch the dots race with different curves:
<button class="play-btn" id="raceBtn">▶ Play race</button> <div class="race" id="race"> <div class="race-lane linear"><span class="lbl">linear</span><div class="race-track"><div class="race-dot"></div></div></div> <div class="race-lane ease"><span class="lbl">ease</span><div class="race-track"><div class="race-dot"></div></div></div> <div class="race-lane io"><span class="lbl">ease-in-out</span><div class="race-track"><div class="race-dot"></div></div></div> <div class="race-lane spring"><span class="lbl">cubic-bezier ↗</span><div class="race-track"><div class="race-dot"></div></div></div> </div>
.play-btn {
display: inline-flex; align-items: center; gap: 8px;
background: linear-gradient(135deg, #06b6d4, #ec4899);
color: #fff; font: inherit; font-weight: 700;
border: none; border-radius: 9px; padding: 9px 18px; cursor: pointer;
}
.race { display: grid; gap: 12px; margin-top: 16px; }
.race-lane { display: grid; grid-template-columns: 150px 1fr; gap: 12px; align-items: center; }
.race-lane .lbl { font-family: 'SF Mono', Consolas, monospace; font-size: .72rem; color: #6d28d9; }
.race-track { background: #f1f5f9; border-radius: 999px; height: 26px; position: relative; overflow: hidden; }
.race-dot {
position: absolute; top: 3px; left: 3px;
width: 20px; height: 20px; border-radius: 50%;
background: linear-gradient(135deg, #06b6d4, #ec4899);
transition: left 1.2s; /* duration set here, easing per-lane below */
}
/* same move, different easing curve — that's the whole lesson */
.race-lane.linear .race-dot { transition-timing-function: linear; } /* constant — mechanical */
.race-lane.ease .race-dot { transition-timing-function: ease; } /* the comfy default */
.race-lane.io .race-dot { transition-timing-function: ease-in-out; } /* smooth start AND stop */
.race-lane.spring .race-dot { transition-timing-function: cubic-bezier(.34, 1.56, .64, 1); } /* springy overshoot */
/* the .go class (added by JS) is what triggers the move */
.race.go .race-track .race-dot { left: calc(100% - 23px); }
var btn = document.getElementById('raceBtn');
var race = document.getElementById('race');
btn.addEventListener('click', function () {
race.classList.remove('go');
void race.offsetWidth; // force reflow so it restarts
race.classList.add('go');
});
150–250ms; entrances and bigger moves 300–500ms. Anything over ~500ms starts to feel slow. Smaller, faster, subtler almost always wins.Keyframes — multi-step & repeating motion
When you need more than a simple A→B change — something with several steps or that loops forever
— use @keyframes and the animation property. These run on their own:
<div class="kf-row"> <div><div class="kf pulse">♥</div><div class="kf-cap">pulse</div></div> <div><div class="kf spin">◠</div><div class="kf-cap">spin</div></div> <div><div class="kf bounce">⇧</div><div class="kf-cap">bounce</div></div> <div><div class="kf shake">🔔</div><div class="kf-cap">shake</div></div> </div>
.kf-row { display: flex; flex-wrap: wrap; gap: 26px; align-items: center; }
.kf {
width: 70px; height: 70px; border-radius: 14px;
background: linear-gradient(135deg, #22d3ee, #6366f1);
display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 1.5rem;
}
.kf-cap { font-size: .72rem; color: #6b7280; text-align: center; margin-top: 6px;
font-family: 'SF Mono', Consolas, monospace; }
/* attach an animation: name duration easing repeat */
.kf.pulse { animation: kfPulse 1.4s ease-in-out infinite; }
.kf.spin { animation: kfSpin 1.6s linear infinite; border-radius: 12px; }
.kf.bounce { animation: kfBounce 1.2s ease-in-out infinite; }
.kf.shake { animation: kfShake 1.6s ease-in-out infinite; }
@keyframes kfPulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.18); opacity: .7; }
}
@keyframes kfSpin { to { transform: rotate(360deg); } }
@keyframes kfBounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-18px); }
}
@keyframes kfShake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-7px); }
75% { transform: translateX(7px); }
}
Reveal & stagger
Fading elements up as they appear feels modern and guides the eye. Staggering them — a tiny delay between each — turns a flat group into a graceful cascade. Press play:
<button class="play-btn" id="stagBtn">▶ Replay</button> <div class="stagger" id="stagger"> <div class="st-item">1</div> <div class="st-item">2</div> <div class="st-item">3</div> <div class="st-item">4</div> <div class="st-item">5</div> </div>
.play-btn {
display: inline-flex; align-items: center; gap: 8px;
background: linear-gradient(135deg, #06b6d4, #ec4899);
color: #fff; font: inherit; font-weight: 700;
border: none; border-radius: 9px; padding: 9px 18px; cursor: pointer;
}
.stagger { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 16px; }
/* each item starts hidden + nudged down, then transitions in */
.st-item {
width: 86px; height: 86px; border-radius: 12px;
background: linear-gradient(135deg, #06b6d4, #8b5cf6);
display: flex; align-items: center; justify-content: center;
color: #fff; font-weight: 700;
opacity: 0; transform: translateY(20px);
transition: opacity .5s ease, transform .5s cubic-bezier(.25, .8, .25, 1);
}
.stagger.in .st-item { opacity: 1; transform: none; }
var btn = document.getElementById('stagBtn');
var wrap = document.getElementById('stagger');
var items = Array.prototype.slice.call(wrap.querySelectorAll('.st-item'));
function play() {
wrap.classList.remove('in');
items.forEach(function (el) { el.classList.remove('in'); });
void wrap.offsetWidth; // force reflow so it restarts
// add .in to each item with a stagger — a tiny delay per child
items.forEach(function (el, i) {
setTimeout(function () { el.classList.add('in'); }, i * 90);
});
}
btn.addEventListener('click', play);
play(); // run once on load
Reveal on scroll
The most common motion on modern marketing pages: elements fade in as they enter the viewport.
IntersectionObserver watches each one and adds a class when it appears. (These reveal as you
scroll to them.)
<div class="rv-grid"> <div class="rv">Watch</div> <div class="rv">It</div> <div class="rv">Fade</div> <div class="rv">In</div> </div>
.rv-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 14px; }
/* start hidden + nudged down; .in (added by JS) reveals it */
.rv {
background: #f5f5f7;
border-radius: 12px;
padding: 22px;
text-align: center;
color: #1d1d1f;
font-weight: 600;
opacity: 0;
transform: translateY(26px);
transition: opacity .6s ease, transform .6s cubic-bezier(.25, .8, .25, 1);
}
.rv.in { opacity: 1; transform: none; }
const io = new IntersectionObserver((entries) => {
entries.forEach(e => {
if (e.isIntersecting) { e.target.classList.add('in'); io.unobserve(e.target); }
});
}, { threshold: 0.2 });
document.querySelectorAll('.rv').forEach(el => io.observe(el));
Loading & progress states
Motion reassures people that something is happening. A spinner, a shimmering skeleton, or bouncing dots all say “hang on, it's working” — far better than a frozen screen.
<div class="load-row"> <div><div class="spinner"></div></div> <div class="dots"><span></span><span></span><span></span></div> <div class="skeleton"><div class="ln"></div><div class="ln"></div><div class="ln short"></div></div> </div>
.load-row { display: flex; flex-wrap: wrap; gap: 30px; align-items: center; }
/* 1. spinner — a rotating ring */
.spinner { width: 40px; height: 40px; border-radius: 50%;
border: 4px solid #e5e7eb; border-top-color: #06b6d4;
animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* 2. bouncing dots — same keyframe, delayed per dot */
.dots { display: flex; gap: 7px; }
.dots span { width: 11px; height: 11px; border-radius: 50%; background: #06b6d4;
animation: bounce 1s ease-in-out infinite; }
.dots span:nth-child(2) { animation-delay: .15s; }
.dots span:nth-child(3) { animation-delay: .3s; }
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-18px); }
}
/* 3. skeleton — shimmering placeholder lines */
.skeleton { width: 200px; display: grid; gap: 8px; }
.ln { height: 12px; border-radius: 6px;
background: linear-gradient(90deg, #eee 25%, #ddd 37%, #eee 63%);
background-size: 400% 100%;
animation: shimmer 1.4s ease infinite; }
.ln.short { width: 60%; }
@keyframes shimmer { from { background-position: 100% 0; } to { background-position: 0 0; } }
Respect “reduce motion”
Some people get dizzy or distracted by movement and turn on “reduce motion” in their OS. Always honor it — keep the content, drop the animation. One media query covers your whole page (this page uses it):
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation: none !important;
transition: none !important;
scroll-behavior: auto !important;
}
}
Do & don't
Do
- Give every animation a clear purpose
- Animate
transform&opacityfor smoothness - Keep it fast — 150–500ms
- Use natural easing, not
linear, for UI - Be consistent — reuse the same durations & curves
- Honor
prefers-reduced-motion
Don't
- Animate just because you can
- Animate
width/top/left(janky) - Use long, slow 1–2 second transitions
- Make everything move at once
- Auto-play big looping motion that distracts
- Hide content behind motion that may not run
The motion toolkit
Transitions animate a state change, transforms move things cheaply, easing makes it feel natural, and keyframes handle looping or multi-step motion. IntersectionObserver reveals things on scroll, and loaders fill the wait. Keep every animation purposeful, fast, GPU-friendly, and respectful of reduced-motion — and your pages will feel alive without ever feeling busy.