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

Store overview

Drag a grip, or focus it and use the arrow keys.

  1. Revenue

    Monthly recurring revenue

    $84.2k

  2. Conversion

    Visitor to customer rate

    7.4%

  3. Orders

    Awaiting fulfillment

    128

  4. Alerts

    Inventory warnings

    3

  5. Activity

    Events in the last hour

    246

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">
            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.

  1. Inventory

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

  2. InventoryItem

    Native list item placed from its matching layout entry. Owns a DOM element.

  3. InventoryPreviewOptional

    Optional aria-hidden list item placed over the current pointer 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, with optional move and resize handles inside each card.

  3. 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

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

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 pointer 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 move is in progress.
[data-resizing]A pointer 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 move is in progress.
[data-resizing]A pointer 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 pointer position cannot fit or was vetoed.

InventoryMoveHandle

Style hookMeaning
[data-dragging]A pointer move is in progress.

InventoryResizeHandle

Style hookMeaning
[data-resizing]A pointer resize is in progress.

Keep exploring