WebberUI

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.

Loading preview…
npx shadcn@latest add https://webberui.com/r/isotype-proportion-grid.json

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

100
10
0.14
<IsotypeProportionGrid />

Installation

npx shadcn@latest add https://webberui.com/r/isotype-proportion-grid.json

Or, 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

PropTypeDefaultDescription
stepsProportionStep[]The proportion statement for each step, switched in order as you scroll
totalnumber100Total number of cells in the icon grid
columnsnumber10Number of grid columns
iconSizenumber20Side length of a single icon (px)
gapnumber6Spacing between icons (px)
iconIsotypeGlyphbuilt-in human figureDefault icon component (lucide-react components are accepted)
activeColorstring"#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
waveWidthnumber0.14Crest width (the share of icons transitioning at once, 0–1)
revealFractionnumber0.6The share of each step's window used for the sweep; the rest holds
stepScrollHeightstring"90svh"Scroll height allocated to each step (a CSS length)
showDotsbooleantrueWhether to show the clickable step indicator dots
onStepChange(index: number, step: ProportionStep) => voidCallback fired when the current step changes
containerRefObject<HTMLElement | null>Ref of the nested scroll container; the window is the scroll container when omitted
aria-labelstring"圖標比例格"Accessible label — the built-in default is Traditional Chinese for "isotype proportion grid", so pass this prop to localize it
classNamestringApplied to the outermost scroll wrapper
gridClassNamestringApplied to the grid container
viewportClassNamestring"h-[100svh]"Applied to the sticky viewport panel (used to give it a height)

ProportionStep

FieldTypeDescription
numeratornumberNumerator: how many in each group are hit
denominatornumberDenominator: the total in each group
labelReactNodeMain heading; when omitted, "{numerator} in every {denominator}" is generated automatically
captionReactNodeSupporting caption text
colorstringThe lit color for this step, overriding activeColor
iconIsotypeGlyphThe 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 by waveOrder (diagonal along the diagonal, radial expanding 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 precisely round(0.63 × total) cells.
  • Scroll progress is mapped to the fill proportion in segments: the first revealFraction of 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 animate on 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.

On this page