any cards arranged in lanes by their real height, or in a grid, from the first paint
npx shadcn add @ag/arrangeAlready installed? Preview an update
npx shadcn add @ag/arrange --dry-runYour installed source stays unchanged until you update it. How updates work
- New: any cards in the page's flow, in lanes by their real height (masonry) or in a grid.
<Arrange layout="lanes" laneWidth={320} className="gap-6">around<ArrangeCell>s holding anything. The lane is the unit: as many lanes of aboutlaneWidthas fit, with no breakpoints. The first placement runs before the page paints, so nothing moves once shown;filter="tag"shows only the cells whosetagshold the address's?tag=, from the first paint too (setArrangeFilter,useArrangeFilter).
the demo above, verbatim
"use client"
import { Sample } from "@/app/samples"
import {
Arrange,
ArrangeCell,
setArrangeFilter,
useArrangeFilter,
} from "@/registry/base-nova/ui/arrange"
import { Button } from "@/registry/base-nova/ui/button"
import { Panel } from "@/registry/base-nova/ui/panel"
// #region notes
/** A feed's worth of notes: short and long, as a feed's are, each tagged. */
const NOTES = [
{
id: "a",
tags: ["craft"],
text: "Make it work, make it right, make it fast.",
},
{
id: "b",
tags: ["ai"],
text: "A model's answer is a draft. The work is knowing which half to keep, and saying why the other half goes.",
},
{
id: "c",
tags: ["craft"],
text: "Delete the code you are proudest of first.",
},
{
id: "d",
tags: ["ai", "craft"],
text: "The prompt is a spec nobody reviewed. Write it the way you would write the function: one job, its inputs named, what it returns, and what it must never do.",
},
{ id: "e", tags: ["life"], text: "Walk before deciding." },
{
id: "f",
tags: ["life"],
text: "Most of what feels urgent this morning will not be remembered by Friday. The rest deserves the morning.",
},
{ id: "g", tags: ["ai"], text: "Ask for the diff, not the essay." },
{
id: "h",
tags: ["craft"],
text: "A name is the cheapest documentation and the most read.",
},
]
/** The same cards whatever arranges them: a cell never knows its arrangement. */
function Note({ text, tags }: { text: string; tags: string[] }) {
return (
<Panel className="gap-3 p-4 text-sm">
<p className="text-foreground/80">{text}</p>
<p className="font-mono text-[11px] text-foreground/40">
{tags.join(" · ")}
</p>
</Panel>
)
}
// #endregion
// #region filter
/** The tag in the address, set in place: Back leaves the page, not the filter. */
function Tags() {
const tag = useArrangeFilter("tag")
return (
<div className="flex gap-2">
{[null, "ai", "craft", "life"].map((t) => (
<Button
key={t ?? "all"}
size="sm"
variant={tag === t ? "default" : "outline"}
onClick={() => setArrangeFilter("tag", t)}
>
{t ?? "all"}
</Button>
))}
</div>
)
}
// #endregion
export default function ArrangeDemo() {
return (
<div className="space-y-8">
<Sample
name="lanes"
glance="large"
title="lanes, by real height"
description="as many 220 px lanes as fit: resize the window and the count follows, with no breakpoint."
with="notes"
>
<Arrange layout="lanes" laneWidth={220} className="gap-4">
{NOTES.map((n) => (
<ArrangeCell key={n.id} tags={n.tags}>
<Note text={n.text} tags={n.tags} />
</ArrangeCell>
))}
</Arrange>
</Sample>
<Sample
name="grid"
title="the same cards in a grid"
description="each row as tall as its tallest card."
with="notes"
>
<Arrange layout="grid" laneWidth={220} className="gap-4">
{NOTES.map((n) => (
<ArrangeCell key={n.id} tags={n.tags}>
<Note text={n.text} tags={n.tags} />
</ArrangeCell>
))}
</Arrange>
</Sample>
<Sample
name="filtered"
title="filtered by the address"
description="?tag= in the address shows only the cards that carry it, from the first paint; reload with one set."
with="notes filter"
>
<div className="space-y-4">
<Tags />
<Arrange
layout="lanes"
laneWidth={220}
filter="tag"
className="gap-4"
>
{NOTES.map((n) => (
<ArrangeCell key={n.id} tags={n.tags}>
<Note text={n.text} tags={n.tags} />
</ArrangeCell>
))}
</Arrange>
</div>
</Sample>
</div>
)
}the source, every file an install writes
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { laneCount, placeLane } from "@/registry/base-nova/lib/layouts"
// THINGS ARRANGED: any cards, in the page's flow, placed by one of the registry's
// arrangements (`layouts`). What a cell holds is the consumer's (a quote, a post, a
// card-link); the arrangement never knows, and a cell never knows which arrangement it
// is in, so the same cards go into lanes on a feed and a plain grid on a shelf.
//
// lanes masonry: every cell at the lane's width, placed by its real height with the
// lanes rule (`placeLane`: the lane whose bottom is highest, lanes within a
// tolerance counted level and taken in reading order, so a level stretch reads
// 1 2 3 / 4 5 6 and only a clearly shorter lane pulls a cell out of order)
// grid rows of equal lanes, each row as tall as its tallest cell
//
// THE LANE IS THE UNIT: `laneWidth` is the width a lane should have, and as many fit as
// do (the same count CSS's auto-fill gives, `laneCount`), so one number holds on a
// phone (one lane, the cells flowing) and on a wide screen, with no breakpoints. The
// gap is the container's own (`gap-*` in its className).
//
// PLACED BEFORE THE FIRST PAINT: lanes need the cells' heights, which exist only in the
// page, so the first placement is an inline script right after the container, run while
// the page is still parsing; nothing moves once shown. It is `lay` below, given
// `laneCount` and `placeLane` as arguments, each inlined as its own source (all three
// self-contained), so the rule the script runs is the one the library runs. After
// hydration the same `lay` runs again whenever a cell or the container changes size
// (fonts arriving, an image, a comment loading, a resize) and when the filter changes.
// Without JavaScript the cells stand in the grid CSS draws from the same numbers.
//
// A FILTER IN THE ADDRESS: `filter="tag"` shows only the cells whose `tags` hold the
// address's `?tag=`, from the first paint (the script reads it too), and
// `setArrangeFilter("tag", "ai")` changes it in place (replacing the history entry).
// Read through useSyncExternalStore, never useSearchParams, which in a static page makes
// everything up to its Suspense boundary render on the client only.
export type ArrangeLayout = "lanes" | "grid"
type Lay = {
masonry: boolean
laneWidth: number
tolerance: number
filter: string | null
}
/** Places the container's cells. Self-contained, no name from outside it: it is
* inlined into the page as its own source. Writes only what React does not render:
* styles, `hidden`, `data-first` on the first cell shown. */
function lay(
container: HTMLElement,
count: (width: number, laneWidth: number, gap: number) => number,
place: (bottoms: number[], cursor: number, tolerance: number) => number,
o: Lay,
): void {
const style = getComputedStyle(container)
const gapX = Number.parseFloat(style.columnGap) || 0
const gapY = Number.parseFloat(style.rowGap) || 0
const wanted =
o.filter === null
? null
: new URLSearchParams(window.location.search).get(o.filter)
const cells = Array.from(container.children) as HTMLElement[]
for (const cell of cells)
if (cell.dataset.slot !== "arrange-cell")
throw new Error(
`arrange: a child is not an ArrangeCell (${cell.tagName})`,
)
const shown = cells.filter((cell) => {
const on =
!wanted ||
(JSON.parse(cell.dataset.tags ?? "[]") as string[]).includes(wanted)
cell.hidden = !on
cell.toggleAttribute("data-first", false)
return on
})
shown[0]?.toggleAttribute("data-first", true)
const width = container.clientWidth
const lanes = count(width, o.laneWidth, gapX)
if (!o.masonry || lanes === 1) {
container.style.display = ""
container.style.height = ""
for (const cell of cells) {
cell.style.position = ""
cell.style.width = ""
cell.style.transform = ""
}
return
}
const lane = (width - gapX * (lanes - 1)) / lanes
container.style.display = "block"
for (const cell of shown) {
cell.style.position = "absolute"
cell.style.top = "0"
cell.style.left = "0"
cell.style.width = `${lane}px`
}
// Decided in lane units, as the library decides: a height is a share of the lane.
const heights = shown.map((cell) => cell.getBoundingClientRect().height)
const bottoms: number[] = new Array(lanes).fill(0)
let cursor = 0
shown.forEach((cell, i) => {
const l = place(bottoms, cursor, o.tolerance)
cell.style.transform = `translate(${l * (lane + gapX)}px, ${(bottoms[l] ?? 0) * lane}px)`
bottoms[l] = (bottoms[l] ?? 0) + ((heights[i] ?? 0) + gapY) / lane
cursor = l + 1
})
container.style.height = `${Math.max(0, Math.max(...bottoms) * lane - gapY)}px`
}
const CHANGE = "ag-arrange-filter"
function subscribe(onChange: () => void) {
window.addEventListener(CHANGE, onChange)
window.addEventListener("popstate", onChange)
return () => {
window.removeEventListener(CHANGE, onChange)
window.removeEventListener("popstate", onChange)
}
}
/** The address's `?<param>=`: null on the server and while hydrating (a static page is
* the same for every query), the address's right after. */
export function useArrangeFilter(param: string): string | null {
return React.useSyncExternalStore(
subscribe,
() => new URLSearchParams(window.location.search).get(param),
() => null,
)
}
/** Sets the address's `?<param>=` in place, replacing the history entry, so Back
* leaves the page rather than stepping through filters. */
export function setArrangeFilter(param: string, value: string | null): void {
const url = new URL(window.location.href)
if (value) url.searchParams.set(param, value)
else url.searchParams.delete(param)
window.history.replaceState(null, "", url)
window.dispatchEvent(new Event(CHANGE))
}
export function Arrange({
layout = "lanes",
laneWidth,
tolerance = 0.15,
filter,
className,
children,
}: {
layout?: ArrangeLayout
/** The width a lane should have, css px: as many lanes fit as do. */
laneWidth: number
/** Lanes: how close two lanes count as level, a share of the lane's width. */
tolerance?: number
/** The address's parameter whose value a cell's `tags` must hold to be shown. */
filter?: string
className?: string
children: React.ReactNode
}) {
const ref = React.useRef<HTMLDivElement>(null)
const observer = React.useRef<ResizeObserver | null>(null)
// Subscribed so a filter set elsewhere renders this, which lays out again below.
useArrangeFilter(filter ?? "")
const key = JSON.stringify({
masonry: layout === "lanes",
laneWidth,
tolerance,
filter: filter ?? null,
} satisfies Lay)
// Sizes change after layout (fonts, images, comments, a resize). Laying out again
// from the observer's own callback would resize what it observes inside it, so the
// next frame does it.
React.useLayoutEffect(() => {
const container = ref.current
if (!container) throw new Error("arrange: no container to observe")
let frame = 0
const watch = new ResizeObserver(() => {
cancelAnimationFrame(frame)
frame = requestAnimationFrame(() =>
lay(container, laneCount, placeLane, JSON.parse(key) as Lay),
)
})
watch.observe(container)
observer.current = watch
return () => {
cancelAnimationFrame(frame)
watch.disconnect()
observer.current = null
}
}, [key])
// Every render: a cell added or removed, a filter set. Measuring n cells is cheap;
// every cell is watched, a new one included (observing twice is a no-op).
React.useLayoutEffect(() => {
const container = ref.current
if (!container) throw new Error("arrange: no container to lay out")
lay(container, laneCount, placeLane, JSON.parse(key) as Lay)
for (const cell of Array.from(container.children))
observer.current?.observe(cell)
})
return (
<>
<div
ref={ref}
data-slot="arrange"
data-layout={layout}
suppressHydrationWarning
className={cn(
"relative grid grid-cols-[repeat(auto-fill,minmax(min(var(--ag-arrange-lane),100%),1fr))] items-start",
className,
)}
style={{ "--ag-arrange-lane": `${laneWidth}px` } as React.CSSProperties}
>
{children}
</div>
<script
// The server's and the client's bundles print the functions differently.
suppressHydrationWarning
// biome-ignore lint/security/noDangerouslySetInnerHtml: our own functions, as source
dangerouslySetInnerHTML={{
__html: `(${lay})(document.currentScript.previousElementSibling,${laneCount},${placeLane},${key})`,
}}
/>
</>
)
}
/** One cell of an arrangement: what it holds is anything. `tags` are what a filter
* matches. The first cell shown carries `data-first` (a divider drawn above every
* cell but the first: `[[data-first]>&]:hidden`). */
export function ArrangeCell({
tags,
className,
children,
}: {
tags?: readonly string[]
className?: string
children: React.ReactNode
}) {
return (
<div
data-slot="arrange-cell"
data-tags={tags ? JSON.stringify(tags) : undefined}
suppressHydrationWarning
className={className}
>
{children}
</div>
)
}