Design & Media

Web page motion

Motion is the difference between a page that feels static and one that feels alive. Used well, it guides attention, explains what changed, and adds polish. Used badly, it's distracting and slow. Here's the toolkit — every example is live, with the code right below it.

Start here

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.

The basics

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:

Hover me
And me
Me too
Each card lifts with a springy easing curve
HTML
<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>
CSS
.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; }
The pattern: put the 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.
The engine

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:

translate
scale
rotate
skew
HTML
<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>
CSS
.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); */
Why transforms? Animating 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.
The feel

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:

linear
ease
ease-in-out
cubic-bezier ↗
HTML
<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>
CSS
.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); }
JavaScript
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');
});
Duration rules of thumb: hovers and small UI changes 150–250ms; entrances and bigger moves 300–500ms. Anything over ~500ms starts to feel slow. Smaller, faster, subtler almost always wins.
Looping

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:

pulse
spin
bounce
shake
HTML
<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>
CSS
.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); }
}
Entrances

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:

1
2
3
4
5
HTML
<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>
CSS
.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; }
JavaScript
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
On scroll

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.)

Watch
It
Fade
In
HTML
<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>
CSS
.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; }
JavaScript
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));
Go deeper: for pinned visuals, scene swaps, and scroll-driven animation, see the Scrollytelling page. For timeline-based sequencing, see GSAP Animation.
Waiting

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.

HTML
<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>
CSS
.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; } }
Do it right

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;
  }
}
Accessibility rule: never put essential content behind an animation that might be switched off. Reveal-on-scroll elements should still be readable if the motion never runs — test with reduce-motion on.
In practice

Do & don't

Do

  • Give every animation a clear purpose
  • Animate transform & opacity for 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
Keep going: this is the foundation — build on it with Animation, GSAP Animation, Scrollytelling, and Microinteractions.
Recap

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.