Skip to content

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.

On this page

Example

Loading example…
Inventory.tsxtsx
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">
            Arrow between cards, Tab into a grip, then press Enter or Space to adjust.
          </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-[38rem] 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 scroll-mx-2 overflow-hidden rounded-lg border border-zinc-950/10 bg-white p-3 shadow-sm outline-teal-600 focus-visible:outline-2 data-dragging:border-dashed data-dragging:border-orange-500 data-resizing:border-dashed data-resizing:border-orange-500 dark:border-white/10 dark:bg-zinc-900 dark:outline-teal-400"
              >
                <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.

  1. Inventory

    Controlled or uncontrolled owner for one spatial list layout. Owns a DOM element.

  2. InventoryItem

    Roving-focus native list item placed from its matching layout entry. Owns a DOM element.

  3. InventoryPreviewOptional

    Optional aria-hidden list item placed over the current provisional target. Owns a DOM element.

  4. InventoryMoveHandleOptional

    Native button that moves its item by whole grid cells. Owns a DOM element.

  5. InventoryResizeHandleOptional

    Native button that changes its item's row and column spans. Owns a DOM element.

Step by step

  1. 1

    Add the main part

    Start Inventory with its column count, row count, and a complete layout.

  2. 2

    Add the supporting parts

    Render one InventoryItem per layout value; arrow between cards, then Tab through the focused card's optional move and resize handles.

  3. 3

    Make the behavior clear

    Add InventoryPreview for a styleable pointer or active keyboard 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 />
        Sales
        <InventoryResizeHandle />
      </InventoryItem>
    </Inventory>;

Keyboard

Moves focus to the closest item in that visual direction. · on InventoryItem
Steps through the focused item's controls, then leaves Inventory. · on InventoryItem or one of its controls
Steps backward through the focused item's controls and back to its item. · on an InventoryItem control
Space
Starts moving or resizing; pressing it again commits the change. · on an Inventory move or resize handle
Moves the item one cell while movement is active. · on an active InventoryMoveHandle
Shrinks or grows the column span while resizing is active. · on an active InventoryResizeHandle
Shrinks or grows the row span while resizing is active. · on an active InventoryResizeHandle
Esc
Cancels an active interaction and restores its starting layout; leaving the handle does the same.

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 with one roving item tab stop rather than claiming ARIA grid behavior.
  • Arrow keys move item focus by persisted visual position. Tab enters the focused item's controls and leaves Inventory after its last control.
  • Keep both handles visible and clearly named. Enter or Space activates one, arrows adjust its item, and Enter or Space commits the change.
  • 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

Importtsx
import { Inventory, InventoryItem, InventoryMoveHandle, InventoryPreview, InventoryResizeHandle } from "@comp0/react";

Inventory

DOM element

Controlled or uncontrolled owner for one spatial list layout.

PropTypeDescription
columnsnumberPositive integer bounds for the uniform grid.
rowsnumberPositive integer bounds for the uniform grid.
valueInventoryLayoutComplete positions and spans as one-based grid units.
defaultValueInventoryLayoutComplete positions and spans as one-based grid units.
onChange(value: InventoryLayout) => voidReceives the complete next layout during movement and resizing.
canChange(value, changedValue) => booleanVetoes a proposed complete layout before it is emitted.

InventoryItem

DOM element

Roving-focus native list item placed from its matching layout entry.

PropTypeDescription
valuestringUnique key matching one Inventory layout entry.
textValuestringReadable card name used by default handle labels and announcements.

InventoryPreview

OptionalDOM element

Optional aria-hidden list item placed over the current provisional target.

InventoryMoveHandle

OptionalDOM element

Native button that moves its item by whole grid cells.

InventoryResizeHandle

OptionalDOM element

Native 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 hookMeaning
[data-dragging]A pointer drag or armed keyboard move is in progress.
[data-resizing]A pointer drag or armed keyboard resize is in progress.

InventoryItem

Style hookMeaning
[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 drag or armed keyboard move is in progress.
[data-resizing]A pointer drag or armed keyboard resize is in progress.

InventoryPreview

Style hookMeaning
[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 position cannot fit or was vetoed.

InventoryMoveHandle

Style hookMeaning
[data-dragging]A pointer drag or armed keyboard move is in progress.

InventoryResizeHandle

Style hookMeaning
[data-resizing]A pointer drag or armed keyboard resize is in progress.

Keep exploring