GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Mascot Animation: 9 CSS Parts from Wave to Sleep

A mascot animation is a small drawn character that reacts to what the screen is doing: waving at a new visitor, pointing at a button, waiting, failing,

Auto-plays · click a tile to jump to its section · all nine in one zip

The order is the one a character lives through inside a product. It opens standing still (01), says hello (02), and points you at the first button (03), then shields its eyes while a password goes in (04) and hauls a box while an upload runs (05). Failure and success come next — a head shake (06) and a jump (07) — and the last two belong to the moments when nobody is doing anything: a poke that squashes it (08) and a doze that closes its eyes (09). Motion is budgeted the way character animation css usually has to be: 33.276% of the frame sweeps during the loader, where the reader is stuck waiting, against 2.336% for the sleeper. For the tour steps that sit around 03, see nine onboarding tour widgets; for the error states that sit around 06, see nine form validation patterns. Flanking this one in the same interaction pillar are Kiosk UI Design: 9 Finger-Sized Order Screens, where a finger rather than a character drives the screen, and CSS Background Pattern Generator, which builds the backdrop you would stand a mascot on.

01Idle breathing pose

What the body and the shadow do in opposite directions

The body swells and settles while the shadow narrows the other way, and that opposition is what reads as breathing rather than zooming. The lids shut for a single frame at 76% and open again at 82%.

Idle breathing pose
Open in a new tab

01 · Idle breathing pose — Measured area 2.49% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

scaleYsteps(1, end)transform-originanimation-iteration-count
@keyframes mbBreath {
  0%, 100% { transform: scaleY(1) scaleX(1); }
  50%      { transform: scaleY(1.085) scaleX(.975); }
}
@keyframes mbShadow {
  0%, 100% { transform: scaleX(1); opacity: .18; }
  50%      { transform: scaleX(.84); opacity: .12; }
}
// The lids close only at 76% and 80%, ahead of the loop's return at 90%
@keyframes mbBlink {
  0%, 74%   { transform: scaleY(1); }
  76%, 80%  { transform: scaleY(.08); }
  82%, 100% { transform: scaleY(1); }
}

Why the origin sits at the feet

An origin of 50% 100% keeps the soles planted while the body grows upward, and with the blink cutting inside the loop this part registers movement in 23 of 23 frames while sweeping only 2.49% of the frame.

02Waving greeting

An entrance and a wave that are two separate animations

The character rises from below first, and the arm only starts swinging afterward because it carries animation-delay: 560ms. Drop animation-fill-mode: backwards and the arm flashes once in its finished pose at frame zero, then disappears until the delay runs out.

Waving greeting
Open in a new tab

02 · Waving greeting — Measured area 5.653% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

rotatetransform-originanimation-fill-modetranslateY
@keyframes wgWave {
  0%, 28%   { transform: rotate(58deg); }
  32%       { transform: rotate(24deg); }
  40%       { transform: rotate(52deg); }
  48%       { transform: rotate(24deg); }
  56%       { transform: rotate(52deg); }
  64%       { transform: rotate(24deg); }
  72%       { transform: rotate(52deg); }
  80%       { transform: rotate(24deg); }
  88%, 100% { transform: rotate(24deg); }
}
// The arm starts 560ms after the entrance; backwards fills the delay with the 0% frame
.wg.is-demo .wg__arm {
  animation-name: wgWave;
  animation-duration: $duration;
  animation-timing-function: $easing;
  animation-iteration-count: infinite;
  animation-delay: 560ms;
  animation-fill-mode: backwards;
}

Why the body leans away from the arm

The torso tilts against the arm rather than with it, because leaning the same way turns a greeting into a fall, and everything settles by 88% so the loop seam never shows.

03Pointing at the next button

An arm and a pair of pupils that travel together

The arm swings toward the button and the pupils slide 3px in the same direction, which is enough to sell a glance without deforming the face. Three guide sentences cycle underneath.

Pointing at the next button
Open in a new tab

03 · Pointing at the next button — Measured area 3.006% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

rotatetranslateXaria-livebox-shadow
@keyframes pgPoint {
  0%, 31%   { transform: rotate(80deg); }
  34%       { transform: rotate(92deg); }
  37%, 62%  { transform: rotate(80deg); }
  65%       { transform: rotate(92deg); }
  68%, 86%  { transform: rotate(80deg); }
  88%       { transform: rotate(92deg); }
  90%, 100% { transform: rotate(80deg); }
}
// Only the pupil moves — shifting the whole eye warps the face
@keyframes pgLook {
  0%        { transform: translateX(0); }
  8%, 100%  { transform: translateX(3px); }
}
.is-demo .pg__msg--0,
.is-demo .pg__msg--1,
.is-demo .pg__msg--2 {
  animation-duration: $duration;
  animation-timing-function: steps(1, end);
  animation-iteration-count: infinite;
}

Where the highlight ring comes from

The ring around the button is a spreading box-shadow, which paints outside the layout box and therefore never nudges the sentence sitting next to it.

04Covering eyes at the password field

Arms that rotate instead of sliding

The moment typing starts in the password field, both arms swing from 28 degrees to 95 degrees, mirrored left and right, and cover the eyes. Their origin is the shoulder joint at 5px 5px, since an arm pivoting on its own middle drifts along an orbit instead of turning in place.

Covering eyes at the password field
Open in a new tab

04 · Covering eyes at the password field — Measured area 2.806% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

rotatetransform-originaria-hiddentype="password"
// The origin is the shoulder joint (5px 5px), not the middle of the arm
.mc__arm {
  position: absolute; top: 42px; width: 10px; height: 40px;
  border-radius: $r-pill;
  background: $subject-blue-deep;
  transform-origin: 5px 5px;
  transition-property: transform;
  transition-duration: $dur-base;
  transition-timing-function: $easing;
}
.mc__arm--l { left: 2px; transform: rotate(28deg); }
.mc__arm--r { right: 2px; transform: rotate(-28deg); }
.mc.is-cover .mc__arm--l { transform: rotate(-95deg); }
.mc.is-cover .mc__arm--r { transform: rotate(95deg); }
// The eyes close with no tween — there is no half-shut frame in between
.mc.is-cover .mc__eye { transform: scaleY(.1); }

What a screen reader gets instead of the drawing

The character is marked aria-hidden="true", so the meaning rides entirely on the reveal button, whose aria-pressed and label swap between showing and hiding the password.

05Carrying loader

Timings that all divide into two seconds

A round trip takes 2s: the walk cycle is a 1s keyframe run through animation-direction: alternate, a footfall lands every 250ms, and the units column of the odometer runs its ten digits in 200ms. All three divide cleanly into the loop, which is part of why this one sweeps 33.276% of the frame at an average intensity of 192.4, the busiest of the nine.

Carrying loader
Open in a new tab

05 · Carrying loader — Measured area 33.276% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateXscaleXanimation-directionrole="progressbar"
// The lane spans the stage and the character is pinned to its right edge, so a negative
// translateX only ever pushes the box out to the left, where nothing can be scrolled to
.ml__lane {
  position: absolute; left: 0; right: 0; bottom: 0; height: 140px;
  --char-w: 104px;
  animation-name: mlWalk;
  animation-duration: 1s;
  animation-timing-function: linear;
  animation-direction: alternate;
  animation-iteration-count: infinite;
}
@keyframes mlWalk {
  0%   { transform: translateX(calc(-1 * (100% - var(--char-w)))); }
  100% { transform: translateX(0); }
}
@keyframes mlStep {
  0%, 100% { transform: translateY(0) scaleY(.9); }
  40%      { transform: translateY(-6px) scaleY(1.05); }
  80%      { transform: translateY(0) scaleY(1); }
}

Why the progress bar grows from its left edge

The bar is anchored with transform-origin: 0 50% and filled with scaleX, so it stretches from the side it starts on instead of spreading out from its middle.

06Head shake on failure

One cut beside eight interpolated beats

Submit a wrong value and the head swings through eight beats of shrinking amplitude while the field rocks on exactly the same timing. The happy and unhappy faces are stacked on top of each other and swapped at a single point, 11% to 12%, so no blended expression can exist.

Head shake on failure
Open in a new tab

06 · Head shake on failure — Measured area 9.047% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateXsteps(1, end)aria-liveanimation-name
@keyframes seHead {
  0%, 11% { transform: translateX(0) rotate(0deg); }
  18% { transform: translateX(-10px) rotate(-7deg); }
  26% { transform: translateX(10px) rotate(7deg); }
  34% { transform: translateX(-9px) rotate(-6deg); }
  42% { transform: translateX(9px) rotate(6deg); }
  48% { transform: translateX(-7px) rotate(-5deg); }
  54% { transform: translateX(7px) rotate(5deg); }
  60% { transform: translateX(-4px) rotate(-3deg); }
  66% { transform: translateX(4px) rotate(3deg); }
  72%, 100% { transform: translateX(0) rotate(0deg); }
}
// Every state swap happens at the same point (11% to 12%), cut by steps(1, end)
@keyframes seFaceOk { 0%, 11% { visibility: visible; } 12%, 100% { visibility: hidden; } }
@keyframes seFaceNo { 0%, 11% { visibility: hidden; } 12%, 100% { visibility: visible; } }
@keyframes seErrIn  { 0%, 11% { visibility: hidden; } 12%, 100% { visibility: visible; } }

What a cut costs in moved frames

A cut yields two distinct frames and nothing else, which is why a shake runs alongside it to fill the gaps, and even with that help this part lands at 19 of 23 moved frames.

07Jump on success

Crouch, launch, and land

The character takes one beat to crouch, leaves the ground somewhere between two sampled frames, and the shadow collapses to scaleX(.54) for as long as it hangs. Height is an illusion the shadow builds.

Jump on success
Open in a new tab

07 · Jump on success — Measured area 11.441% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateYanimation-delayanimation-fill-modescale
@keyframes seJump {
  0%, 4% { transform: translate(0, 0) scale(1, 1); }
  9%, 12% { transform: translate(0, 8px) scale(1.1, .8); }    // crouch — one beat of wind-up
  16% { transform: translate(0, var(--j)) scale(.92, 1.16); } // launch, between two frames
  19% { transform: translate(0, var(--j)) scale(.98, 1.04); } // apex — the body unwinds
  26% { transform: translate(0, 0) scale(1.02, .96); }        // just before landing
  29% { transform: translate(0, 0) scale(1.12, .84); }        // landing squash
  35%, 100% { transform: translate(0, 0) scale(1, 1); }       // recovery
}
@keyframes seShadow {
  0%, 4% { transform: scaleX(1); opacity: .2; }
  9%, 12% { transform: scaleX(1.12); opacity: .24; }
  16%, 19% { transform: scaleX(.54); opacity: .1; }
  26% { transform: scaleX(.92); opacity: .18; }
  29% { transform: scaleX(1.14); opacity: .24; }
  35%, 100% { transform: scaleX(1); opacity: .2; }
}

Why the stars never vanish inside the loop

Three stars stagger up behind the jump and stay floating when the loop ends — a disappearing element changes more area than an appearing one and would hand the gallery a blank cover — and the part still moves in 22 of 23 frames.

08Squash when petted

Rub distance rather than clicks

Rubbing the head flattens the body to scale(1.22, .84) from the soles, and releasing it hands the return to $ease-pop, which overshoots once on the way back. A pet is counted every 36px of accumulated pointer travel rather than once per click.

Squash when petted
Open in a new tab

08 · Squash when petted — Measured area 4.586% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

scalepointerdowntransform-origintouch-action
var LABEL = 'Petted {n} times';
var RUB_PX = 36;
var n = 0, down = false, lastX = 0, acc = 0;

function rub() {
  n = n + 1;
  tally.textContent = LABEL.replace('{n}', n);
  var h = document.createElement('i');
  h.className = 'mb__heart mb__heart--once';
  h.style.setProperty('--x', (24 + Math.round(Math.random() * 52)) + '%');
  h.innerHTML = '<svg><use href="#ico-heart"/></svg>';
  layer.appendChild(h);
  setTimeout(function () { h.remove(); }, 1500);
}
btn.addEventListener('pointermove', function (e) {
  if (!down) return;
  acc = acc + Math.abs(e.clientX - lastX);
  lastX = e.clientX;
  if (acc >= RUB_PX) { acc = 0; rub(); }
});

The keyboard path through the same code

Enter and Space each land one pet as well, and the character is a real <button> carrying touch-action: none so a phone scroll cannot steal the drag out from under the rub.

09Falling asleep when idle

A timer any input can reset

After 3.6 seconds without input, the eyes cut shut and three Z's drift upward, staggered 0.46s apart. Any pointerdown or keydown clears the timer, wakes the character with a single pop, and arms the countdown again from the start.

Falling asleep when idle
Open in a new tab

09 · Falling asleep when idle — Measured area 2.336% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateYanimation-delaysetTimeoutprefers-reduced-motion
var AWAKE = 'Awake';
var ASLEEP = 'Asleep';
var IDLE_MS = 3600;
var timer = null;

function sleep() { root.classList.add('is-asleep'); state.textContent = ASLEEP; }
function arm() { clearTimeout(timer); timer = setTimeout(sleep, IDLE_MS); }
function wake() {
  var was = root.classList.contains('is-asleep');
  root.classList.remove('is-demo');
  if (was) {
    root.classList.remove('is-asleep');
    root.classList.add('is-wake');
    setTimeout(function () { root.classList.remove('is-wake'); }, 460);
    state.textContent = AWAKE;
  }
  arm();
}
document.addEventListener('pointerdown', wake);
window.addEventListener('keydown', wake);

Why the real timer stays off during the preview

The idle countdown arms only after somebody has touched the screen, since a genuine doze landing in the middle of a capture would wreck the measurement it is supposed to feed.

Where does a mascot stand in an onboarding illustration ui?

Beside the thing that needs pressing, never on top of it

In an onboarding illustration ui, the character is not the content; it is the pointer. It stands to one side of what has to be pressed and gestures at it (03), or it sits below the field and answers what was typed there (04 and 06), or it takes over the empty rectangle a person is stuck looking at (05 and 09). Text stays where a reader expects text, and every stage color here comes from the shared token file, so the character never lands on a background that was tuned for something else. Contrast was measured on the composited result: the guide line in 03 reads 5.50:1 on #ff4d1f, the failure message in 06 reads 5.47:1 on #ffffff, and the loader readout in 05 reads 17.11:1 on #17141a.

Three variables that carry all nine

Name Value changed Feel
duration $dur-loop 2s to 1s The breath and the wave double in speed and the character reads as more excitable
easing $ease-spring to $ease-pop The jump and the squash bounce one extra time, which reads as playful
color $subject-blue to $stage-orange A calm guide turns into a loud promotional character

Where it breaks — the trap

A grid item defends its min-content width

Of the nine character animation examples here, 06 was the only one that spilled sideways, and only on a phone. The stage is display: grid, and the automatic minimum size of a grid item is its min-content width, so the root of the part held 309px against the 294.4px actually available on a 320px stage. The <input> carried a floor of its own on top of that, roughly 177px coming from its default size, and no amount of shrinking inside the form could get under either number.

Three declarations that let the row shrink

Inside the narrow-width media query, the root, the form, the box, and the input each pick up min-width: 0, and the long failure message gets overflow-wrap: anywhere so it breaks instead of propping the row open from the inside. After a re-render, body.scrollWidth dropped from 328 to 320. Deriving a bounce like the jump in 07 from math instead of hand-tuned easing is what the springs in JavaScript Physics Simulation: 9 Demos, No Library are for. The password on the archive holding all nine, plain SCSS and React alike, is wyvxy9q2, and it is worth saying plainly that the motion was never the hard part here; the box around the motion was.

@media (max-width: 340px) {
  .mb { gap: $sp-2; }
  .mb__row { gap: $sp-3; }
  // The stage is a grid, so .mb keeps its min-content width until this is released;
  // without it the row stays pinned at 309px and spills out of a 320px stage.
  // The input's intrinsic minimum from its size attribute has to be released as well.
  .mb { min-width: 0; }
  .mb__form, .mb__box, .mb__input { min-width: 0; }
  .mb__error, .mb__label { overflow-wrap: anywhere; }
}

A static width check cannot see every version of this, which is the other half of the lesson. 05 passed every measurement even though its walking lane, translated as a full-width box, grew scrollWidth during the frames it was actually moving. Anchoring the lane to the right and traveling on a negative translateX is what closed it.

Accessibility (reduced-motion)

The one part where this is a requirement

09 stops every idle animation under prefers-reduced-motion: reduce and holds a single sleeping still. Nobody asked that doze to start, and motion a reader never triggered is covered by WCAG 2.2.2 Pause, Stop, Hide, so this one is an obligation rather than a matter of taste. The other eight drop their loops and keep whichever still frame reads best: 01 holds a mid-breath body, 05 holds a partly filled bar, and 07 holds the stars already floating. Every declaration in those blocks carries !important because the .is-demo loop class outranks a bare selector on specificity. The reference pages are the MDN entry for prefers-reduced-motion and the MDN entry for scale.

What the drawing is never allowed to carry

Meaning never lives in the character. In 04 the mascot is aria-hidden="true" and the reveal button carries the state through aria-pressed and a label that changes with it. In 06 the failure is a role="alert" region with a sentence and an icon, never color on its own. In 08 the pointer drag has Enter and Space equivalents, so petting is not something only a mouse can do.

FAQ

How do I keep one mascot animation character consistent across nine parts?

Freeze two things and let everything else vary: the body proportions and the coordinates of the eyes, cheeks, and mouth. Every part here is the same ellipse with border-radius: 50% 50% 46% 46% and the same feature offsets, and only the fill color changes to suit the stage. Once those are fixed, nine different behaviors still look like one character.

Can a cut expression and a smooth movement share the same part?

They have to, in practice. A steps(1, end) swap is the only way to avoid a half-transparent ghost face, but it also produces just two distinct frames, so a part built on cuts alone barely registers as moving. Pairing the cut with a small interpolated movement beside it is what puts 06 at 19 of 23 and 07 at 22 of 23. How the input box that 04 stands beside answers focus on its own is covered in 9 CSS Input Focus Animations.

Where should a mascot not go?

Anywhere a person is trying to finish a task under time pressure, and anywhere the character would be the only thing announcing a result. Checkout totals, error summaries, and dense tables are better served by plain text. A mascot earns its space where the screen would otherwise be empty or slow, which is exactly what 05, 08, and 09 are built for.

Enter the archive password

The password is inside this article. You will find it as you read.