Skip to main content
Back to BlogImage Guides

How to Create a CSS Sprite Sheet (and Why It Still Matters After HTTP/2)

Pack many icons into one image and address each with background-position. Covers the four layouts, the negative-offset math that makes sprites work, retina @2x/@3x with background-size, padding to prevent bleed, and the JSON manifest Phaser and PixiJS load directly.

The Toolbox TeamAugust 16, 20268 min read

A technique that was supposed to die in 2015

Sprites were invented to dodge HTTP/1.1's one-request-at-a-time problem: twelve icons meant twelve round trips, so you stitched them into one PNG and paid once. HTTP/2 shipped multiplexing and the request-count argument mostly collapsed. The technique didn't die, because two of its uses have nothing to do with request count.

The first is hover-state flicker. Swap background-image on :hover and the browser fires a new request; for a frame or two the element is blank. A sprite sheet has both states already loaded, and background-position just shifts the window. No flash, no preload hack.

The second is game engines. Phaser and PixiJS want one atlas with a manifest telling them where each frame lives, not twelve texture files. The sprite generator's JSON output is the file those engines read directly.

Fastest path

Open the Sprite Generator, drop in your images, pick a layout, and download the sheet plus the CSS. For four nav icons at 48×48 with 2px padding:

Layout:  horizontal
Sheet:   200 × 48   (48 + 2 + 48 + 2 + 48 + 2 + 48 + 2, trailing pad trimmed)
CSS:     .sprite-home  { background-position: 0 0; }
         .sprite-search { background-position: -50px 0; }
         .sprite-cart   { background-position: -100px 0; }
         .sprite-user   { background-position: -150px 0; }

Every offset is negative except the first. That is not a coincidence, and it is the whole mechanic.

The four layouts, and the one that's actually hard

Layout How it places images Use when
Horizontal x accumulates left to right, height = tallest A single row of same-height icons (nav bar, toolbar)
Vertical y accumulates top to bottom, width = widest A single column (stacked menu, status indicators)
Grid Fixed cell = max of all, columns you set Mixed sizes, uniform cells, easy to reason about
Packed Shelf-based bin packing, sorted by height desc Many images of varying size, smallest sheet

Three of those are mechanical. The packed one is where the generator earns its keep. It sorts images by height, places the tallest leftmost on a shelf, and keeps adding until the next would exceed the shelf width — then starts a new shelf below. This is shelf-based packing, not optimal guillotine packing; it leaves gaps. For a dozen icons the waste is a few percent. For a thousand frames you'd want a real bin-packer, but at that point you're shipping a game and the JSON manifest matters more than byte count.

Grid is the safe default when sizes vary. It pads every cell to the largest image, so smaller ones sit centered with whitespace — wasteful in bytes, but the offsets are predictable.

The negative-offset trick

A sprite sheet is one big image. The element shows a window onto it the size of one icon. To put a specific icon in that window, you don't move the window — you move the sheet. background-position shifts the sheet relative to the element's origin; positive values move it down-right, so the top-left icon shows. To show the icon at x=150, shift the sheet left: background-position: -150px 0. Every icon after the first gets a negative offset.

.sprite        { background-image: url('sheet.png'); background-repeat: no-repeat; }
.sprite-cart   { width: 48px; height: 48px; background-position: -100px 0; }

The width and height are the window. The position is where the sheet sits behind it. Get either wrong and you see the neighbor icon's edge — the classic 1px sliver, which is what padding is for.

Padding, retina, and the background-size trick

Padding is not optional. The default 2px gap exists because sub-pixel rounding and background-size scaling can land the window half a pixel into the next icon. With zero padding, that half pixel is the neighbor's edge, visible as a hairline. With 2px, it lands in transparent space. If you're scaling for retina, keep the padding at 2px or raise it.

Retina is where people overcomplicate. The generator can render the sheet at 2x or 3x — same layout, every dimension multiplied. The CSS pixel size of each icon stays the same; you tell the browser the sheet is smaller than it really is, using background-size:

@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
  .sprite { background-image: url('sheet@2x.png'); background-size: 200px 48px; }
}

The sheet is physically 400×96 at 2x. background-size: 200px 48px tells the browser to draw it as if it were 200×48, so each icon lands in a 48×48 window at double pixel density. The offsets stay -150px 0 because they address the logical sheet, not the physical one. Render big, declare small, keep the offsets.

Format and the JSON manifest

PNG for anything with transparency — most icons. WebP for the same thing at smaller byte counts if you control the rendering target. JPEG only if every icon is a photograph, which is almost never. Pick the format for the hardest icon in the sheet, not the average.

For game engines, the JSON manifest is the point. The generator emits a frames object keyed by image name, each with frame: {x, y, w, h}, sourceSize, and spriteSourceSize — the shape Phaser and PixiJS expect. Load the PNG as a texture and the JSON as an atlas; the engine resolves atlas['run-03'] to the right sub-rectangle.

Gotchas

  • Name collisions become class collisions. The generator sanitizes filenames to [a-zA-Z0-9-_], so Run Frame 3.png becomes Run-Frame-3 and the class is .sprite-Run-Frame-3. Two files that sanitize to the same name overwrite each other in the CSS. Keep input names distinct before uploading.
  • The 4096×4096 ceiling is real. Canvas maxes out around 32k per side in Chrome, less in Safari; the generator caps at 4096 to stay safe. A packed sheet of 256×256 icons holds 256 at that size. Need more? Split into two sheets.
  • Don't sprite the hero image. Sprites are for small, repeatable, same-context assets. A 200KB hero in a sprite sheet blocks every icon behind it from painting until the sheet loads. Keep large images as their own requests.
  • JPEG ruins transparency. If any icon has alpha, JPEG turns the transparent pixels white and you get a white box around every icon. PNG or WebP, always, for icon sheets.

Summary

  • Sprites outlived HTTP/1.1 because of hover-state flicker and game-engine texture atlases, not request count.
  • Four layouts: horizontal and vertical for single rows/columns, grid for mixed sizes with predictable cells, packed for the smallest sheet when sizes vary.
  • The mechanic is negative background-position offsets — you move the sheet, not the window. Padding prevents the 1px-neighbor bleed.
  • Retina is render-at-2x, declare-at-1x with background-size, keep the same offsets. The JSON manifest is what Phaser and PixiJS read.
  • Build sheets at the Sprite Generator. To fix icon dimensions before packing, the Image Resizer; to compress the finished sheet, the Image Compressor; and to rasterize SVG icons into PNGs the sheet can ingest, SVG to PNG.