Skip to content

About

Pixel panels for a GitHub profile README - 20 SVG components on one 2px grid and one embedded bitmap face. Both palettes in every file. No build step, no runtime.

Topics

Resources

Stars

5 stars

Watchers

0 watching

Forks

Latest commit

 

History

4 Commits

Folders and files

Repository files navigation

PIXEL PANELS, set as a large hollow pixel headline with a scan crossing the letterforms

Pixel panels for a GitHub profile README. Twenty SVG components — meters, dials, a contribution field, project cards, a transcript, a contact row — built from one bitmap typeface and one 2px grid, so a page made of them looks like a single instrument rather than a shelf of widgets.

No build step, no package manager, no runtime. node and git are the whole toolchain. Every component is a function that returns a string.

Every panel carries both palettes and follows your system theme. They were drawn in dark and then made to work in light, and it shows — if you are reading this in light mode you are seeing the second-best version of it. There is a side-by-side further down, and your theme switch on this page is the real demonstration.

Three sentences typed out in a bitmap face

What this is, and what it is not

Is: a set of parts you copy into your own repository and wire to your own data. The design system comes with them, which is the point — the reason a page of these holds together is that they all obey the same four rules.

Is not: a service, an action you install, or a URL you point at. Nothing here phones home, and nothing renders on somebody else's server.

Three properties that turned out to matter more than they sound:

  • One file per layout, not per theme. Both palettes ship inside every SVG as custom properties behind a prefers-color-scheme block. Verified to work through <img> by rendering a probe to a canvas and reading the pixels back. Switching theme repaints instead of re-downloading, and <picture> is left with a single-condition width query.
  • The type is embedded. An SVG shown through <img> cannot fetch a font, so a subset WOFF2 (~2 KB) is inlined in every file. Without it the whole design falls back to whatever monospace the reader happens to have.
  • Motion never hides content. Every effect animates transform only. An <img>-loaded SVG does not reliably start its animations, and an effect that begins at opacity: 0 can leave a panel permanently blank — this was found the hard way. The same trap has a quieter form: an entrance that starts at 40% scale leaves the element at 40% until it runs, which is invisible on a heat cell and turns a dial into a ring of dots. Ask what the from state looks like, because that is a state real readers will see.

Both palettes, one file

The same panel, with its theme pinned so you can see the one you are not in:

The language share bar in the light palette

The same panel in the dark palette, where the data ramp climbs toward light instead of away from it

These two are the only files in the gallery that ignore your setting; every other one follows it. They exist because half the design is otherwise unreachable — a reader in light mode has no way to look at the dark palette, and dark is the one these were drawn for.

They are also not inversions of each other, which is the part worth looking at. The data ramp climbs toward light on a dark page and toward dark on a light one, so "more" is always further from the background rather than always brighter. Inverting a dark ramp gives you a light theme whose largest value is its palest colour, which is the wrong way round and the usual reason a light mode looks washed out. Borders, the panel fill and the shadow are each picked per theme for the same reason — the shadow is black on dark and a cool grey on light, because black on white is a hole rather than a shadow.


Quick start

git clone https://github.com/yunmin311/pixel-panels
cd pixel-panels
cp config.example.json config.json     # then edit the text
node scripts/build-preview.mjs         # renders everything into preview/

Point a <picture> at the result. One condition, nothing else:

<picture>
  <source media="(max-width: 500px)" srcset="preview/rhythm-m.svg">
  <img alt="…" title="…" src="preview/rhythm.svg">
</picture>

alt carries the panel's content as text, which the components generate for you. title gives the one hover a README can have — a native tooltip. Real :hover is not possible: GitHub strips script, and an <img>-loaded SVG receives no pointer events.


The gallery

Everything below is the demo data in scripts/demo.mjs.

hero — a typewriter

Shown at the top of this page. Three sentences typed and erased on a loop, one glyph per step. The caret is welded to the leading edge of a page-coloured cover that slides right in steps(n), so it tracks the reveal for free and erasing is the same motion backwards. On a phone the sentences wrap to two lines and the typing time splits between them.

"hero": { "lines": ["…", "…", "…"] }

about — the identity block

An about panel: name, two paragraphs, exploring chips, principles

Name at 22px, prose at 11px, plus two label rows folded in so the section is one object rather than three loose pieces. The chips light one after another.

"about":      { "name": "…", "meta": "…", "paragraphs": ["…", "…"] },
"exploring":  ["…", "…"],
"principles": ["…", "…"]

sections — numbered rules

A section header: 01 // About me A section header with a subtitle: 02 // Selected work

Set at 11px like everything else, not a size larger. The number in accent and a rule running to the margin say "new section" without raising a voice; a header bigger than its content turns a page into a stack of announcements.

"sections": [{ "key": "01", "num": "01", "title": "About me", "sub": "optional" }]

work — project cards

Plotter — a stream of measurements is not information until something shapes it. TinyFS — small enough to read in an afternoon. Ledger — plain-text accounting with no opinions about your money. Signal Desk — collapses a day of notifications into the few that changed something.

A card is one image so a README can wrap one link around the whole thing. Each leads with why the thing exists and puts the stack underneath — the stack is the least interesting true fact about any project. No stars, forks or issue counts: those are the numbers a page reaches for when it has nothing to say about the work. A marquee crawls the border, the marker every early graphical interface used for "this one is pickable".

"work": [{ "key": "…", "name": "…", "why": "…", "tags": ["…"], "url": "…" }]

rhythm — segmented meters

Activity by hour and by weekday, drawn as segmented level meters

Every column shows its full scale in an empty colour and fills upward, the way a level meter on a piece of hardware does — unused capacity is information too. The ceiling cell of each column flickers. On a phone the two meters stack and the readout drops to one fact per row.

Expects { hours[24], days[7], total, spanDays, peakHour, peakWindow, busiestDay, nightShare, offsetLabel }.

languages — a share bar

A language share bar with a six-row legend

One hue at four designed steps, not a rainbow: the ranking is the information, and six unrelated colours make it harder to read. One slot is left blank between languages so the steps read as ranked segments rather than one ribbon. A highlight sweeps the bar.

The panel prints its own method line, because an unqualified language chart is usually measuring build output.

Expects { top: [{ name, pct, amount }], repoCount, caption, summary, note }.

progress — bars for things that are partly done

Five labelled progress bars drawn as segmented cells

The whole scale is always drawn and the unfilled cells keep their own colour, so you can count what is left — "45%" is a number, eleven empty cells is a quantity. Percentages round to the nearest cell and nothing finer: a bar whose last cell is half-lit is claiming a precision the grid cannot carry.

One label column and one number column for the entire panel, not per row. Bars that each start somewhere else cannot be compared by eye, which is the only reason to draw them as bars.

"progress": [{ "label": "TESTS", "pct": 45 }]

gauge — a dial made of right angles

Two dials, each a stepped semicircular arc with a reading beside it

The one shape a pixel system is supposed to be bad at. Twenty-one blocks placed on a semicircle and snapped to the grid after the trigonometry, never before — left on their exact positions they land on fractions, the renderer antialiases them, and the arc becomes a soft grey smear.

No needle. A line from the centre is a diagonal, and a grid draws a diagonal as a staircase that reads as a mistake rather than as a style; the reading is marked by one oversized block on the arc instead. Dial left, number right, because a 400px tile with a dial centred in it is mostly empty tile.

This one also has no entrance animation, which was not the original plan. The arc used to assemble itself with bloom — and bloom rests at 40% scale until it runs, so a render that caught it early showed the dial as a ring of 2px dots. A heat cell survives being scaled down. An instrument does not.

"gauge": [{ "label": "PAGE WEIGHT", "value": 214, "max": 500, "display": "214" }]

stars and activity — two lists

Recently starred repositories, with a rail marking one row Recent activity: releases, repos opened, merged pull requests

A matched pair, so they behave identically: a rail down the left margin with one lit segment exactly one row tall. An earlier version used a floating arrow and could never be aligned, because a free-floating pointer has no structural anchor — "is it pointing at the right thing" has no right answer. The rail's segment is the row, so alignment is geometry.

Three inks per row and no more: the name carries the blue, the description is the one grey, everything subordinate is faint.

timeline — dated entries on a rail

Four dated milestones hung off a vertical rail

The same rail the two list panels use, turned on its end into a spine. A milestone list drawn as bullets has to imply a relationship between its rows; a rail states one, because the rail is continuous and the nodes are on it. It stops at the last node — a rail that overshoots its own contents is decoration wearing structure's clothes.

Every node is the same size, including the newest. Scaling the most recent entry is the standard move and it says "this one matters more" when what is actually true is "this one happened last", which the date column already said. A block runs down the rail, so the motion never crosses a word.

"timeline": [{ "when": "AUG 2026", "what": "…", "note": "v1.1.0" }]

contributions — a year as a field

A year of contributions as a filled field with a diagonal pixel wave

Days with activity fill their cell; empty days are a small centred dot, so the graph-paper texture stays where it belongs and every real day reads at full strength. Thresholds are the quartiles of the days that have activity, not fractions of the maximum — one loud day would otherwise drag everything else into the lowest band and waste three quarters of the ramp.

The wave is diagonal: cells are bucketed by (column + row) % 8, so each diagonal rises together and the crest travels. Cells change size, not position, and the timing is step-end — a grid with no sub-pixels cannot ease, and pretending otherwise is what makes an effect look like CSS instead of like a display. Amplitude is ±8% and only cells with data move; the first attempt ran 0.6×–1.35× across every cell and was a strobe.

"motion": { "contributionStyle": "pixel", "contributionPeriod": 3000 }

"contributionStyle": "wave" switches to the smooth version, which is kept.

tiles — a row of readouts

Four framed readout tiles

Separate framed boxes rather than one divided panel, so the row survives wrapping onto a phone. Each tile's lamp takes its turn.

"tiles": [{ "label": "SHIPPED", "value": "4 tools", "note": "MIT" }]

quote — a framed statement

A framed quotation with a blue pixel spine

The blue spine sits inside the frame. A coloured stripe down the outside edge of a card is the single most reached-for way to make a block look designed, and it is on the anti-AI-taste list for exactly that reason.

"quote": { "text": "…", "by": "…" }

terminal — a session transcript

A short shell transcript with a blinking caret

The one panel allowed to look like a terminal, so none of the others have to. A README that wants to show what running the thing looks like otherwise reaches for a fenced code block, which arrives wearing GitHub's type, GitHub's background and GitHub's corner radius, and puts a foreign object in the middle of the page.

Two inks and no more: a line starting with the prompt is a command and takes the accent, everything else is output and takes the one grey. A transcript with four colours in it is a screenshot of somebody's colour scheme. A band drifts down the panel — the idiom this component is borrowing from anyway.

"terminal": { "title": "build", "prompt": "$", "lines": ["$ node …", "  40 file(s)"] }

table — rows and columns on the grid

A five-row table with a header rule and alternating row bands

Column widths are measured rather than guessed, which a monospaced face makes exact: the width of a string is its length times a known advance. Nothing has to be estimated and nothing is clipped "just in case" — and when the columns do overrun, the first one gives up the difference, because it holds the names and names are the only cells that survive being cut.

No vertical rules. At this density they collide with the type on every row, and the gutters have already separated the columns. The field cursor rides the header rule for the same reason: the first version stepped a full-height line across the table and read as a misplaced border — the exact rule the panel refuses to draw, reintroduced as an animation.

"table": { "head": ["WHERE", "WIDTH"], "align": ["start", "end"], "rows": [["…", "831 px"]] }

ticker — a strip crawling past

A single-line strip of short phrases separated by slashes

The one place content itself is allowed to move, because here the component is the motion. The loop is seamless because the strip carries its content twice and travels exactly one copy's width, so the reset lands on an identical frame; any other distance produces a visible jump, and a marquee that jumps is worse than no marquee.

Speed comes from length, not from a fixed duration — three items and nine both travel at the same pixels per second. Fix the duration instead and a long strip is a blur while a short one crawls.

"ticker": { "sep": "//", "items": ["No build step", "No runtime"] }

display — oversized pixel headline

Shown at the top, where it is this repository's wordmark. Three treatments — solid, hollow, shadow. Hollow is a stroke on a bitmap face, which traces every step in the glyph; that is why it reads as pixel art rather than as an outlined font. Sizes are 33 / 44 / 55 and nothing between, because the face is drawn on an 11-pixel em and anything else puts glyph edges on fractions. A phrase too long for one line at 33 is set one word per line instead of being shrunk off the list — a two-line lockup is a normal thing for a wordmark to be, where a headline at 27px is just a blurry headline.

The scan is masked by the letterforms. The first version slid an accent bar across the word on top of it: a rectangle with no relationship to the glyphs it was crossing, which is exactly what it looked like. This one exists only inside the letters, so the word fills in ahead of the head and empties out behind it, the way a character-cell display paints. It advances in whole font-pixels via steps(), so the leading edge always lands on a glyph edge rather than halfway through one, and the head is three units of solid accent followed by two dimmer columns — a phosphor tail, not a gradient.

Two details in the markup are load-bearing and easy to get wrong. The mask region is declared userSpaceOnUse; left at the default it resolves against the bounding box of the element referencing it, which is the moving band, so the band carries its own mask along and never appears to move. And the animated group is nested inside the masked one, because a transform applies to an element's mask as well as to the element.

"display": { "text": "PIXEL PANELS", "treatment": "hollow", "colour": "ink", "sweep": true, "sweepPeriod": 4600 }

contact — keycap buttons

Email Website Mastodon

One button shape, one border, one blue pixel each, and no brand colours: Gmail red beside a green beside a gradient is three other companies' design systems fighting on your page. Each button is its own file because a README can only wrap a link around a whole image.

"contact": [{ "key": "email", "label": "EMAIL", "href": "mailto:…", "enabled": true }]

fortune — one rotating line

A single line of text with a blinking caret

Picked by a date-derived index, so the page changes between visits without anything being random at build time.

"fortunes": ["…", "…"]

The four rules

They are the whole difference between a set of parts and a pile of widgets.

1 · Type is 11px or 22px, nothing between. The face is drawn on an 11-pixel em — unitsPerEm 550, advance 350, cap height 400, all multiples of 50 — so at 11px one font-pixel is one screen pixel and the advance is a whole number. Any size in between lands glyph edges on half pixels and the face goes soft, which is what "the pixel font looks bad" usually means. Hierarchy comes from case, tracking and colour instead.

2 · Chrome is a multiple of 2px. Borders, markers, meter segments, heat cells. Things get bigger by occupying more cells, never by scaling the cell — mixing pixel sizes between elements is what breaks the illusion that they share a surface.

3 · Type is sized against the box it sits in, never the canvas — under 6% of it. Measured from github-readme-stats, where the headline number is 24/450 of its card. Breaking this is subtle and expensive: 22px numerals dropped into 208px readout cells is 10.6%, and they collide with the border and with each other while looking perfectly fine in isolation.

4 · Motion animates transform only, never opacity. See above. Also: motion may not repaint data. Scan heads and highlights pass over a chart at low opacity; a peak indicator is applied to the cell already marked as the peak. An animation that alters a reading is a lie that moves.

Colour budget is roughly 80% neutral, 15% muted blue, 5% accent, and the accent has four jobs: a panel's corner lamp, the peak in a chart, the newest item in a list, and a caret.

Full reasoning and the measured sources in DESIGN.md.


Writing a component

// scripts/panels/thing.mjs
import { panel, svgDoc, body, label, W_FULL, W_MOBILE, SHADOW, S } from "../lib/design.mjs"
import { styles, enabled } from "../lib/motion.mjs"

export const id = "thing"
export const responsive = true          // also emit a 284px phone variant

export function render(t, ctx, cfg, { mobile = false } = {}) {
  const W = mobile ? W_MOBILE : W_FULL
  const out = [panel(t, { x: 0, y: S.xs, w: W, h: 120, title: "Thing", meta: "note" })]
  out.push(body("hello", { x: S.sm, y: 48, fill: t.ink }))
  return { w: W, h: 136, body: out.join(""), css: styles(cfg, "thing", ""), title: "Thing — hello" }
}

export const build = (t, ctx, cfg, v) => {
  const r = render(t, ctx, cfg, v)
  return svgDoc({ ...r, theme: t, bleed: SHADOW })
}

Add it to the arrays in scripts/build-preview.mjs. That is the whole wiring.

t is the theme: every value is a CSS custom-property reference, so fill="${t.ink}" emits fill="var(--ink)" and the document decides what that resolves to. title becomes the image's alt text, so write it as a sentence.

Primitives

In scripts/lib/design.mjs:

pixelFrame a border with stepped corners — a grid cannot curve, so it steps
pixelRule a divider made of blocks rather than a hairline
listRail a track down a list's margin with one lit segment one row tall
pixelCaret a stacked-block pointer. Archived — kept because it suits something with an anchor (a menu, a prompt), not a free margin
tab a label plate riveted onto a frame rather than punched through it
panel the three-zone anatomy: top rail, content, bottom rail
readout a label and value that stay together
pixelFrame, marker, rect, hline, vline the rest of the grid

In scripts/lib/motion.mjs — entrances (rise, grow, fill, draw, bloom) and ambient loops (lamp, tick, stream, playhead, cursor, flicker, ants, nudge, wave, pixelWave, keylight, rowlight, tape, march, scanline, belt). Each is switchable per component in config, and every animated document carries a prefers-reduced-motion guard.

Type

Departure Mono by Helena Zhang, SIL Open Font License 1.1. Subset to the printable ASCII range plus a few marks — about 2 KB — and embedded as a data URI in every generated file.

The licence travels with the font in assets/fonts/OFL.txt, which OFL section 2 requires of every copy. It declares no reserved font name, which is why a subset may keep the original name; that was checked in the licence text rather than in the binary, because a subset binary has its name table stripped and would give a confidently wrong answer.

Subsetting makes you the maker of a Modified Version. If you re-subset with a font that does declare a reserved name, you must rename it.

Licence

MIT for the code — see LICENSE. The font keeps its own OFL, which is not the same licence and does not become MIT by sitting in this repository; the details are in NOTICE.

About

Pixel panels for a GitHub profile README - 20 SVG components on one 2px grid and one embedded bitmap face. Both palettes in every file. No build step, no runtime.

Topics

Resources

Stars

5 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages