Isotype Proportion Grid
An "X in every Y" isotype proportion grid — hundreds of small icons light up in a color wave as you scroll, stepping through different proportion statements one at a time.
npx shadcn@latest add https://webberui.com/r/isotype-proportion-grid.jsonPlayground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<IsotypeProportionGrid />
Installation
npx shadcn@latest add https://webberui.com/r/isotype-proportion-grid.jsonOr, once registries are configured in components.json, install it as @webberui/isotype-proportion-grid.
Usage
import {
IsotypeProportionGrid,
type ProportionStep,
} from "@/components/ui/isotype-proportion-grid";
const steps: ProportionStep[] = [
{ numerator: 90, denominator: 100, label: "90% use a phone every day", color: "#6366f1" },
{ numerator: 63, denominator: 100, label: "63% listen to music while commuting", color: "#14b8a6" },
{ numerator: 27, denominator: 100, label: "27% read every week", color: "#f59e0b" },
];
<IsotypeProportionGrid steps={steps} total={100} columns={10} />;The component stretches out a scroll height of stepScrollHeight × number of steps and pins the grid with a sticky panel. As you scroll, the fill front sweeps across the whole grid following waveOrder and the icons matching the proportion turn one by one from the neutral color to the lit color; when the proportion drops, the surplus icons go dark in a reverse ripple. The copy at the top crossfades whenever a step boundary is crossed.
Nested scroll container
If the component sits inside a container with overflow (rather than scrolling the whole page), pass that container's ref to container and use viewportClassName to give the sticky panel a height that matches the container:
const scrollerRef = React.useRef<HTMLDivElement>(null);
<div ref={scrollerRef} className="h-[320px] overflow-y-auto">
<IsotypeProportionGrid
steps={steps}
container={scrollerRef}
stepScrollHeight="240px"
viewportClassName="h-[320px]"
/>
</div>;Custom icons and sweep direction
icon accepts any icon component taking { className } (including lucide-react), and waveOrder switches how the ripple sweeps:
import { Heart } from "lucide-react";
<IsotypeProportionGrid steps={steps} icon={Heart} waveOrder="radial" />;Props
| Prop | Type | Default | Description |
|---|---|---|---|
steps | ProportionStep[] | — | The proportion statement for each step, switched in order as you scroll |
total | number | 100 | Total number of cells in the icon grid |
columns | number | 10 | Number of grid columns |
iconSize | number | 20 | Side length of a single icon (px) |
gap | number | 6 | Spacing between icons (px) |
icon | IsotypeGlyph | built-in human figure | Default icon component (lucide-react components are accepted) |
activeColor | string | "#6366f1" | The lit color, overridable by a step's color |
waveOrder | "row" | "column" | "diagonal" | "radial" | "diagonal" | Sweep direction in which the wave lights cells up |
waveWidth | number | 0.14 | Crest width (the share of icons transitioning at once, 0–1) |
revealFraction | number | 0.6 | The share of each step's window used for the sweep; the rest holds |
stepScrollHeight | string | "90svh" | Scroll height allocated to each step (a CSS length) |
showDots | boolean | true | Whether to show the clickable step indicator dots |
onStepChange | (index: number, step: ProportionStep) => void | — | Callback fired when the current step changes |
container | RefObject<HTMLElement | null> | — | Ref of the nested scroll container; the window is the scroll container when omitted |
aria-label | string | "圖標比例格" | Accessible label — the built-in default is Traditional Chinese for "isotype proportion grid", so pass this prop to localize it |
className | string | — | Applied to the outermost scroll wrapper |
gridClassName | string | — | Applied to the grid container |
viewportClassName | string | "h-[100svh]" | Applied to the sticky viewport panel (used to give it a height) |
ProportionStep
| Field | Type | Description |
|---|---|---|
numerator | number | Numerator: how many in each group are hit |
denominator | number | Denominator: the total in each group |
label | ReactNode | Main heading; when omitted, "{numerator} in every {denominator}" is generated automatically |
caption | ReactNode | Supporting caption text |
color | string | The lit color for this step, overriding activeColor |
icon | IsotypeGlyph | The icon for this step, overriding the component-level icon |
How it works
- Each cell's rank is
rank = position after sorting / total, and its sweep order is decided bywaveOrder(diagonalalong the diagonal,radialexpanding from the center, and so on). When the fill proportion crosses a cell's rank, exactly the right number of icons light up — so "63%" lights preciselyround(0.63 × total)cells. - Scroll progress is mapped to the fill proportion in segments: the first
revealFractionof each step's window is used to sweep to the new proportion and the rest to hold, so whenever the copy is steady the grid stays at the complete proportion too. - The lit color is driven by
animateon the grid's parent, tweening smoothly when the color changes between steps; each cell's reveal, meanwhile, is driven purely by scroll position, so scrolling back up rewinds it in reverse. - The indicator dots below are clickable and smoothly scroll to the hold zone of the matching step (detecting automatically whether the window or a nested container is scrolling).
Accessibility
- A hidden region with
aria-live="polite"announces the current proportion statement whenever the step changes. - The grid itself is a decorative repeating pattern marked
aria-hidden, with the information conveyed by the copy and the live region instead. - The step indicator dots are buttons with an
aria-label, mark the current step (aria-current), and have keyboard focus styling. - When the user has "reduce motion" enabled at the system level, it switches to a static stacked layout: each step presents its own grid at the correct proportion, with neither sticky positioning nor the wave animation applied.
Rank Shuffle Board
A data-driven rank shuffle leaderboard — rows swap positions with a FLIP animation when scores update, arrows flash for rank gains and losses, and the top three carry gold, silver, and bronze podium vocabulary.
Guess Gap Reveal
Guess-the-gap reveal — drag a slider or draw a trend line to guess the answer first, then let go and watch the real data animate in, with a color block marking the gap between "your guess" and the truth alongside the distribution of other readers' guesses.