DEV Community

Cover image for How to put crisp, animated pixel art in a GitHub profile README
rivskiy skulitom
rivskiy skulitom

Posted on Fully Autonomous

How to put crisp, animated pixel art in a GitHub profile README

Written with AI help (Claude), checked against the project's docs.

The header, tool cards, link buttons and footer of the GitHub profile at github.com/skulitom are now pixel art that moves: a night workbench where a flask boils and steam rolls up past a window, and a small scene for each tool. AI agents made all of it with Pixelbolt, a pixel-art tool that agents drive with JSON recipes. The liquid, mist and steam are real fluid simulations, shaded as pixel art. And it stays sharp at every width GitHub shows it at.

A night workbench in pixel art: a mint liquid boils in a round flask and its steam rolls up in shaded billows past a moonlit window

This post covers the README trick that keeps the pixels crisp, the simulations behind the steam, and how one command rebuilds every pixel to the same bytes.

The problem: GitHub scales your GIF and blurs it

A profile README is Markdown plus a little HTML. No JavaScript, no <style>, no style="". You get images.

Pixel art needs whole pixels. The usual move is a GIF with width="100%", so it fills the column. But the browser scales that image with smoothing, and GitHub's column is almost never a whole multiple of your art. These are the widths we measured:

Viewport README column Cell of a two-column table
1440 px 846 px 395.5 px
1280 px 831 px 388 px
1012 px 563 px 254 px
768 px 383 px 164 px
390 px phone (2x screen) 308 px 126.5 px

Here is one frame of a 192x100 card at 846 px wide, rendered by Chrome at 1x on a page that sizes images the way GitHub does. Left, as a plain image. Right, the same frame through the SVG wrapper described below.

The same pixel-art frame twice at 846 px wide: on the left, a plain image with soft, smeared edges; on the right, the SVG wrapper with hard pixel edges

The trick: an SVG that plays a sprite sheet

An SVG file is an image as far as the README is concerned, but inside it you can write CSS. Two properties do the work:

  • image-rendering: pixelated on the embedded sprite sheet, so it scales without smoothing.
  • A @keyframes animation with step-end timing that moves the sheet one frame at a time behind a clip.

This is the Inkbolt card's wrapper, trimmed (one keyframe stop per frame, 72 in all):

<svg xmlns="http://www.w3.org/2000/svg" width="384" height="200" viewBox="0 0 192 100"
     shape-rendering="crispEdges">
<style>
image{image-rendering:pixelated;image-rendering:crisp-edges;image-rendering:pixelated}
.a{animation-timing-function:step-end;animation-iteration-count:infinite}
@keyframes k0{0.0000%{transform:translate(-192px,0px)}1.3889%{transform:translate(-384px,0px)} /* ... */}
.l0{animation-name:k0;animation-duration:5040ms;transform:translate(-192px,0px)}
</style>
<defs><clipPath id="c0"><rect x="0" y="0" width="192" height="100"/></clipPath></defs>
<g clip-path="url(#c0)">
  <image class="a l0" width="1536" height="1000" href="data:image/png;base64,..."/>
</g>
</svg>
Enter fullscreen mode Exit fullscreen mode

The sheet is 1536x1000: eight columns and ten rows of 192x100 frames. 72 frames at 70 ms each is 5,040 ms, so each frame owns 100/72 = 1.3889% of the animation. The viewBox is in art pixels, so the README can scale the whole SVG to any width and every art pixel stays a hard-edged block.

In the README it is an ordinary image:

<picture>
  <source media="(prefers-reduced-motion: reduce)" srcset="assets/v4/inkbolt-still.svg">
  <img src="assets/v4/inkbolt.svg" width="100%" alt="An ink drop falls into water and blooms into a vortex ring">
</picture>
Enter fullscreen mode Exit fullscreen mode

Pixel-art card: an ink drop falls from a dip pen into a glass tank of water and blooms into a vortex ring

We tested this in Chrome 155, Firefox 157 and WebKit 26.6 at 1x and 2x, and on github.com itself, before drawing any real art. It is cheap: a 96-frame, 208x162 stress sheet came to about 530 KB, and the finished profile's 40 SVGs (the header, phone header, four cards, footer and seven link buttons with their phone-width versions, each with a motionless twin) total 531,619 bytes.

Things that caught us out:

  • Everything must be inline. An SVG shown as an image can't load other files, so the PNG sheet goes in as a data: URI.
  • Reduced motion doesn't reach inside. The SVG can carry a prefers-reduced-motion query, but no engine we tested applied it to an SVG shown through <img>. The motionless twin goes in a <picture> source instead.
  • The animation starts at frame 0 on every page load. If frame 0 is a quiet hold, that's what visitors stare at. Order the sheet so it opens on a moment worth seeing; the loop is the same.
  • Two halves leave a hairline. Pixelbolt caps a frame at 256x256, so the 416x162 header is two 208x162 halves side by side. At fractional widths (831 and 563 px) Chrome and Firefox drew a 1 px seam between them. Making the left background one column wider (209 px), so the right half covers an identical column, removed it.
  • Text belongs in the SVG, not the pixels. Labels are SVG <text> drawn over the art, so they stay sharp at any size. Screen readers read the <img>'s alt, so describe the scene there.

The steam is a simulation

Pixelbolt compiles a fluid source into an ordinary recipe of palette pixels. Gases use stable fluids (advection, a pressure solve, vorticity confinement, buoyancy from temperature); liquids are FLIP particles, four per cell, on the same grid. "Look" layers then map fields such as density, speed, depth, light and shadow onto palette colours with ordered dither, so no colour outside the palette can appear, and looping effects blend their last frames into the first.

The header's steam is a 128x60 gas grid over 96 frames, fed by eight billows a loop at uneven intervals. One billow and the look, trimmed from the real source:

{
  "format": "pixelforge-fluid",
  "width": 128, "height": 60,
  "effects": { "steam": {
    "frames": 96, "duration": 70, "loop": true, "seed": 5,
    "buoyancy": 0.2, "vorticity": 0.35, "dissipation": 0.04, "drag": 0.09,
    "emitters": [
      { "name": "billow-1", "at": [86, 53], "radius": 3, "rate": 1, "start": 0, "end": 3,
        "density": 3.6, "temperature": 0.9, "velocity": [-0.3, -1.4], "expand": 2.0, "vortex": 0.3 }
    ],
    "look": [
      { "field": "density", "colors": ["u", "v"], "thresholds": [0.16, 0.34], "dither": 0.85 },
      { "field": "shadow",  "colors": ["v", "t"], "thresholds": [0.25, 0.55], "dither": 0 },
      { "field": "light",   "colors": ["x", "F", "G"], "thresholds": [0.22, 0.5, 0.82], "dither": 0 }
    ]
  } }
}
Enter fullscreen mode Exit fullscreen mode

(pixelforge-fluid is the format's name from before the tool was renamed Pixelbolt.) The boiling liquid under the steam is a separate 44x50 FLIP simulation. Every piece shares one palette of 70 colours in 12 ramps; the header loop uses 44 of them.

The agents didn't get there first time. Three things they found:

  • At one cell per pixel, gas is too damped to roll a vortex ring by itself. A heavy blob, a starting jet and a burst vortex pair all stayed small and slow, across 14 parameter sweeps (the first is below: one setting per row, sampled over time). What worked was a velocity-only emitter riding a quarter turn of an orbit, so the push eases out as the ink sinks, with two spinning emitters on either side to spread the ring. The curls themselves are the simulation's. Unequal spins keep it lopsided: a symmetric ring reads as a face.
  • Too much swirl makes eyes. At vortex 3 and above, the flask's liquid hollowed into round voids that looked like eyes. Vortex 2.2 gives a clean swirl and slosh up one wall.
  • A rising column drags thin smoke down beside it. A gentle updraft, "wind": [0, -0.08], removed the downward tails without changing the look.

Five rows of a heavy blob of ink sinking in water, each row a different weight and swirl setting, sampled at three moments: none rolls up into a clean ring

One command rebuilds every pixel

The recipes live in the profile repo's art/ folder. Small Python generators write the JSON recipes and fluid sources, Pixelbolt runs the simulations and draws every pixel, and a short wrapper script packs the sheets into the SVGs:

bash art/build.sh
Enter fullscreen mode Exit fullscreen mode

It fetches Pixelbolt at the pinned commit (8c5f662), needs Node 20+ and Python 3.11+ with Pillow, and takes about 45 seconds. From a clean copy it rebuilt all 40 SVGs byte for byte. That works because Pixelbolt's simulation is deterministic by design: a fixed timestep, fixed iteration counts, seeded noise, and only arithmetic, square roots and polynomial trigonometry, so a source gives the same recipe on every machine and in the browser. (The one caveat in the notes: the Python generators use Python's math module, so another platform's maths library could in principle move a pixel.)

The site re-simulates it in your browser

The portfolio at skulitom.github.io uses the same art, played on a canvas. Under the workbench there are three buttons. "Add heat" and "Stir the flask" don't switch to a pre-made clip: Pixelbolt's fluid compiler runs in a Web Worker in the page, re-simulates the steam or the liquid with your change, and plays the new loop.

The workbench on the site:

On the live site, on a desktop Ryzen 9 7950X3D, "Add heat" took 932 ms in Chrome, 795 ms in Firefox and 793 ms in WebKit. (The recording above, of the same page just before it went live, shows 957 ms.) To get under a second, the live version simulates a 48-frame loop with 12 pressure iterations; the pre-rendered art uses longer settings. One limit showed up: an emitter's start/end window can't wrap around the loop, so a click near the end of the loop is moved to frame 0.

Pixel-art card: molten metal pours from a crucible into a star-shaped mould, cools along the palette and pops out as a finished sprite

Try it

Pixelbolt has no npm dependencies. From a clone:

node bin/pixelbolt.js compile examples/steam-vent.fluid.json --out output/steam.json
node bin/pixelbolt.js gif output/steam.json --scale 4 --out output/steam.gif
Enter fullscreen mode Exit fullscreen mode

The repo has ten fluid presets (campfire, torch, steam, magic, explosion, smoke puff, waterfall, drip, potion, splash), and its fluid guide covers emitters, looks and loops. Limits worth knowing: a fluid canvas is at most 128x128, a frame at most 256x256, gravity and obstacles are static, and emitters move only on circles and ellipses (or by chaining time windows along a path). It runs on the CPU, with no GPU and no model calls.

To see how the wrapper is built, read art/tools/wrap.py in the profile repo: it turns a Pixelbolt sheet and atlas into a self-contained animated SVG and its still twin.

Top comments (0)