Skip to content

Repository files navigation

weighted-grid

npm version bundle size types CI license

Sized by weight. Rendered as native CSS Grid.

Per item and per axis: a relative weight, an exact cols/rows span, or one of each. The grid places items in source order, grows the elastic ones into leftover space, and hands whatever is still empty to a fill component. The JS only decides spans — CSS Grid does the rendering.

Preview

▶ Play with the live demo

Why not just CSS Grid?

weighted-grid plain CSS Grid auto-flow react-grid-layout
You specify relative importance every span, by hand x/y/w/h per item
Empty space absorbed fairly, then filled stays empty stays empty
Setup drop in children, tag a few write the placement yourself layout array + drag handlers
Best for dashboards, feeds, galleries, unknown item counts layouts you're happy to hand-place user-editable draggable boards
Runtime deps zero (react is a peer) zero react + drag internals

Use plain CSS Grid when you know the layout up front. Reach for this when the item count is whatever the API returned and you still want it to look deliberate. Not a drag-and-drop library, and not trying to be — want that? Open an issue.

What's new

Version Highlights
1.6.0 Core placement engine available separately; React is peer dependency
1.5.3 Preset API improvements: named types and enhanced flexibility
1.5.0 New preset prop with masonPreset/organicPreset, tree-shakeable subpath

Full history in CHANGELOG.md.

Install

bun add weighted-grid   # npm / pnpm / yarn all fine

Quick start

import { Grid, GridItem } from "weighted-grid/react";

<Grid nrCols={7}>
  <GridItem weight={4}>hero</GridItem>
  <GridItem>a</GridItem>
  <GridItem>b</GridItem>
  <GridItem>c</GridItem>
</Grid>;

weight works like flexbox flex: how much of the grid does this item get. Equal weights give equal squares.

The mental model

Three ideas, and you've seen the whole library.

1 · Weight sizes both axes. weight={2} is a 2×2 block. Pin one axis with cols or rows and weight keeps driving the other:

<GridItem weight={4}>elastic on both axes</GridItem>
<GridItem cols={3} weight={2}>3 columns wide, weight sets the height</GridItem>
<GridItem cols={2} rows={2}>strict — never stretches</GridItem>

Elasticity is per axis. Only pinning both makes an item fully rigid.

2 · Leftover space gets absorbed, fairly. Elastic items stretch into the gaps around them, split evenly between the neighbours flanking a gap — never all growth dumped on one side. Cap it globally with stretch={n} on the Grid, or per item.

3 · Whatever's left is yours. Cells nothing could reach are merged into rectangular blocks and handed to fillComponent — one node per block, not one per cell. Skip the prop and they stay empty.

<Grid
  nrCols={12}
  stretch={2}
  fillComponent={(rect) => <Placeholder {...rect} />}
>
  {items.map((item) => (
    <GridItem key={item.id}>{item.label}</GridItem>
  ))}
</Grid>

API

Prop tables below are generated by type-to-table — re-run bun run docs:props, or it runs automatically on npm publish.

<Grid>

Prop Type Default Description
nrCols? number 7 Number of columns. Always scales with the container width. Named nrCols, not
cols, so it reads unambiguously as a count — <GridItem cols> means something different (a
span), and the two showing up in the same JSX block was the confusing part.
nrRows? number Minimum number of row tracks. Omit it (default, and the right choice for most grids) and the
grid auto-counts the rows its items occupy, then stretches exactly that many to fill the height.
Set it only to reserve extra headroom for stretch to grow into — it's a floor, not a cap:
content that needs more rows always gets them regardless of this value (same as CSS Grid's own
implicit-row overflow), so setting it below what content needs has no visible effect.
preset? PresetFn Auto-assigns weight/cols/rows per item so the grid fills itself with minimal config — a
{@link PresetFn}, e.g. masonPreset(4) from weighted-grid/presets, or your own
({ count, nrCols }) => [...]. Explicit props on a GridItem always override the preset's
defaults. Pass a stable function (e.g. wrap a custom preset in useCallback) so it doesn't
recompute every render.
gap? string | number 8 Gutter between items (px if a number).
rowHeight? string | number 'auto' "auto": stretch to the parent's height, splitting it into row bands — the parent
must have a height. A number/string (e.g. 100, "5rem"): fixed height per row, grid grows down.
stretch? number Infinity Extra cells a weight-only item may grow per axis to absorb gaps (0 off, fill as far as
possible by default). Growth is fair — split evenly between the items flanking a gap, never
all to one. Runs first, regardless of fillComponent.
fillComponent? ReactNode | ((rect: { row: number; col: number; rowSpan: number; colSpan: number; }) => ReactNode) Rendered in whatever's left over after stretch — the cells no elastic neighbor could reach.
Doesn't disable stretching; it plugs the remainder. A plain ReactNode renders the same node in
every gap; pass a function to receive each gap's own placement (post-merge, see
{@link groupEmptyRects }) — e.g. for a debug label or a size-aware filler. Default: undefined
(those cells stay empty).
showGrid? boolean false Debug overlay: draws a guide line exactly on the real gap gutter between items (a gradient
whose period accounts for gap, not a simulated line that can drift out of sync with it).
animateSize? boolean false Smoothly transition an item's on-screen size when its span changes (e.g. stretch growing it
into a gap after a re-layout). CSS Grid line/span values aren't natively interpolable, so this
is a FLIP transform (scale, transitioned back to identity) applied after layout, not a real
grid-track animation.
animatePosition? boolean false Same FLIP mechanism as animateSize, but for on-screen position (translate) instead of size.
Off by default — most layout changes reorder enough that animating position reads as noisy; turn
it on only for grids where items mostly nudge rather than jump.
itemAnimation? string The CSS transition value applied to the FLIP transform, e.g. "200ms ease-out" or
"400ms cubic-bezier(...)" — spliced verbatim into transition: transform ${itemAnimation}.
No effect unless animateSize/animatePosition is also on. Omit it and the transform still
applies but with no transition, i.e. no visible animation.
className? string
style? CSSProperties

<GridItem>

Prop Type Default Description
weight? number 1 Relative size, flexbox-flex-style ("how much of the grid do I get"). Fills whichever axis you
don't pin with cols/rows; pin neither and it drives both (weight={2} → a 2×2 block, so
equal weights are equal squares).
cols? number Exact column span. Pins the horizontal axis — it never stretches — while weight keeps driving
rows (still elastic, unless rows is also pinned). Clamped to the grid's column count.
rows? number Exact row span. Pins the vertical axis — it never stretches — while weight keeps driving
columns (still elastic, unless cols is also pinned).
stretch? number Extra cells this item may still grow, per axis, beyond cols/rows/weight — even on an axis
cols/rows pinned (normally frozen at 0 growth). Sets both axes; stretchX/stretchY
override per axis. Also works the other way: caps a weight-driven (normally fully elastic) axis
below the Grid-level stretch default. Doesn't affect weight/cols/rows themselves, only
how far stretch may grow the item afterward.
stretchX? number Per-axis override of stretch for the column axis.
stretchY? number Per-axis override of stretch for the row axis.

💡 stretch on an item cuts both ways: let a cols-pinned card grow into a gap anyway, or stop one greedy elastic item from eating the whole row.

Presets

A preset is just a function — ({ count, nrCols, nrRows }) => Partial<GridItemProps>[] — that hands each item its defaults. Perfect for "here are 40 cards, make it look intentional".

import { masonPreset } from "weighted-grid/presets";

<Grid nrCols={8} preset={masonPreset()}>
  {items.map((item) => (
    <GridItem key={item.id}>{item.label}</GridItem>
  ))}
</Grid>;
  • masonPreset(brick = 2) — running-bond brick rows, every other row offset by half a brick.
  • organicPreset(seed = 1) — a drifting mosaic of small/medium/large tiles, scaled to nrCols.

They live on the weighted-grid/presets subpath, so a preset you don't import (and its code) never reaches your bundle. Rolling your own is a ten-liner:

import type { PresetFn } from "weighted-grid/presets";

const stripes: PresetFn = ({ count, nrCols }) =>
  Array.from({ length: count }, (_, i) => ({
    weight: i % nrCols < nrCols / 2 ? 2 : 1,
  }));

Keep the function reference stable (module scope, or useCallback) — Grid memoizes on it.

Responsive columns

There's no wrap prop, because there doesn't need to be one. Spans clamp to nrCols, so dropping the column count at a breakpoint reflows everything into fewer columns and more rows, flex-wrap style:

<Grid nrCols={isMobile ? 2 : 6}>...</Grid>

Examples

Every one of these runs in the live demo — the source is a few lines long and worth a skim.

Example Shows off
organic-raw organicPreset, flat color, cropped into a strip that scrolls sideways
organic-styled Same preset, real cards, stretch + fillComponent doing the work
responsive-cols nrCols collapsing on narrow viewports
prop-matrix Every sizing prop side by side — the cheat sheet
pinned-spans Strict cols/rows items with elastic ones flowing around them
row-height "auto" bands vs. fixed rows, with live controls
modes masonPreset and organicPreset on the same content

Development

bun install
bun run test        # bun test
bun run typecheck
bun run build       # vite → dist/
bun run format      # biome check --write
bun run demo:dev    # the demo app in demo/

License

MIT © jayF0x

About

A React grid that sizes items by relative weight and fills empty space automatically, rendered as native CSS Grid.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages