webdevpuneet.com

Waffle Chart — HTML CSS JS Square-Grid Percentage Chart

Waffle Chart · Charts · Plain HTML, CSS & JS · Live preview

CategoryCharts

Copy & paste

Source Code

<div class="wc-card">
  <div class="wc-head"><h3>Budget allocation</h3><span class="wc-sub">each square = 1%</span></div>
  <div class="wc-grid" id="wcGrid"></div>
  <ul class="wc-legend" id="wcLegend"></ul>
  <div class="wc-tip" id="wcTip" hidden></div>
</div>

Extend with AI

Take This Snippet Further With AI

The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.

· Become a supporter

What's included

Features

100-square grid
A responsive 10x10 CSS grid where each cell is one percent.
Largest-remainder rounding
Guarantees the categories sum to exactly 100 cells, distributing leftovers fairly.
Contiguous colour runs
Each category fills a continuous run of cells for clear blocks.
Normalised values
Source values need not total 100 — they are scaled to the grid.
Hover tooltips
Hovering a coloured cell shows its category.
Legend matches the grid
The legend shows rounded cell counts, so it always agrees with the squares.
Empty-cell fallback
Any unallocated cells render neutral so the grid never breaks.
Data-driven & no library
Renders from a DATA array in plain HTML/CSS/JS — zero dependencies.

About this UI Snippet

Waffle Chart — A 100-Square Grid Where Each Cell Is One Percent

Screenshot of the Waffle Chart snippet rendered live

A waffle chart shows part-to-whole composition as a 10×10 grid of 100 squares, each representing one percent, coloured by category. It is often easier to read than a pie chart because counting squares is more intuitive than judging angles. This snippet builds it in plain HTML, CSS, and vanilla JavaScript, with correct rounding so the categories always fill exactly 100 cells — no charting library.

The grid is the chart

A CSS grid of 100 square cells (10 columns, aspect-ratio: 1) is the entire canvas. Each category claims a run of cells in its colour, filled in order, so the coloured blocks read as proportions of the whole. Using a real grid of elements (rather than a drawn chart) means every cell is a DOM node you can hover, and the layout is naturally responsive — the squares scale with the card width.

Largest-remainder rounding (the hard part)

The subtle problem every waffle chart faces: percentages rarely round to whole cells that sum to 100. If you naively round each category, you might end up with 99 or 101 cells. The snippet uses the largest-remainder method: floor each category's cell count, then hand out the leftover cells one at a time to the categories with the biggest fractional remainders. This guarantees the grid sums to exactly 100 while distributing rounding as fairly as possible — the same apportionment method used for allocating seats from vote shares. Getting this right is what keeps the waffle honest and gap-free.

Color runs and an empty fallback

Cells are generated category by category, so each colour forms a contiguous run across the grid (left-to-right, top-to-bottom). Any unallocated cells (which should not occur once rounding sums to 100) fall back to a neutral empty colour, so the grid never breaks. Hovering a coloured cell shows its category name in a tooltip.

A legend with the rounded values

The legend lists each category with its colour and its *rounded* cell count (the number actually shown), not the raw percentage — so the legend always agrees with what is on the grid. Showing the displayed value rather than the source number avoids the confusing case where the legend says 38% but you can count 37 squares.

Data-driven and drop-in

It renders from a DATA array of { name, value, color }; values need not pre-sum to 100 (they are normalised). Swap in budget splits, market share, time allocation, or survey results and it lays out the grid. It is a clear, dependency-free reference for waffle layout and the largest-remainder rounding that part-to-whole square charts require. The same largest-remainder method generalises beyond charts — it's the standard algorithm for apportioning parliamentary seats by vote share, splitting a fixed pool of anything (budget headcount, inventory units) across categories by percentage, and any other case where rounded shares of a whole must still sum to the whole exactly. It's worth implementing once as a small, generic allocate(values, total) helper rather than re-deriving it per chart, since the floor-then-distribute-remainders logic here doesn't reference anything specific to waffle rendering — the exact same function would apportion budget headcount across departments or seats across parties from vote percentages.

Step by step

How to Use

  1. 1
    Paste HTML, CSS, and JSA 10x10 waffle chart renders, each square representing 1% of the whole.
  2. 2
    Read the blocksEach colour's run of squares is its share; the legend lists the percentages.
  3. 3
    Hover a squareSee which category a cell belongs to in a tooltip.
  4. 4
    Swap in your dataReplace the DATA array with your own { name, value, color } items.
  5. 5
    Values need not total 100They are normalised, and rounding ensures the grid always fills exactly 100 cells.
  6. 6
    Resize the gridChange the grid-template-columns count (and TOTAL) for a different cell count.

Real-world uses

Common Use Cases

Budget and spend allocation
Show how a budget splits across categories as a 10 by 10 grid, where each square represents exactly one percent of the total.
Market and category share
Display share of a whole more intuitively than a pie chart, because counting squares is easier than judging angles.
Time and effort breakdown
Visualise where time goes across projects, with each category filling one continuous run of cells for clear, countable blocks.
Survey and demographic splits
Show response composition, with largest-remainder rounding guaranteeing that the categories always add up to exactly 100 squares.
Single-colour progress
Use one colour for a completion figure that reads at a glance, and a donut chart when you want a continuous ring instead.

Got questions?

Frequently Asked Questions

Each category's share rarely converts to a whole number of cells, and naively rounding each one can make the total come out to 99 or 101 instead of 100. The largest-remainder method floors every count, then distributes the leftover cells one at a time to the categories with the largest fractional parts. This guarantees the grid sums to exactly 100 while keeping the rounding as fair as possible.

No. The values are normalised — each category's cell count is value ÷ total × 100 — so you can pass raw amounts (dollars, counts, hours) and the chart converts them to percentages of their sum. The largest-remainder rounding then ensures those convert to whole cells that fill the grid exactly.

Because rounding can make the displayed squares differ slightly from the source percentage (38.4% becomes 38 cells). Showing the rounded cell count in the legend means the legend always matches what you can count on the grid, avoiding the confusing case where the legend and the visible squares disagree.

Yes. Change grid-template-columns to a different column count and set TOTAL to the number of cells (e.g. 5x5 = 25, or 10x20 = 200 for half-percent resolution). The allocation scales to whatever TOTAL you choose, so each cell represents 100/TOTAL percent. Keep TOTAL and the column count consistent for square cells.

In React, hold the data in useState, compute the cell allocation with useMemo, and render the grid from it; in Vue, use a computed counts array with v-for; in Angular, a getter with *ngFor. The allocate() largest-remainder logic is framework-agnostic — only the state and rendering move into the framework.