You can paint entire 8-bit worlds with nothing but CSS. By the end of this article you will build a classic alien, a tiny hero with a sword, and a shiny coin. Everything runs on a single element per character using box-shadows, custom properties, and a few small tricks that scale cleanly from 8 to 800 pixels.
Why Re-creating 8-Bit Video Game Characters with CSS Matters
CSS pixel art gives you production-friendly, dependency-free graphics that ship as code. No image requests, no spritesheets to manage, and no blurry scaling at high DPI. You gain theme control with variables, instant color swaps, and animation with a few lines of @keyframes. For UI mood, loading states, or playful illustrations, CSS characters turn a static screen into something memorable without adding weight to your bundle.
Prerequisites
You do not need canvas or SVG. You will map pixels to box-shadows, use pseudo-elements for extra layers, and drive sizes with custom properties.
- Basic HTML
- CSS custom properties
- CSS pseudo-elements (::before / ::after)
Step 1: The HTML Structure
The markup stays lean. A container holds three elements: the alien invader, the hero, and a coin. Each element is a single div that we will paint with box-shadows or simple shape styles. ARIA labels make each figure readable to assistive tech when the graphic has meaning.
<!-- HTML -->
<div class="scene">
<div class="character invader" role="img" aria-label="8-bit alien invader"></div>
<div class="character hero" role="img" aria-label="8-bit hero with sword"></div>
<div class="item coin" role="img" aria-label="gold coin"></div>
</div>
Step 2: The Basic CSS & Styling
Set a root pixel size that controls the entire scale. One shadow equals one pixel. The scene uses CSS Grid for simple layout. Characters share a base that defines the pixel size. Colors live in variables for quick palette swaps.
/* CSS */
:root {
--px: 12px; /* Size of one pixel */
--bg: #0f1020; /* Scene background */
--grid-gap: calc(var(--px) * 4);
/* Palette */
--alien: #6aff6a;
--hero-skin: #f2c7a5;
--hero-hair: #5b3a29;
--hero-cap: #d22;
--hero-shirt: #1473e6;
--hero-boot: #3a2d1f;
--coin-a: #ffd54a;
--coin-b: #ffb300;
--coin-c: #fff3c2;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
min-height: 100svh;
margin: 0;
display: grid;
place-items: center;
background: radial-gradient(1200px circle at 70% 20%, #1a1b33 0%, #0f1020 55%, #0a0b18 100%);
color: #e8e9ff;
font-family: ui-sans-serif, system-ui, Segoe UI, Roboto, Helvetica, Arial, Apple Color Emoji, Segoe UI Emoji;
}
.scene {
display: grid;
grid-template-columns: repeat(3, max-content);
gap: var(--grid-gap);
align-items: end;
}
.character,
.item {
position: relative;
width: var(--px);
height: var(--px);
/* Each element paints a grid of "pixels" via box-shadows */
}
Advanced Tip: Drive scale with a single variable. Changing
--pxfrom 12px to 20px upscales the entire scene. This makes it trivial to export a 2x or 3x version for screenshots or to fit higher density displays.
Step 3: Building the Alien Invader
The alien is a symmetric 8×8 grid. One element paints every pixel using box-shadow offsets. Each offset multiplies the pixel size variable for precise grid placement.
/* CSS */
.invader {
--c: var(--alien);
width: var(--px);
height: var(--px);
background: var(--c);
/* Row by row "pixels": x = column, y = row */
box-shadow:
/* y = 0 */ calc(var(--px) * 2) 0 0 0 var(--c),
calc(var(--px) * 3) 0 0 0 var(--c),
calc(var(--px) * 5) 0 0 0 var(--c),
calc(var(--px) * 6) 0 0 0 var(--c),
/* y = 1 */ calc(var(--px) * 1) calc(var(--px) * 1) 0 0 var(--c),
calc(var(--px) * 2) calc(var(--px) * 1) 0 0 var(--c),
calc(var(--px) * 3) calc(var(--px) * 1) 0 0 var(--c),
calc(var(--px) * 4) calc(var(--px) * 1) 0 0 var(--c),
calc(var(--px) * 5) calc(var(--px) * 1) 0 0 var(--c),
calc(var(--px) * 6) calc(var(--px) * 1) 0 0 var(--c),
/* y = 2 */ 0 calc(var(--px) * 2) 0 0 var(--c),
calc(var(--px) * 1) calc(var(--px) * 2) 0 0 var(--c),
calc(var(--px) * 2) calc(var(--px) * 2) 0 0 var(--c),
calc(var(--px) * 5) calc(var(--px) * 2) 0 0 var(--c),
calc(var(--px) * 6) calc(var(--px) * 2) 0 0 var(--c),
calc(var(--px) * 7) calc(var(--px) * 2) 0 0 var(--c),
/* y = 3 */ 0 calc(var(--px) * 3) 0 0 var(--c),
calc(var(--px) * 1) calc(var(--px) * 3) 0 0 var(--c),
calc(var(--px) * 2) calc(var(--px) * 3) 0 0 var(--c),
calc(var(--px) * 5) calc(var(--px) * 3) 0 0 var(--c),
calc(var(--px) * 6) calc(var(--px) * 3) 0 0 var(--c),
calc(var(--px) * 7) calc(var(--px) * 3) 0 0 var(--c),
/* y = 4 */ 0 calc(var(--px) * 4) 0 0 var(--c),
calc(var(--px) * 1) calc(var(--px) * 4) 0 0 var(--c),
calc(var(--px) * 2) calc(var(--px) * 4) 0 0 var(--c),
calc(var(--px) * 3) calc(var(--px) * 4) 0 0 var(--c),
calc(var(--px) * 4) calc(var(--px) * 4) 0 0 var(--c),
calc(var(--px) * 5) calc(var(--px) * 4) 0 0 var(--c),
calc(var(--px) * 6) calc(var(--px) * 4) 0 0 var(--c),
calc(var(--px) * 7) calc(var(--px) * 4) 0 0 var(--c),
/* y = 5 */ 0 calc(var(--px) * 5) 0 0 var(--c),
calc(var(--px) * 2) calc(var(--px) * 5) 0 0 var(--c),
calc(var(--px) * 5) calc(var(--px) * 5) 0 0 var(--c),
calc(var(--px) * 7) calc(var(--px) * 5) 0 0 var(--c),
/* y = 6 */ 0 calc(var(--px) * 6) 0 0 var(--c),
calc(var(--px) * 7) calc(var(--px) * 6) 0 0 var(--c),
/* y = 7 */ calc(var(--px) * 1) calc(var(--px) * 7) 0 0 var(--c),
calc(var(--px) * 6) calc(var(--px) * 7) 0 0 var(--c);
}
How This Works (Code Breakdown)
Each pixel is a 1×1 square by setting width and height to the root pixel size. The element’s own background counts as one pixel at the origin. Every entry in box-shadow paints another pixel at a specific offset. The first value moves it on the x-axis, the second on the y-axis. Both use calc with the pixel size variable for crisp alignment. The color at the end can differ per pixel, which unlocks multi-color characters later.
You can view this as a grid where each “on” pixel is a shadow. If you want a deeper primer on the building block, read how to make a square with CSS. That is the base unit of any 8-bit sprite.
Symmetry keeps the shadow list shorter. Build one half and mirror it across the center by duplicating offsets with mirrored x values. This reduces mistakes and speeds up creation.
Step 4: Building the Hero (with a Sword Tip)
The hero uses multiple colors. You can list a color per shadow, or layer with pseudo-elements. The example below does both: box-shadows with per-pixel colors for cap, hair, skin, shirt, and boots, plus a triangle sword tip drawn in a pseudo-element.
/* CSS */
.hero {
width: var(--px);
height: var(--px);
background: transparent;
/* Multicolor grid with per-entry colors */
box-shadow:
/* Cap (R) */
calc(var(--px) * 2) 0 0 0 var(--hero-cap),
calc(var(--px) * 3) 0 0 0 var(--hero-cap),
calc(var(--px) * 4) 0 0 0 var(--hero-cap),
calc(var(--px) * 1) calc(var(--px) * 1) 0 0 var(--hero-cap),
calc(var(--px) * 2) calc(var(--px) * 1) 0 0 var(--hero-cap),
calc(var(--px) * 3) calc(var(--px) * 1) 0 0 var(--hero-cap),
calc(var(--px) * 4) calc(var(--px) * 1) 0 0 var(--hero-cap),
calc(var(--px) * 5) calc(var(--px) * 1) 0 0 var(--hero-cap),
/* Hair (H) */
calc(var(--px) * 1) calc(var(--px) * 2) 0 0 var(--hero-hair),
calc(var(--px) * 2) calc(var(--px) * 2) 0 0 var(--hero-hair),
calc(var(--px) * 3) calc(var(--px) * 2) 0 0 var(--hero-hair),
/* Face (S) */
calc(var(--px) * 2) calc(var(--px) * 2) 0 0 var(--hero-skin),
calc(var(--px) * 3) calc(var(--px) * 2) 0 0 var(--hero-skin),
calc(var(--px) * 4) calc(var(--px) * 2) 0 0 var(--hero-skin),
calc(var(--px) * 1) calc(var(--px) * 3) 0 0 var(--hero-skin),
calc(var(--px) * 2) calc(var(--px) * 3) 0 0 var(--hero-skin),
calc(var(--px) * 3) calc(var(--px) * 3) 0 0 var(--hero-skin),
calc(var(--px) * 4) calc(var(--px) * 3) 0 0 var(--hero-skin),
/* Shirt (J) */
calc(var(--px) * 1) calc(var(--px) * 5) 0 0 var(--hero-shirt),
calc(var(--px) * 2) calc(var(--px) * 5) 0 0 var(--hero-shirt),
calc(var(--px) * 3) calc(var(--px) * 5) 0 0 var(--hero-shirt),
calc(var(--px) * 4) calc(var(--px) * 5) 0 0 var(--hero-shirt),
0 calc(var(--px) * 6) 0 0 var(--hero-shirt),
calc(var(--px) * 1) calc(var(--px) * 6) 0 0 var(--hero-shirt),
calc(var(--px) * 2) calc(var(--px) * 6) 0 0 var(--hero-shirt),
calc(var(--px) * 3) calc(var(--px) * 6) 0 0 var(--hero-shirt),
calc(var(--px) * 4) calc(var(--px) * 6) 0 0 var(--hero-shirt),
calc(var(--px) * 5) calc(var(--px) * 6) 0 0 var(--hero-shirt),
/* Boots (B) */
0 calc(var(--px) * 8) 0 0 var(--hero-boot),
calc(var(--px) * 1) calc(var(--px) * 8) 0 0 var(--hero-boot),
calc(var(--px) * 4) calc(var(--px) * 8) 0 0 var(--hero-boot),
calc(var(--px) * 5) calc(var(--px) * 8) 0 0 var(--hero-boot),
0 calc(var(--px) * 9) 0 0 var(--hero-boot),
calc(var(--px) * 5) calc(var(--px) * 9) 0 0 var(--hero-boot);
}
/* Sword tip using a right-pointing triangle */
.hero::after {
content: "";
position: absolute;
left: calc(var(--px) * 6);
top: calc(var(--px) * 6);
width: 0;
height: 0;
border-top: calc(var(--px) * 0.5) solid transparent;
border-bottom: calc(var(--px) * 0.5) solid transparent;
border-left: calc(var(--px) * 2) solid #d8dee9;
}
How This Works (Code Breakdown)
Color per pixel keeps the hero readable without extra markup. You can set a different color at the end of each shadow entry, so hair, skin, shirt, and boots become distinct blocks. The element itself has a transparent background, which prevents an extra pixel at the origin from showing.
The sword tip uses the classic border triangle trick. It is a perfect match for pixel art since it hugs the grid and keeps a crisp silhouette. If you want a refresher on the technique, see how to make a right-pointing triangle in CSS. The pseudo-element positions from the hero element with left and top offsets based on the pixel size, which preserves scale when you change the root variable.
If you prefer to keep every pixel blocky, swap the triangle tip for a small group of square shadows and you will get a classic chiptune look across the whole sprite.
Advanced Techniques: Animations & Hover Effects
Small motion brings these sprites to life. A gentle bob on the alien, a sword glint on hover, and a spinning coin create game-like energy without JS. Keep motion short and low amplitude to match the 8-bit style.
/* CSS */
@keyframes bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(calc(var(--px) * -0.5)); }
}
@keyframes coin-spin {
0% { transform: scaleX(1); }
50% { transform: scaleX(0.2); }
100% { transform: scaleX(1); }
}
.invader {
animation: bob 2.4s ease-in-out infinite;
}
/* Sword glint: quick triangle color shift on hover */
.hero:hover::after {
border-left-color: #ffffff;
filter: drop-shadow(0 0 4px #fff6);
}
/* Coin: a simple circle with a highlight and a spin */
.coin {
width: calc(var(--px) * 6);
height: calc(var(--px) * 6);
border-radius: 50%;
background:
radial-gradient(circle at 30% 30%, var(--coin-c) 0 25%, transparent 26%),
linear-gradient(145deg, var(--coin-a), var(--coin-b));
box-shadow:
inset 0 0 0 calc(var(--px) * 0.3) #0003,
0 calc(var(--px) * 0.5) 0 0 #0006;
transform-origin: center;
animation: coin-spin 1.2s cubic-bezier(.5,0,.5,1) infinite;
}
/* Respect user preference and cut motion if requested */
@media (prefers-reduced-motion: reduce) {
.invader,
.coin {
animation: none;
}
}
The coin uses border-radius to create a perfect disk and blends two gradients for depth. If you need a primer on the technique, here is how to make a circle with CSS. The spin is a simple scaleX tween, which suggests a flip without 3D. The alien bob animation matches old-school idle cycles and calls attention to the sprite without being distracting.
Accessibility & Performance
Even decorative art benefits from clear intent. Set the right roles and labels, and avoid motion that clashes with user preferences. Keep an eye on render cost when shadow counts grow.
Accessibility
If a sprite conveys meaning, give it role=”img” and an aria-label that describes the content succinctly. If it is purely decorative, set aria-hidden=”true” and remove the role. For motion, honor prefers-reduced-motion with an @media feature that disables or simplifies animations. Keep hover-only affordances paired with focus styles for keyboard users. For focusable sprites, add tabindex=”0″ and a visible outline for navigation clarity.
Performance
Box-shadows render on the CPU. A few dozen shadows per element is fine on modern devices. Hundreds can add up. Group pixels by color and use pseudo-elements to split heavy lists. Limit animating properties to transforms and opacity. Avoid animating box-shadow itself. Use a single scaling variable for responsive size instead of dynamically rebuilding shadow lists. Cache-heavy scenes by avoiding layout thrash: keep sprites as display: block or max-content, and refrain from animating width or height.
Note: If you plan a full cast of characters, consider building a small generator that converts a grid array into a box-shadow string. You can keep your sprite source in JSON and output CSS as part of your build.
Pixels, Patterns, and Play
You built an alien, a hero, and a coin using nothing but CSS. You saw how a single variable scales the scene, how per-pixel colors shape a character, and how a triangle pseudo-element can add a clean sword tip. Now you have the tools to draw your own cast, from foes to NPCs, and wire them into your UI as delightful, zero-asset sprites.