Pixel stats
Stat cards made of pixels: figures in textured tiles assemble and count up, and a pixel hand drags a progress bar.
pixel-stats/pixel-stats.tsxFaster code reviews
“Reviews used to wait for days. Now most of them land before lunch, and nobody has to chase a handoff.”
The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- A results section on a landing page: two or three headline numbers, a quote that backs them, and room to breathe.
- A brand that plays with pixels, where the figures can be art as well as data.
- Numbers worth a moment of attention: each card assembles and counts up once, the first time it comes into view.
- A progress figure people may want to try: the bar is a real slider, dragged by pointer or moved with the arrow keys.
When not to use
- Dashboards and live metrics that change often or need comparing. Use plain stat tiles or a chart.
- More than five or six figures: the tiles only work for a few short numbers. Use a table.
- Decimals, dates or long figures in the big card: its pixel font draws whole numbers. Put those in a small card.
- A serious or regulated context (finance, health) where playful figures could read as unserious.
You can add this component with the shadcn CLI or by hand:
Into components/pixel-stats of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/pixel-stats.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
pixel-stats/pixel-stats.tsxpixel-stats/pixel-stats.module.csspixel-stats/stat-card.tsxpixel-stats/progress-card.tsxpixel-stats/testimonial.tsxpixel-stats/mini-stat.tsxpixel-stats/pixel-figure.tsxpixel-stats/glyphs.tspixel-stats/pixels.tspixel-stats/sprites.tsxpixel-stats/decoration.tsxpixel-stats/portrait.tsxpixel-stats/motion.tspixel-stats/use-autoplay.ts
- Import it
As shown under Usage, below.
Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { PixelStats } from './pixel-stats';// Two stat cards, a testimonial and two small stats: pass your own figures.export function ReviewStats() { return ( <PixelStats stat={{ value: 86, suffix: '%', caption: 'Fewer handoffs between teams' }} progress={{ value: 64, caption: 'Faster code reviews' }} quote={{ text: 'Reviews used to wait for days. Now most of them land before lunch.', name: 'Ines Moreau', role: 'Engineering lead', }} figures={[ { value: 3.2, decimals: 1, suffix: '×', label: 'Faster first review' }, { value: 12, suffix: 'k', label: 'Reviews closed weekly', accent: 'lilac', corner: 'br' }, ]} /> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
A section of stat pieces built from pixels. Each big figure is drawn with square tiles of a soft, brushed texture inside a tilted selection frame: when its card comes into view, the tiles of a zero rise into place one by one and the number counts up. A pixel hand grabs a segmented progress bar and drags it, a portrait arrives as a mosaic of its own pixels, and textured squares twinkle at the cards’ edges.
- The figures come from pixel fonts written as text: a 5 × 7 grid for the big number and 3 × 5 for the percentage, # lit and . empty. Every cell of a glyph is always in the page, lit or not, so when a digit changes its cells fade and scale with a CSS transition instead of being replaced.
- The tiles’ texture is made in code. Gradient noise is swirled by a slower noise, slanted, stretched into streaks and mapped onto a ramp of peach or violet, then written as a 64 × 64 PNG in a data URI. It comes out the same on the server and in the browser, so it is in the first HTML, and every tile shows its own window of the same image.
- The server draws every card finished. In the browser a card resets before its first paint and plays once it comes into view: the selection frame swings into its tilt and its handles pop, the tiles of a zero rise in on a sweep from left to right, and the number counts up to its value. Pressing the card plays it again.
- The progress bar is a slider whose fill is one custom property, --fill. Each of its 24 segments works out its own opacity from it in CSS, so the last one fills gradually. While the card is on screen and untouched, a pixel hand comes to the end of the fill, grabs it and drags it to a new value, and the figure follows.
- The bolt and the hands are pixel art drawn from text into crisp SVG squares; the bar’s ends and the portrait’s circle are clip-path polygons cut in steps. The edge tiles twinkle one at a time with the Web Animations API, above the content and never clipped, so a twinkle never pulls a larger layer onto the GPU.
- Every colour but the art is a light-dark() token on the root, so it follows the page’s color-scheme, or the colorScheme prop: the big stat cards are white by day and near-black at night. The tiles, textures, sprites and portrait keep their colours; by day each tile gains a hairline in its own tone, so the pale figures hold on white.
| Prop | Type | Default | Description |
|---|---|---|---|
stat | Stat | STAT | The big figure in tiles: a whole number counted up from zero (two or three digits read best), a suffix set in plain type and a one-line caption. |
progress | Progress | PROGRESS | Where the bar starts, from 0 to 100, and its caption, which also names the slider. |
quote | Quote | QUOTE | The testimonial: its text, and the name and role of who said it. The portrait is an illustration, in portrait.tsx. |
figures | readonly Figure[] | FIGURES | The small stat cards: a value with its decimals, prefix and suffix, a label, and the texture (accent) and corner of their pixels. |
defaultState | PixelStatsState | — | Opens held still: phase 'rest' (every card finished) or 'hidden' (where the entrances start), and hand 'rest', 'hover', 'grab' or 'hidden' on the bar. No entrances, no twinkle, and the demo off. |
onProgressChange | (value: number) => void | — | Called when the reader moves the progress bar, with its value from 0 to 100. |
onReplay | (figure: string) => void | — | Called when a stat card the reader pressed has counted up again, with its figure as read. |
autoplay | boolean | true | Drag the progress bar to its next stop while the card is visible and untouched. |
colorScheme | 'light' | 'dark' | 'system' | — | Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one. |
className | string | — | Extra class for the root, a stage of its own. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useState } from 'react';import { MiniStat, type Figure } from './mini-stat';import { TEXTURES } from './pixels';import { ProgressCard, type Progress } from './progress-card';import { StatCard, type Stat } from './stat-card';import { Testimonial, type Quote } from './testimonial';import styles from './pixel-stats.module.css';/* * Pixel stats: a section of a landing page in which pixels are the material. Figures are drawn * with square tiles of a soft, brushed texture; a tilted selection frame holds them, like a design * tool's; textured squares gather at the cards' edges and twinkle; a pixel hand drags a progress * bar; a portrait is cut in a circle of pixels. * * - Two big cards: a figure that assembles tile by tile and counts up (stat-card.tsx), and a * progress bar to drag, with its value in tiles (progress-card.tsx). * - A testimonial (testimonial.tsx) and two small stat cards (mini-stat.tsx). * - The tiles' textures are noise made in code and written as small PNGs (pixels.ts), set here as * custom properties; the figures are drawn from pixel fonts (glyphs.ts), and the bolt and the * hands from pixel art (sprites.tsx)./* Pixel stats (pixel-stats.tsx): a stage with two big stat cards, a testimonial and two small stat cards. The layout follows the stage's own width (a container query): from 600px the big cards sit side by side above the testimonial and the small cards; narrower, they stack. Each big card is a container too, and sizes its tiles, frame and type in cqi, so they scale with it; the testimonial lays itself out for its own width. Nothing clips its content, so a tile that twinkles on a layer of its own never needs a clipping layer round it. Colours. Everything but the art takes the tokens below, each light-dark(light, dark): the component follows the color-scheme it inherits from the page (light unless the page says otherwise), or the one its colorScheme prop sets (data-color-scheme). The big cards are white by day and near-black at night. The tiles, their textures, the sprites, the progress bar and the portrait are pixel art: the same in both. */.root,.root * { box-sizing: border-box;}.root { /* The stage; the testimonial and the small cards, a step lighter at night. */ --surface: #fff; --surface-2: #fbfbfc; /* The words: figures, the quote and its author's name; labels and the author's role. */ --ink: #17171a; --ink-muted: #74747c;'use client';import { useId, useState } from 'react';import { SelectionFrame, Tiles, type Tile } from './decoration';import { TALL } from './glyphs';import { prefersReducedMotion, tween, useEntrance, useInView, useScheduler, useTwinkle,} from './motion';import { PixelFigure } from './pixel-figure';import styles from './pixel-stats.module.css';/* * A stat card: a big figure drawn in tiles, held in a tilted selection frame, over a line of * caption, with textured tiles scattered on the card's edges. * * When the card comes into view the figure plays its entrance: the frame swings into place and its * handles settle, the tiles of a zero rise into place one by one, then the figure counts up to its * value, its tiles turning on and off as the digits change. Pressing the card plays it again. * While it is on screen, the edge tiles twinkle now and then.'use client';import { useCallback, useEffect, useId, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent,} from 'react';import { SelectionFrame, Tiles, tilesFromMap, type Tile } from './decoration';import { SQUAT } from './glyphs';import { easeInOut, tween, useEntrance, useInView, useScheduler, useTwinkle } from './motion';import { PixelFigure } from './pixel-figure';import { stairs } from './pixels';import { BOLT, GRABBING_HAND, PixelArt, POINTING_HAND } from './sprites';import { useAutoplay } from './use-autoplay';import styles from './pixel-stats.module.css';/* * A progress card in purple: a pixel bolt, a segmented bar and, under it, its value drawn in * tiles, in a selection frame, with clusters of pixels in the corners and faint grid lines.'use client';import { useState, type CSSProperties } from 'react';import { useEntrance, useInView } from './motion';import { hash, stairs } from './pixels';import { INKS, Portrait } from './portrait';import styles from './pixel-stats.module.css';/* * A testimonial row: a portrait cut in a circle of pixels, a quote, and who said it. * * The portrait comes in as pixels. When the row comes into view, it shows as a mosaic of 8 × 8 * squares, each the shade of the portrait at that spot, which drop away one by one, at random, to * the illustration beneath. */export type Quote = { text: string; name: string; role: string;};/** The portrait's outline: a circle in pixels, on its 8 × 8 squares. */const PIXEL_CIRCLE = stairs([2, 1], '12.5%');'use client';import { useId, useState } from 'react';import { Tiles, tilesFromMap, type Corner } from './decoration';import { prefersReducedMotion, tween, useEntrance, useInView, useScheduler, useTwinkle,} from './motion';import type { Tone } from './pixels';import styles from './pixel-stats.module.css';/* * A small stat card: a figure in plain type that counts up from zero when the card comes into * view (and again when pressed), a label, and a few textured pixels in one corner, which * twinkle now and then. */export type Figure = { value: number; /** Digits after the decimal point, kept while it counts. */import type { CSSProperties } from 'react';import { isLit, type PixelFont } from './glyphs';import { hash, type Tone } from './pixels';import styles from './pixel-stats.module.css';/* * A figure drawn in tiles. Each character of `text` is a grid of the font's cells, and each lit * cell a small square with a textured fill. Every cell is there, lit or not, so when a character * changes its cells turn on and off with a transition rather than being replaced. * * Each cell carries its own variety (stable from server to browser, see `hash`): a window into the * texture (--x, --y), a blush (--glow) and the delay it rises with when the figure assembles * (--delay), a sweep from left to right with a little jitter. The card around it sets the phase. *//** Each cell's delay, per column swept, in ms. */const SWEEP = 24;export function PixelFigure({ text, font, tone, seed = 0,}: {/* * The figures' pixel fonts. A glyph is a grid of cells written as rows of text, `#` for a lit cell * and `.` for an empty one, so it can be read, and redrawn, right here. * * - TALL, 5 × 7: dot-matrix digits for a big figure. * - SQUAT, 3 × 5: chunky digits and a percent sign, for a figure that changes as you watch. */export type PixelFont = { columns: number; rows: number; glyphs: Readonly<Record<string, readonly string[]>>;};export const TALL: PixelFont = { columns: 5, rows: 7, glyphs: { '0': ['.###.', '#...#', '#...#', '#...#', '#...#', '#...#', '.###.'], '1': ['..#..', '.##..', '..#..', '..#..', '..#..', '..#..', '.###.'], '2': ['.###.', '#...#', '....#', '...#.', '..#..', '.#...', '#####'], '3': ['.###.', '#...#', '....#', '..##.', '....#', '#...#', '.###.'], '4': ['...#.', '..##.', '.#.#.', '#..#.', '#####', '...#.', '...#.'], '5': ['#####', '#....', '####.', '....#', '....#', '#...#', '.###.'],import type { CSSProperties } from 'react';/* * What the pixels are made of. * * - `marble()`: a soft, brushed texture for the tiles, made in code. Fractal noise is swirled, * turned to a slant and stretched into streaks, mapped onto a ramp of colours, and written as a * small PNG (64 × 64, in a data URI). It comes out the same on the server and in the browser, * so it is in the server's HTML before any script runs; the browser decodes it once, and every * tile shows a window of it, at an offset of its own. * - `hash()`: a number that looks random but is the same on the server and in the browser, so a * tile's texture and timing never change between the two. * - `stairs()`: a clip-path outline whose corners are cut in steps, as if drawn in pixels. *//** Colours from light to deep: the noise's low values take the first, its high values the last. */type Ramp = readonly string[];type Marble = { ramp: Ramp; seed: number; /** The slant of the streaks, in degrees. */ angle: number; /** How long and how thin the streaks are, in pixels per cell of noise. */import type { ReactElement } from 'react';/* * Pixel art drawn from text: one string per row, one character per pixel, `.` for none. A sprite * is an SVG with a rect for each run of one colour along a row, and crisp edges; size it in CSS at * a whole number of screen pixels per pixel to keep it sharp. *//** The colour of each character used in the art below. */const PALETTE: Readonly<Record<string, string>> = { k: '#0b0b10', w: '#ffffff', p: '#a585ff', l: '#d6c8ff',};export function PixelArt({ art, className }: { art: readonly string[]; className?: string }) { const width = Math.max(...art.map((row) => row.length)); const rects: ReactElement[] = []; art.forEach((row, y) => { for (let x = 0; x < row.length;) { const ink = row[x]; let length = 1; while (row[x + length] === ink) length++;import type { CSSProperties } from 'react';import { hash, type Tone } from './pixels';import styles from './pixel-stats.module.css';/* * What the cards are decorated with: textured tiles scattered at their edges, and the selection * frame, a design tool's, with a square handle at each corner. All of it is decorative * (aria-hidden). */export type Corner = 'tl' | 'tr' | 'bl' | 'br';export type Tile = { /** The corner it is placed from. */ corner: Corner; /** Its distance from that corner, in tiles, across and down (or up). */ x: number; y: number; tone: Tone; /** How it twinkles: `dim` fades and shrinks for a moment; `flash` rests hidden and flashes in. */ twinkle?: 'dim' | 'flash';};/**import { useId } from 'react';/* * The testimonial's portrait: an illustrated person in two inks (indigo on lavender, with the * tints between), so the demo needs no photo and shows no real face. A fine halftone of dots on * the backdrop gives it the look of a duotone print. Swap it for an <img> for a real person. *//** The inks, from the deepest to the lightest. */export const INKS = { ink: '#23265e', deep: '#4549b8', mid: '#8e92e6', paper: '#c4c7f5', light: '#eceefe',} as const;export function Portrait({ className }: { className?: string }) { const dots = useId(); const { ink, deep, mid, paper, light } = INKS; return ( <svg className={className} viewBox="0 0 96 96" aria-hidden="true"> <defs> <pattern id={dots} width="3" height="3" patternUnits="userSpaceOnUse">'use client';import { useCallback, useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react';/* * The motion the cards share: whether a card is on screen, how it enters, how a number eases to * another, and how tiles twinkle. Nothing here runs while a card is off screen or in a hidden tab, * and nothing moves under reduced motion. */export const prefersReducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;export const easeOut = (t: number) => 1 - (1 - t) ** 3;export const easeInOut = (t: number) => (t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2);/** * Whether the element is on screen, in a visible tab. It comes in once `threshold` of it shows, * and goes out once it has left the screen entirely. */export function useInView<T extends Element>(threshold = 0.35) { const ref = useRef<T>(null); const [inView, setInView] = useState(false); useEffect(() => {'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden); const observer = new IntersectionObserver( ([entry]) => { onScreen = entry.isIntersecting;- The stat cards are buttons named by their figure and caption (“86% Fewer handoffs between teams”) and described as “Press to count it again”. When a replay ends, a polite live region says the figure again.
- The bar is a slider from 0 to 100 named by its caption: the arrow keys move it by 1 (Shift by 10), Page Up and Page Down by 10, Home and End to the ends. A finger moves it only once it slides sideways, or on a tap, so a vertical swipe across it still scrolls the page.
- The demo hand stops at the reader’s first touch, key or focus on the bar. Tiles, sprites, frames and the portrait are hidden from screen readers: the values are in the accessible names, not in the counting digits.
- Under reduced motion nothing moves: no entrance, count, twinkle or demo. The figures show their values, and pressing a card only announces it.