Navigation
Inventory
A uniform spatial grid whose cards can move and span more rows or columns.
When to use it: Use it for customizable dashboards and boards where position and size must persist as grid units.
Example
Store overview
Drag a grip, or focus it and use the arrow keys.
Revenue
Monthly recurring revenue
$84.2k
Conversion
Visitor to customer rate
7.4%
Orders
Awaiting fulfillment
128
Alerts
Inventory warnings
3
Activity
Events in the last hour
246
import { useState } from "react";
import {
Inventory,
InventoryItem,
InventoryMoveHandle,
InventoryPreview,
InventoryResizeHandle,
type InventoryLayout,
} from "@comp0/react";
const initialLayout: InventoryLayout = [
{ value: "revenue", column: 1, row: 1, columnSpan: 3, rowSpan: 2 },
{ value: "conversion", column: 4, row: 1, columnSpan: 3, rowSpan: 1 },
{ value: "orders", column: 4, row: 2, columnSpan: 2, rowSpan: 2 },
{ value: "alerts", column: 6, row: 2, columnSpan: 1, rowSpan: 2 },
{ value: "activity", column: 1, row: 3, columnSpan: 3, rowSpan: 2 },
];
const cards = {
revenue: { title: "Revenue", summary: "Monthly recurring revenue", metric: "$84.2k" },
conversion: { title: "Conversion", summary: "Visitor to customer rate", metric: "7.4%" },
orders: { title: "Orders", summary: "Awaiting fulfillment", metric: "128" },
alerts: { title: "Alerts", summary: "Inventory warnings", metric: "3" },
activity: { title: "Activity", summary: "Events in the last hour", metric: "246" },
};
function MoveGrip() {
return (
<svg aria-hidden="true" className="size-4" fill="currentColor" viewBox="0 0 12 18">
<circle cx="3" cy="3" r="1.25" />
<circle cx="9" cy="3" r="1.25" />
<circle cx="3" cy="9" r="1.25" />
<circle cx="9" cy="9" r="1.25" />
<circle cx="3" cy="15" r="1.25" />
<circle cx="9" cy="15" r="1.25" />
</svg>
);
}
function ResizeGrip() {
return (
<svg aria-hidden="true" className="size-full" viewBox="0 0 24 24">
<path d="M9 19h10V9M14 19l5-5" fill="none" stroke="currentColor" strokeWidth="1.5" />
</svg>
);
}
export function Example() {
const [layout, setLayout] = useState(initialLayout);
return (
<div>
<div className="mb-4 flex items-end justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-zinc-950 dark:text-white">Store overview</h2>
<p className="mt-1 text-sm/6 text-zinc-600 dark:text-zinc-400">
Drag a grip, or focus it and use the arrow keys.
</p>
</div>
<button
className="shrink-0 rounded-lg border border-zinc-950/10 px-3 py-2 text-sm font-medium outline-teal-600 hover:bg-zinc-50 focus-visible:outline-2 dark:border-white/10 dark:outline-teal-400 dark:hover:bg-zinc-800"
type="button"
onClick={() => setLayout(initialLayout)}
>
Reset
</button>
</div>
<div className="overflow-x-auto pb-2">
<Inventory
aria-label="Store dashboard"
columns={6}
rows={6}
value={layout}
onChange={setLayout}
className="relative h-[30rem] min-w-[42rem] list-none gap-2 rounded-xl border border-zinc-950/10 bg-zinc-100/70 p-2 dark:border-white/10 dark:bg-zinc-950"
>
<li
aria-hidden="true"
className="pointer-events-none absolute inset-2 grid grid-cols-6 grid-rows-6 gap-2"
>
{Array.from({ length: 36 }, (_, index) => (
<span
className="rounded-md border border-dashed border-zinc-950/10 bg-white/40 dark:border-white/10 dark:bg-white/2"
key={index}
/>
))}
</li>
<InventoryPreview className="pointer-events-none z-20 rounded-lg border-2 border-teal-500 bg-teal-500/10 data-invalid-placement:border-red-500 data-invalid-placement:bg-red-500/10" />
{layout.map((entry) => {
const card = cards[entry.value as keyof typeof cards];
return (
<InventoryItem
key={entry.value}
value={entry.value}
textValue={card.title}
className="relative z-10 min-w-0 overflow-hidden rounded-lg border border-zinc-950/10 bg-white p-3 shadow-sm data-dragging:opacity-70 data-resizing:ring-2 data-resizing:ring-teal-500 dark:border-white/10 dark:bg-zinc-900"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h3 className="truncate text-sm font-medium text-zinc-700 dark:text-zinc-200">
{card.title}
</h3>
<p className="mt-1 truncate text-xs text-zinc-500 dark:text-zinc-400">
{card.summary}
</p>
</div>
<InventoryMoveHandle className="shrink-0 cursor-grab touch-none rounded p-1 text-zinc-400 outline-teal-600 hover:bg-zinc-100 hover:text-zinc-700 active:cursor-grabbing focus-visible:outline-2 data-dragging:cursor-grabbing dark:outline-teal-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100">
<MoveGrip />
</InventoryMoveHandle>
</div>
<p className="mt-3 text-2xl font-semibold tracking-tight text-zinc-950 dark:text-white">
{card.metric}
</p>
<InventoryResizeHandle className="absolute right-1 bottom-1 size-7 cursor-nwse-resize touch-none rounded text-zinc-400 outline-teal-600 hover:bg-zinc-100 hover:text-zinc-700 focus-visible:outline-2 dark:outline-teal-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100">
<ResizeGrip />
</InventoryResizeHandle>
</InventoryItem>
);
})}
</Inventory>
</div>
</div>
);
}Anatomy
Dashed frames are invisible state providers; shaded shapes own real DOM. Numbered pins match the list below.
A wireframe sketch of the assembled component. Each numbered marker matches a part in the list that follows.
Inventory
Controlled or uncontrolled owner for one spatial list layout. Owns a DOM element.
InventoryItem
Native list item placed from its matching layout entry. Owns a DOM element.
InventoryPreviewOptional
Optional aria-hidden list item placed over the current pointer target. Owns a DOM element.
InventoryMoveHandleOptional
Native button that moves its item by whole grid cells. Owns a DOM element.
InventoryResizeHandleOptional
Native button that changes its item's row and column spans. Owns a DOM element.
Step by step
- 1
Add the main part
Start Inventory with its column count, row count, and a complete layout.
- 2
Add the supporting parts
Render one InventoryItem per layout value, with optional move and resize handles inside each card.
- 3
Make the behavior clear
Add InventoryPreview for a styleable pointer landing overlay; blocked placements stay put and only the preview becomes invalid.
Exampletsx <Inventory columns={6} rows={6} value={layout} onChange={setLayout}> <InventoryPreview /> <InventoryItem value="sales" textValue="Sales"> <InventoryMoveHandle /> <InventoryResizeHandle /> Sales </InventoryItem> </Inventory>;
Keyboard
- ←→↑↓
- Moves the item one cell in that direction. · on InventoryMoveHandle
- ←→
- Shrinks or grows the column span. · on InventoryResizeHandle
- ↑↓
- Shrinks or grows the row span. · on InventoryResizeHandle
Forms and accessibility
Inventory does not create a form value; persist its layout in application state or storage.
Accessibility checklist
- Give Inventory an aria-label or aria-labelledby; it renders a native ordered list rather than claiming ARIA grid behavior.
- Keep both handles visible and clearly named. Arrow keys provide the same move and resize operations without dragging.
- InventoryPreview is aria-hidden; use its valid and invalid styling only as visual reinforcement for the live announcements.
- Keep item DOM order meaningful even when visual positions change, and announce saved ordering separately when reading order must also change.
- A fixed spatial grid may need horizontal scrolling on narrow screens; do not silently rewrite persisted coordinates for visual responsiveness.
API reference
import { Inventory, InventoryItem, InventoryMoveHandle, InventoryPreview, InventoryResizeHandle } from "@comp0/react";Inventory
DOM elementControlled or uncontrolled owner for one spatial list layout.
| Prop | Type | Description |
|---|---|---|
columns | number | Positive integer bounds for the uniform grid. |
rows | number | Positive integer bounds for the uniform grid. |
value | InventoryLayout | Complete positions and spans as one-based grid units. |
defaultValue | InventoryLayout | Complete positions and spans as one-based grid units. |
onChange | (value: InventoryLayout) => void | Receives the complete next layout during movement and resizing. |
canChange | (value, changedValue) => boolean | Vetoes a proposed complete layout before it is emitted. |
InventoryItem
DOM elementNative list item placed from its matching layout entry.
| Prop | Type | Description |
|---|---|---|
value | string | Unique key matching one Inventory layout entry. |
textValue | string | Readable card name used by default handle labels and announcements. |
InventoryPreview
OptionalDOM elementOptional aria-hidden list item placed over the current pointer target.
InventoryMoveHandle
OptionalDOM elementNative button that moves its item by whole grid cells.
InventoryResizeHandle
OptionalDOM elementNative button that changes its item's row and column spans.
Style hooks
Attributes that appear while a state is true. Target them with Tailwind data variants such as data-open:bg-zinc-100, or with any CSS selector.
Inventory
| Style hook | Meaning |
|---|---|
[data-dragging] | A pointer move is in progress. |
[data-resizing] | A pointer resize is in progress. |
InventoryItem
| Style hook | Meaning |
|---|---|
[data-column] | The item's one-based grid position. |
[data-row] | The item's one-based grid position. |
[data-column-span] | The number of grid tracks occupied by the item. |
[data-row-span] | The number of grid tracks occupied by the item. |
[data-dragging] | A pointer move is in progress. |
[data-resizing] | A pointer resize is in progress. |
InventoryPreview
| Style hook | Meaning |
|---|---|
[data-column] | The proposed landing position. |
[data-row] | The proposed landing position. |
[data-column-span] | The proposed landing size. |
[data-row-span] | The proposed landing size. |
[data-invalid-placement] | The proposed pointer position cannot fit or was vetoed. |
InventoryMoveHandle
| Style hook | Meaning |
|---|---|
[data-dragging] | A pointer move is in progress. |
InventoryResizeHandle
| Style hook | Meaning |
|---|---|
[data-resizing] | A pointer resize is in progress. |