Project: How to Code a Pure CSS Pac-Man Animation

You will build a looping Pac-Man scene powered only by CSS. The character will chomp, glide across a track, and make pellets disappear in sync. By the end, you will have a compact component that proves how far modern CSS can go without JavaScript.

Why Pure CSS Pac-Man Animation Matters

Micro-animations make interfaces feel alive, yet every extra script adds weight and maintenance overhead. A pure CSS approach gives you smooth, hardware-accelerated motion, theming through custom properties, and zero JavaScript. You also gain a reusable shape toolkit. The mouth uses a triangle, the body uses a circle, and the pellets are circles too. If you want a refresher on shape techniques, see how to make a circle with CSS, and how to make a triangle-right with CSS. You can also compare approaches with the dedicated guide on the Pac-Man shape in CSS.

Prerequisites

You do not need animation libraries or a build step. You only need to be comfortable with basic HTML and CSS. We will lean on CSS custom properties for theme settings and use pseudo-elements for Pac-Man’s mouth and eye.

  • Basic HTML
  • CSS custom properties
  • CSS pseudo-elements (::before / ::after)

Step 1: The HTML Structure

The markup contains a stage, a horizontal track, a pellet row, and a single Pac-Man element. Pellets are individual spans so we can stagger their disappearance with delays. The stage is marked aria-hidden since this is decorative. If you want a meaningful label, you will adjust this in the accessibility section.

<!-- HTML -->
<div class="stage" aria-hidden="true">
  <div class="track">
    <div class="pellets">
      <span class="pellet"></span>
      <span class="pellet"></span>
      <span class="pellet"></span>
      <span class="pellet"></span>
      <span class="pellet"></span>
      <span class="pellet"></span>
      <span class="pellet"></span>
      <span class="pellet"></span>
      <span class="pellet"></span>
      <span class="pellet"></span>
      <span class="pellet"></span>
      <span class="pellet"></span>
    </div>
    <div class="pacman"></div>
  </div>
</div>

Step 2: The Basic CSS & Styling

Set up a centered stage with theme variables. The track is a rounded bar; Pac-Man and pellets will live inside it. The duration variable drives both movement and pellet timing so everything stays in sync.

/* CSS */
:root{
  --bg: #0b1021;
  --track: #1a2340;
  --pacman: #ffcc00;
  --eye: #1b1b1b;
  --pellet: #a9c7ff;

  --stage-w: 720px;
  --stage-h: 180px;

  --size: 64px;        /* Pac-Man diameter */
  --pellet-size: 10px;
  --pellets: 12;

  --duration: 4s;      /* total loop time */
}

* { box-sizing: border-box; }

html, body {
  height: 100%;
  margin: 0;
  background: #0a0f20;
  color: #e7ecff;
  font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, sans-serif;
}

.stage{
  width: min(92vw, var(--stage-w));
  height: var(--stage-h);
  margin: 6vh auto;
  background: var(--bg);
  border-radius: 16px;
  display: grid;
  place-items: center;
  overflow: hidden;
  box-shadow: 0 14px 40px rgba(0,0,0,.35);
}

.track{
  position: relative;
  width: calc(100% - 64px);
  height: 24px;
  background: var(--track);
  border-radius: 12px;
  box-shadow: inset 0 0 0 2px rgba(255,255,255,0.06);
}

/* Pellet row */
.pellets{
  position: absolute;
  inset: 0;
}

.pellet{
  position: absolute;
  top: 50%;
  width: var(--pellet-size);
  height: var(--pellet-size);
  background: var(--pellet);
  border-radius: 50%;
  transform: translate(-50%, -50%);
  opacity: 1;
}

/* Index variables for spacing along the track */
.pellet:nth-child(1){ --i: 1; }
.pellet:nth-child(2){ --i: 2; }
.pellet:nth-child(3){ --i: 3; }
.pellet:nth-child(4){ --i: 4; }
.pellet:nth-child(5){ --i: 5; }
.pellet:nth-child(6){ --i: 6; }
.pellet:nth-child(7){ --i: 7; }
.pellet:nth-child(8){ --i: 8; }
.pellet:nth-child(9){ --i: 9; }
.pellet:nth-child(10){ --i: 10; }
.pellet:nth-child(11){ --i: 11; }
.pellet:nth-child(12){ --i: 12; }

/* Position each pellet evenly across the track width */
.pellet{
  left: calc( (var(--i) * 100%) / (var(--pellets) + 1) );
}

Advanced Tip: Put motion, sizing, and colors behind CSS variables. It makes the animation easy to theme and keeps movement in sync when you change duration or pellet count.

Step 3: Building the Pac-Man

Pac-Man is a yellow circle with a triangular wedge on top to fake the mouth cutout. The mouth triangle matches the stage color, so it looks like a bite missing from the circle. The pseudo-element animates its border widths to open and close.

/* CSS */
.pacman{
  position: absolute;
  top: 50%;
  left: 0;
  width: var(--size);
  height: var(--size);
  border-radius: 50%;
  background: var(--pacman);
  transform: translateY(-50%);
  /* Movement across the track */
  animation: run var(--duration) linear infinite;
}

/* The mouth (triangle wedge) */
.pacman::before{
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: 0; 
  height: 0;
  border-top: calc(var(--size) / 2) solid transparent;
  border-bottom: calc(var(--size) / 2) solid transparent;
  border-left: var(--size) solid var(--bg); /* Wedge uses stage color */
  transform: translate(-50%, -50%);
  /* Open/close the mouth */
  animation: chew .28s ease-in-out infinite;
}

/* The eye */
.pacman::after{
  content: "";
  position: absolute;
  width: calc(var(--size) * 0.16);
  height: calc(var(--size) * 0.16);
  background: var(--eye);
  border-radius: 50%;
  left: 54%;
  top: 32%;
  transform: translate(-50%, -50%);
}

/* Move Pac-Man from left to right across the track */
@keyframes run{
  0%   { left: 0; }
  100% { left: calc(100% - var(--size)); }
}

/* Open (big wedge) to closed (no wedge) and back */
@keyframes chew{
  0%, 100%{
    border-top-width: calc(var(--size) / 2);
    border-bottom-width: calc(var(--size) / 2);
  }
  50%{
    border-top-width: 0;
    border-bottom-width: 0;
  }
}

How This Works (Code Breakdown)

The Pac-Man element is positioned absolutely within the track, which is relatively positioned. That lets you animate the left property across the full interior width with a single keyframe set. Using left rather than translateX keeps the percentage anchored to the track’s width, not the element’s own width, which prevents position drift.

The circular body uses border-radius: 50% and a solid background color. This shape is the same principle covered when you make a circle with CSS, so you can size it freely while keeping it crisp on any screen. The eye is a smaller circle positioned with percentages to sit in a classic arcade spot.

The mouth is the key trick. The ::before pseudo-element draws a right-pointing triangle using the border method. The triangle’s color matches the stage background, which visually removes a wedge from the circle. If you want more detail on the triangle technique, see how to make a triangle-right with CSS. The chew animation changes border-top-width and border-bottom-width from half the circle size down to zero. That collapses the wedge into a line, giving a convincing chomp with a tiny keyframe.

If you prefer a single-layer approach, you can replace the triangle with a conic-gradient mask, but the triangle wins for clarity and broad support. For alternate techniques and styling ideas, compare this with the dedicated Pac-Man shape in CSS guide.

Step 4: Building the Track and Pellets

Pellets need to be evenly spaced along the track and disappear right when Pac-Man reaches them. We assign an index to each pellet using nth-child and calculate its horizontal position. Then we add a tiny animation to each pellet with a unique delay. The delay lines up with Pac-Man’s travel time so the pellet vanishes at the exact moment Pac-Man passes over it.

/* CSS */
/* Pellets positioned by index across the track width */
.pellet{
  left: calc( (var(--i) * 100%) / (var(--pellets) + 1) );
}

/* Make pellets disappear in sync */
.pellet{
  animation: consume var(--duration) linear infinite;
  animation-delay: calc((var(--i) / (var(--pellets) + 1)) * var(--duration));
}

/* Disappear as soon as each pellet's own timeline begins */
@keyframes consume{
  0%   { opacity: 1; }
  5%   { opacity: 0; }
  100% { opacity: 0; }
}

How This Works (Code Breakdown)

The track is a simple rounded rectangle that sets the coordinate system. The pellets container spans the track, and each pellet uses an –i custom property to target its own position. The calc expression divides the track into (pellets + 1) slices so the first pellet is not flush against the edge. You get consistent spacing regardless of track width.

The timing is shared. The –duration custom property drives Pac-Man’s run keyframes and each pellet’s consume keyframes. The animation-delay formula offsets each pellet in proportion to its index. When the loop starts, every pellet is visible. As Pac-Man moves, each pellet hits its delayed start, runs the consume timeline, and fades out near-instantly. Because the cycle repeats, all pellets return at the beginning of the next iteration without extra code.

If you scale the number of pellets, keep the index list in sync. You can also swap pellets for power pellets by using a larger circle on every fourth span or by adding a modifier class that increases width and height.

Advanced Techniques: Adding Animations & Hover Effects

Now that the core loop works, you can add polish with speed controls, easing tweaks, and interactions. The snippets below show a hover pause, a speed multiplier, and an optional “flip on return” that makes Pac-Man ping-pong instead of snapping back to the start.

/* CSS */
/* Pause the whole scene on hover (great for demos) */
.stage:hover .pacman,
.stage:hover .pellet{
  animation-play-state: paused;
}

/* Speed themes driven by a multiplier */
:root{
  --speed-multiplier: 1; /* 1 = normal, 0.5 = twice as fast, 2 = slower */
}
.track, .pacman, .pellet{
  /* Recompute duration once in one place if you assign it via the track */
  --loop: calc(var(--duration) * var(--speed-multiplier));
}
.pacman{ animation-duration: var(--loop); }
.pellet{ animation-duration: var(--loop); }

/* Optional: ping-pong movement so Pac-Man turns around each cycle */
.pacman{
  animation-name: run-bounce;
  animation-timing-function: linear;
}
@keyframes run-bounce{
  0%   { left: 0; transform: translateY(-50%) scaleX(1); }
  49%  { left: calc(100% - var(--size)); transform: translateY(-50%) scaleX(1); }
  50%  { left: calc(100% - var(--size)); transform: translateY(-50%) scaleX(-1); } /* face left */
  99%  { left: 0; transform: translateY(-50%) scaleX(-1); }
  100% { left: 0; transform: translateY(-50%) scaleX(1); } /* face right */
}

/* Keep the mouth chewing regardless of direction */
.pacman::before{
  animation: chew .28s ease-in-out infinite;
}

Hover pause helps debugging and gives users control. The speed multiplier feeds a single equation for duration so you change speed without touching multiple selectors. The bounce variant flips Pac-Man horizontally at the halfway mark so it looks natural on the return trip.

Accessibility & Performance

This animation is decorative by default, but you can make it present meaning if needed. Also, give users who prefer less motion an escape hatch. The techniques here run on the compositor, which keeps frame rates smooth.

Accessibility

If the animation is just flair, keep aria-hidden=”true” on the stage so screen readers skip it. If the scene conveys meaning, move the label down to the Pac-Man element and supply a short description:

<!-- HTML -->
<div class="stage">
  <div class="track">
    <div class="pellets">...</div>
    <div class="pacman" role="img" aria-label="Pac-Man animation"></div>
  </div>
</div>

Respect reduced motion. Disable movement and show a static shape for users who prefer calm interfaces.

/* CSS */
@media (prefers-reduced-motion: reduce){
  .pacman, .pellet{ animation: none !important; }
}

Performance

Positioning with left inside an absolutely positioned container is cheap to animate and easy to reason about. The chomp effect only alters border widths on a tiny triangle, which is a low-cost property change. Each pellet runs a trivial opacity transition once per cycle, staggered by delay, so the GPU is not doing heavy blending work. Keep shadows minimal and avoid animating box-shadow or filter on large elements if you extend the scene.

Arcade polish with zero JavaScript

You built a complete Pac-Man animation: a moving character with an opening mouth, timed pellet disappearance, and a flexible theme powered by custom properties. You can now adapt the scene for hero banners, loaders, or playful 404 pages. Extend it with ghosts, corners, or power pellets, and you will have the building blocks to craft your own CSS arcade.

Leave a Comment