← ui

layouts

lib

the rules things are arranged by: masonry lanes, justified rows, an ordered bento

npx shadcn add @ag/layouts
Already installed? Preview an update
npx shadcn add @ag/layouts --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-16
  • New, and free: the rules things are arranged by, whatever they are. It is the gallery's engines (lanes, justified rows, and the geometry a grid moves by), which were gallery-layout, plus pack for an ordered bento. gallery-layout is gone: import the same names from lib/layouts and delete lib/gallery-layout.ts. placeLane and laneCount are self-contained, so a page can run them before it paints.
Updated 2026-10-16 · release notes

also called masonry, justified rows, bento

shelf foundations

used by arrange

the demo above, verbatim

layouts.demo.tsx
"use client"

import * as React from "react"
import { Sample } from "@/app/samples"
import {
  type Engine,
  type Layout,
  layout,
} from "@/registry/base-nova/lib/layouts"
import { Button } from "@/registry/base-nova/ui/button"

// #region shapes
// Thirty files of the shapes a phone and a camera leave: verticals (9:16), 16:9,
// squares, 4:3 and 3:4, and one panorama the clamp narrows to 2.4.
const SHAPES = [9 / 16, 16 / 9, 1, 4 / 3, 16 / 9, 3 / 4, 16 / 9, 9 / 16, 3.2]
const ASPECTS = Array.from(
  { length: 30 },
  (_, i) => SHAPES[(i * 7 + (i >> 2)) % SHAPES.length] as number,
)
// #endregion

// #region boxes
/** A layout drawn as numbered boxes: the number is the item's place in the set, so
 *  reading order is visible at a glance. */
function Boxes({ out }: { out: Layout }) {
  return (
    <div className="relative" style={{ height: out.height, width: out.width }}>
      {Array.from({ length: out.count }, (_, i) => (
        <div
          // biome-ignore lint/suspicious/noArrayIndexKey: the place in the set IS the item
          key={i}
          className="absolute flex items-center justify-center rounded-sm bg-foreground/8 font-mono text-[11px] text-foreground/70 tabular-nums"
          style={{
            left: out.x[i],
            top: out.y[i],
            width: out.w[i],
            height: out.h[i],
          }}
        >
          {i + 1}
        </div>
      ))}
    </div>
  )
}

/** One variable at a time, everything else fixed: the width and the lane. */
function Sweep({
  engine,
  tolerance,
  width,
}: {
  engine: Engine
  tolerance: number
  width: number
}) {
  const out = layout(ASPECTS, {
    engine,
    width,
    laneWidth: 72,
    rowHeight: 64,
    gap: 4,
    tolerance,
  })
  return <Boxes out={out} />
}
// #endregion

export default function GalleryLayoutDemo() {
  const [width, setWidth] = React.useState(300)
  return (
    <div className="space-y-8">
      <Sample
        name="sweep"
        title="tolerance 0, 0.15, 0.3 of a lane"
        description="the same thirty files at the same width; read each set of numbers left to right. At 0 the second row zigzags; at 0.15 lanes that are nearly level fill left to right; at 0.3 more reads across and the steps grow."
        with="shapes boxes"
      >
        <div className="flex flex-wrap gap-8">
          {[0, 0.15, 0.3].map((tolerance) => (
            <figure key={tolerance} className="space-y-2">
              <figcaption className="font-mono text-foreground/60 text-xs">
                lanes · {tolerance}
              </figcaption>
              <Sweep engine="lanes" tolerance={tolerance} width={300} />
            </figure>
          ))}
          <figure className="space-y-2">
            <figcaption className="font-mono text-foreground/60 text-xs">
              justified
            </figcaption>
            <Sweep engine="justified" tolerance={0} width={300} />
          </figure>
        </div>
      </Sample>
      <Sample
        name="resize"
        title="resize: lanes keep their members"
        description="drag the width. Within one lane count nothing changes lane, every box only scales; crossing a count reflows everything, as in every masonry. Justified re-breaks its rows at every pixel."
        with="shapes boxes"
      >
        <div className="space-y-4">
          <label className="flex items-center gap-3 font-mono text-foreground/60 text-xs">
            width {width}
            <input
              type="range"
              min={150}
              max={600}
              value={width}
              onChange={(e) => setWidth(Number(e.target.value))}
              className="w-64"
            />
          </label>
          <div className="flex flex-wrap gap-8">
            {(["lanes", "justified"] as const).map((engine) => (
              <figure key={engine} className="space-y-2">
                <figcaption className="font-mono text-foreground/60 text-xs">
                  {engine}
                </figcaption>
                <Sweep engine={engine} tolerance={0.15} width={width} />
              </figure>
            ))}
          </div>
        </div>
      </Sample>
      <Appending />
    </div>
  )
}

function Appending() {
  const [count, setCount] = React.useState(12)
  const out = layout(ASPECTS.slice(0, count), {
    width: 300,
    laneWidth: 72,
    rowHeight: 64,
    gap: 4,
  })
  return (
    <Sample
      name="append"
      title="appending never moves a box"
      description="add a page: the lanes' bottoms and the cursor simply continue, so every earlier box stays where it was."
      with="shapes boxes"
    >
      <div className="space-y-4">
        <div className="flex gap-2">
          <Button
            size="sm"
            variant="secondary"
            onClick={() => setCount((n) => Math.min(ASPECTS.length, n + 6))}
          >
            + 6
          </Button>
          <Button size="sm" variant="ghost" onClick={() => setCount(12)}>
            reset
          </Button>
        </div>
        <Boxes out={out} />
      </div>
    </Sample>
  )
}

the source, every file an install writes

layouts.ts
// HOW A SET OF THINGS IS ARRANGED, framework-free: the rules every arrangement in the
// registry places by, whatever it holds (pictures in the gallery, cards in a feed, a
// shelf of examples). Nothing here knows what an item is; a component measures or
// knows the items' shapes and hands them in.
//
// THE LANES RULE is one decision, `placeLane`, made by everything that places in lanes:
// the rect engine below for items whose aspect is known (pictures), and arrange's
// layout for items measured in the page (cards), which runs it before the first paint
// as an inline script of its own source. It is self-contained for that reason: no name
// from outside it.
//
// The rect engines. In: every item's aspect (width over height) in set order, and a
// width. Out: one rect per item, in set order, plus what a grid needs to move around
// them (which lane or row each sits in, the neighbour above and below, a window of what
// a viewport shows). Two engines behind one interface, so the grid, the keyboard,
// selection and virtualization never know which drew the rects:
//
// LANES (the default) is the CSS Grid Lanes placement rule with a tolerance and a
// sweeping cursor: an item goes to the lane with the least running bottom, every lane
// within `tolerance` of that minimum counts as tied, and among tied lanes it takes the
// first one at or after the cursor (the lane after the previous item's), else the
// first. So a level stretch reads 1 2 3 / 4 5 6, and only a lane that is clearly the
// shortest (a whole hole, more than the tolerance) pulls an item out of reading order.
//
// Every DECISION is made in units of the lane width: a height is 1 / aspect, the
// tolerance is a share of the lane, the gap is converted at the lane count's reference
// width (where a lane is exactly `laneWidth`). The decisions are therefore the same at
// every container width that keeps the lane count: resizing scales pixels and never
// moves an item to another lane. The PIXELS are then laid with the real gap.
//
// JUSTIFIED is loom's greedy rows: fill a row until its exact-fit height drops to the
// target, emit it, repeat; the last row never stretches past the target.
//
// Both are prefix-stable: an item's rect depends only on the items before it, so
// appending a page never moves an earlier item (justified excepted for its last row,
// which re-breaks; the price of rows).

export type Engine = "lanes" | "justified"

/** The narrowest and widest aspect a cell is drawn at: a panorama never becomes a
 *  sliver, a tall screenshot never owns a screen. The cell crops to it. */
export const ASPECT_MIN = 0.5
export const ASPECT_MAX = 2.4
/** The default tolerance, a share of the lane width: lanes this close count as level. */
export const TOLERANCE = 0.15
export const LANE_WIDTH = 240
export const ROW_HEIGHT = 200
export const GAP = 8

export interface LayoutOptions {
  engine?: Engine
  /** The container's inner width, css px. */
  width: number
  /** Lanes: the target lane width; the count is how many fit. */
  laneWidth?: number
  /** Justified: the target row height. */
  rowHeight?: number
  gap?: number
  /** Lanes: how close two lanes must be to count as level, as a share of the lane. */
  tolerance?: number
  clamp?: readonly [min: number, max: number]
}

export interface Layout {
  engine: Engine
  width: number
  count: number
  x: Float64Array
  y: Float64Array
  w: Float64Array
  h: Float64Array
  /** The lane an item sits in (lanes) or its row (justified). */
  line: Int32Array
  /** How many lanes, or rows. */
  lines: number
  /** The item drawn directly above / below in the same lane; -1 at the ends. Lanes
   *  only (rows find their neighbour by x, `step`). */
  above: Int32Array
  below: Int32Array
  /** Justified: where each row starts, `lines + 1` long. Lanes: empty. */
  rowStart: Int32Array
  /** The whole content's height. */
  height: number
  /** The running maximum of tops and the running minimum of tops from the end: both
   *  sorted, so a viewport's window is two binary searches (`windowOf`). */
  topMax: Float64Array
  bottomMin: Float64Array
  tallest: number
}

export function clampAspect(
  aspect: number,
  clamp: readonly [number, number] = [ASPECT_MIN, ASPECT_MAX],
): number {
  if (!(aspect > 0) || !Number.isFinite(aspect))
    throw new Error(`layouts: aspect ${aspect} is not a positive number`)
  return Math.min(clamp[1], Math.max(clamp[0], aspect))
}

/** How many lanes of about `laneWidth` fit: at least one. Self-contained, like
 *  `placeLane`: arrange inlines its source into the page. */
export function laneCount(width: number, laneWidth: number, gap: number) {
  return Math.max(1, Math.floor((width + gap) / (laneWidth + gap)))
}

/** THE RULE, one decision: which lane the next item takes, given every lane's running
 *  bottom, the cursor (the lane after the previous item's) and the tolerance, all in
 *  one unit. The tied lanes are those within `tolerance` of the lowest bottom; the
 *  item takes the first tied lane at or after the cursor, else the first tied lane.
 *  Self-contained (no name from outside it): arrange inlines its source into the page
 *  to place cards before the first paint. */
export function placeLane(
  bottoms: ArrayLike<number>,
  cursor: number,
  tolerance: number,
): number {
  let m = Number.POSITIVE_INFINITY
  for (let l = 0; l < bottoms.length; l++) m = Math.min(m, bottoms[l] as number)
  // A float's 1.1 + 0.15 must still admit 1.25: the epsilon is far below a pixel.
  const ceiling = m + tolerance + 1e-9
  let first = -1
  for (let l = 0; l < bottoms.length; l++) {
    if ((bottoms[l] as number) > ceiling) continue
    if (first < 0) first = l
    if (l >= cursor) return l
  }
  return first
}

function empty(engine: Engine, width: number, count: number): Layout {
  return {
    engine,
    width,
    count,
    x: new Float64Array(count),
    y: new Float64Array(count),
    w: new Float64Array(count),
    h: new Float64Array(count),
    line: new Int32Array(count),
    lines: 0,
    above: new Int32Array(count).fill(-1),
    below: new Int32Array(count).fill(-1),
    rowStart: new Int32Array(0),
    height: 0,
    topMax: new Float64Array(count),
    bottomMin: new Float64Array(count),
    tallest: 0,
  }
}

function finish(out: Layout): Layout {
  let max = Number.NEGATIVE_INFINITY
  let bottom = 0
  let tallest = 0
  for (let i = 0; i < out.count; i++) {
    max = Math.max(max, out.y[i] as number)
    out.topMax[i] = max
    bottom = Math.max(bottom, (out.y[i] as number) + (out.h[i] as number))
    tallest = Math.max(tallest, out.h[i] as number)
  }
  let min = Number.POSITIVE_INFINITY
  for (let i = out.count - 1; i >= 0; i--) {
    min = Math.min(min, out.y[i] as number)
    out.bottomMin[i] = min
  }
  out.height = bottom
  out.tallest = tallest
  return out
}

/** Lanes: the grid-lanes rule, decided in lane units, laid in pixels. */
export function layoutLanes(
  aspects: ArrayLike<number>,
  options: LayoutOptions,
): Layout {
  const gap = options.gap ?? GAP
  const target = options.laneWidth ?? LANE_WIDTH
  const tolerance = options.tolerance ?? TOLERANCE
  const width = Math.max(0, options.width)
  const n = laneCount(width, target, gap)
  const lane = (width - (n - 1) * gap) / n
  const gapUnits = gap / target
  const out = empty("lanes", width, aspects.length)
  out.lines = n
  const units = new Float64Array(n)
  const pixels = new Float64Array(n)
  const last = new Int32Array(n).fill(-1)
  let cursor = 0
  for (let i = 0; i < aspects.length; i++) {
    const a = clampAspect(aspects[i] as number, options.clamp)
    const l = placeLane(units, cursor, tolerance)
    const h = lane / a
    out.x[i] = l * (lane + gap)
    out.y[i] = pixels[l] as number
    out.w[i] = lane
    out.h[i] = h
    out.line[i] = l
    const prev = last[l] as number
    if (prev >= 0) {
      out.above[i] = prev
      out.below[prev] = i
    }
    last[l] = i
    units[l] = (units[l] as number) + 1 / a + gapUnits
    pixels[l] = (pixels[l] as number) + h + gap
    cursor = l + 1
  }
  return finish(out)
}

/** Justified rows: loom's greedy fill, the last row at most the target height. */
export function layoutJustified(
  aspects: ArrayLike<number>,
  options: LayoutOptions,
): Layout {
  const gap = options.gap ?? GAP
  const target = options.rowHeight ?? ROW_HEIGHT
  const width = Math.max(0, options.width)
  const out = empty("justified", width, aspects.length)
  const starts: number[] = []
  let top = 0
  let start = 0
  let sum = 0
  const emit = (end: number, h: number) => {
    let x = 0
    for (let j = start; j < end; j++) {
      const a = clampAspect(aspects[j] as number, options.clamp)
      out.x[j] = x
      out.y[j] = top
      out.w[j] = a * h
      out.h[j] = h
      out.line[j] = starts.length
      x += a * h + gap
    }
    starts.push(start)
    top += h + gap
    start = end
    sum = 0
  }
  for (let i = 0; i < aspects.length; i++) {
    sum += clampAspect(aspects[i] as number, options.clamp)
    const h = (width - gap * (i - start)) / sum
    if (h <= target) emit(i + 1, h)
  }
  if (start < aspects.length)
    emit(
      aspects.length,
      Math.min(target, (width - gap * (aspects.length - 1 - start)) / sum),
    )
  starts.push(aspects.length)
  out.lines = starts.length - 1
  out.rowStart = Int32Array.from(starts)
  return finish(out)
}

export function layout(
  aspects: ArrayLike<number>,
  options: LayoutOptions,
): Layout {
  return (options.engine ?? "lanes") === "lanes"
    ? layoutLanes(aspects, options)
    : layoutJustified(aspects, options)
}

/** The first index whose value is >= v in a non-decreasing array. */
function lowerBound(a: Float64Array, v: number): number {
  let lo = 0
  let hi = a.length
  while (lo < hi) {
    const mid = (lo + hi) >> 1
    if ((a[mid] as number) < v) lo = mid + 1
    else hi = mid
  }
  return lo
}

/** Every item intersecting the band [top, bottom), in set order. Two binary searches
 *  bound the candidates (no item before `lo` reaches the band, none from `hi` starts
 *  above its end), then each is tested. */
export function visible(out: Layout, top: number, bottom: number): number[] {
  const lo = lowerBound(out.topMax, top - out.tallest)
  const hi = lowerBound(out.bottomMin, bottom)
  const found: number[] = []
  for (let i = lo; i < hi; i++) {
    const y = out.y[i] as number
    if (y < bottom && y + (out.h[i] as number) > top) found.push(i)
  }
  return found
}

export interface Rect {
  x: number
  y: number
  w: number
  h: number
}

/** Every item a rectangle touches (a marquee), in set order. */
export function inArea(out: Layout, area: Rect): number[] {
  return visible(out, area.y, area.y + area.h).filter((i) => {
    const x = out.x[i] as number
    return x < area.x + area.w && x + (out.w[i] as number) > area.x
  })
}

export type Direction = "left" | "right" | "up" | "down"

/** Where an arrow goes from `index`. Left and right walk the set order (reading
 *  order). Up and down stay in the lane (lanes) or take the item of the next row
 *  nearest `goal`, the x the hand is keeping (justified), so a column of presses does
 *  not drift. Returns the same index at an end, and the goal to keep. */
export function step(
  out: Layout,
  index: number,
  direction: Direction,
  goal?: number,
): { index: number; goal: number } {
  const center = (i: number) => (out.x[i] as number) + (out.w[i] as number) / 2
  if (out.count === 0) return { index: -1, goal: 0 }
  if (direction === "left" || direction === "right") {
    const next = Math.min(
      out.count - 1,
      Math.max(0, index + (direction === "left" ? -1 : 1)),
    )
    return { index: next, goal: center(next) }
  }
  const keep = goal ?? center(index)
  if (out.engine === "lanes") {
    const next = (direction === "up" ? out.above : out.below)[index] as number
    return { index: next < 0 ? index : next, goal: keep }
  }
  const row = (out.line[index] as number) + (direction === "up" ? -1 : 1)
  if (row < 0 || row >= out.lines) return { index, goal: keep }
  let best = out.rowStart[row] as number
  for (let j = best; j < (out.rowStart[row + 1] as number); j++)
    if (Math.abs(center(j) - keep) < Math.abs(center(best) - keep)) best = j
  return { index: best, goal: keep }
}

/** A viewport at a time: the item in the same lane (or nearest `goal` in a row) whose
 *  top is nearest `distance` px above or below this one's. */
export function page(
  out: Layout,
  index: number,
  direction: "up" | "down",
  distance: number,
  goal?: number,
): { index: number; goal: number } {
  const want =
    (out.y[index] as number) + (direction === "up" ? -1 : 1) * distance
  let at = {
    index,
    goal: goal ?? (out.x[index] as number) + (out.w[index] as number) / 2,
  }
  for (;;) {
    const next = step(out, at.index, direction, at.goal)
    if (next.index === at.index) return at
    const before = Math.abs((out.y[at.index] as number) - want)
    if (Math.abs((out.y[next.index] as number) - want) > before) return at
    at = next
  }
}

/** The item whose rect is nearest a point's y (for holding the view still across a
 *  reflow: the cell nearest the viewport's upper third). */
export function nearest(out: Layout, y: number): number {
  let best = -1
  let gapTo = Number.POSITIVE_INFINITY
  for (const i of visible(out, y - out.tallest, y + out.tallest)) {
    const top = out.y[i] as number
    const d =
      y < top
        ? top - y
        : y > top + (out.h[i] as number)
          ? y - top - (out.h[i] as number)
          : 0
    if (d < gapTo) {
      gapTo = d
      best = i
    }
  }
  return best
}

// AN ORDERED BENTO: cards of three sizes on a grid of equal rows, every card aligned to
// it, nothing stretched, no free masonry. One cell (a small example), two side by side
// and no taller ("wide": a strip, a table), two by two ("large": a player, a
// terminal). A card keeps its cells at two columns and at three, so what it holds is
// judged against one box; only the order changes, which `pack` gives for the grid's own
// auto-placement (sparse, so the order is the one given). One column (a phone) is no
// bento: every card in the set's order, at its natural height.
//
//   2 columns  a wide card the whole row, a large one the whole width and two rows,
//              single cells in pairs
//   3 columns  a wide card and one single make a row; a large card and two singles
//              stacked beside it make two; singles in threes
//
// The set's order is kept as far as that allows: a wide or large card takes the
// singles waiting before it, then the next ones after it. Two cards two cells wide
// never share a row of three, so a set of more large cards than pairs of singles
// would leave a column empty beside each of the rest: a wide or large card with no
// single left at all takes the whole row instead (`across`), as both do at two
// columns.

/** The cells a card spans: one, two across, two by two. */
export type BentoSize = "card" | "wide" | "large"

/** Where `pack` puts the cards, each by its index in `sizes`: its place in the order,
 *  and whether it spans the whole row (three columns only). */
export type Packed = { order: number[]; across: boolean[] }

export function pack(sizes: BentoSize[], cols: 2 | 3): Packed {
  const across = sizes.map(() => false)
  const used = new Set<number>()
  /** The next single not placed yet, in the set's order. */
  const single = () => {
    const at = sizes.findIndex((s, i) => !used.has(i) && s === "card")
    if (at === -1) return undefined
    used.add(at)
    return at
  }
  /** `n` singles: the waiting ones first, then the next ones. */
  const singles = (waiting: number[], n: number) => {
    const got = waiting.splice(0, n)
    while (got.length < n) {
      const next = single()
      if (next === undefined) break
      got.push(next)
    }
    return got
  }
  const sequence: number[] = []
  let row: number[] = []
  sizes.forEach((size, i) => {
    if (used.has(i)) return
    used.add(i)
    if (size === "card") {
      row.push(i)
      if (row.length === cols) {
        sequence.push(...row)
        row = []
      }
      return
    }
    if (cols === 2) {
      // The whole width: an unpaired single is paired first, or waits for a row.
      const pair = row.length === 1 ? singles(row, 2) : []
      if (pair.length === 1) row.push(...pair)
      else sequence.push(...pair)
      sequence.push(i)
      return
    }
    // Three columns: the singles that fill the rest of its rows; with none left at
    // all, the whole row. One single beside a large card stays beside it (one cell
    // empty under it): sent to a row of its own it would leave two.
    const beside = singles(row, size === "wide" ? 1 : 2)
    if (beside.length === 0) across[i] = true
    sequence.push(i, ...beside)
  })
  sequence.push(...row)
  const order = new Array<number>(sizes.length)
  sequence.forEach((i, n) => {
    order[i] = n
  })
  return { order, across }
}