Inventory Grid.
TurtleDeck's inventory: square slots on a faint grid with beveled item swatches, short names and stack counts. The selected slot gets an orange border and the arrow keys move it.
"use client";
import * as React from "react";
import { InventoryGrid, InventorySlot } from "@/components/ui/inventory-grid";
const items: ({ name: string; count: number; color: string } | null)[] = [
{ name: "Cobble", count: 64, color: "#8b8f88" },
{ name: "Coal", count: 23, color: "#2f3331" },
{ name: "Iron", count: 7, color: "#c9a48a" },
{ name: "Torch", count: 16, color: "#f5a665" },
{ name: "Dirt", count: 41, color: "#7a5a3c" },
null,
{ name: "Redstone", count: 12, color: "#c2493a" },
{ name: "Pickaxe", count: 1, color: "#8fb3c9" },
{ name: "Oak log", count: 32, color: "#9a7a4c" },
{ name: "Gold", count: 3, color: "#e8c98a" },
null,
null,
{ name: "Kelp", count: 18, color: "#6d9a5a" },
null,
{ name: "Diamond", count: 2, color: "#7fd3cf" },
null,
];
export default function InventoryGridDemo() {
const [selected, setSelected] = React.useState<number | null>(3);
const current = selected != null ? items[selected] : null;
return (
<div className="flex w-full max-w-[280px] flex-col gap-3">
<InventoryGrid aria-label="Turtle inventory" value={selected} onValueChange={setSelected}>
{items.map((item, i) =>
item ? <InventorySlot key={i} name={item.name} count={item.count} color={item.color} /> : <InventorySlot key={i} />,
)}
</InventoryGrid>
<div className="flex items-center justify-between font-mono text-[10px] tracking-[0.2em] text-muted-foreground uppercase">
<span>Slot {selected != null ? selected + 1 : "-"}</span>
<span className="text-foreground">{current ? `${current.name} x${current.count}` : "Empty"}</span>
</div>
</div>
);
}Installation#
With the CLI
$npx lanterncn add inventory-gridThis also installs the Lantern theme and any Lantern UI components it depends on. The shadcn CLI works directly too: npx shadcn@latest add httptim/lantern-ui/inventory-grid. See Installation if your project is not set up yet.
Manually
Copy the source into your project
components/ui/inventory-grid.tsx"use client"; import * as React from "react"; import { cn } from "@/lib/utils"; type InventoryContextValue = { columns: number; count: number; size: "default" | "sm"; selected: number | null; focusIndex: number; select: (index: number) => void; register: (index: number, el: HTMLButtonElement | null) => void; }; const InventoryContext = React.createContext<InventoryContextValue | null>(null); const SlotIndexContext = React.createContext(-1); function useInventory() { const context = React.useContext(InventoryContext); if (!context) throw new Error("InventorySlot must be used within <InventoryGrid />"); return context; } /** * TurtleDeck's inventory: square slots on a faint grid. Arrow keys, Home and End move the selection. * Children are InventorySlot elements in reading order; they are split into rows of `columns`. */ function InventoryGrid({ className, children, columns = 4, size = "default", value, defaultValue = null, onValueChange, style, ...props }: Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> & { columns?: number; size?: "default" | "sm"; /** Selected slot index (controlled). */ value?: number | null; defaultValue?: number | null; onValueChange?: (index: number) => void; }) { const [internal, setInternal] = React.useState<number | null>(defaultValue); const selected = value !== undefined ? value : internal; const slots = React.Children.toArray(children).filter(React.isValidElement); const count = slots.length; const refs = React.useRef<(HTMLButtonElement | null)[]>([]); const select = React.useCallback( (index: number) => { if (value === undefined) setInternal(index); onValueChange?.(index); }, [value, onValueChange], ); const register = React.useCallback((index: number, el: HTMLButtonElement | null) => { refs.current[index] = el; }, []); function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>) { const current = selected ?? 0; const row = Math.floor(current / columns); let next: number | null = null; switch (event.key) { case "ArrowRight": next = Math.min(current + 1, count - 1); break; case "ArrowLeft": next = Math.max(current - 1, 0); break; case "ArrowDown": next = current + columns < count ? current + columns : current; break; case "ArrowUp": next = current - columns >= 0 ? current - columns : current; break; case "Home": next = event.ctrlKey ? 0 : row * columns; break; case "End": next = event.ctrlKey ? count - 1 : Math.min(row * columns + columns - 1, count - 1); break; default: return; } event.preventDefault(); if (next !== selected) select(next); refs.current[next]?.focus(); } const rows: React.ReactElement[][] = []; slots.forEach((slot, index) => { const r = Math.floor(index / columns); (rows[r] ??= []).push( <SlotIndexContext.Provider key={slot.key ?? index} value={index}> {slot} </SlotIndexContext.Provider>, ); }); return ( <InventoryContext.Provider value={{ columns, count, size, selected, focusIndex: selected ?? 0, select, register }} > <div role="grid" data-slot="inventory-grid" data-size={size} onKeyDown={onKeyDown} style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`, ...style }} className={cn( "grid w-full gap-1.5 rounded-lg border bg-card bg-grid p-2 [background-size:12px_12px] data-[size=sm]:gap-1 data-[size=sm]:p-1.5", className, )} {...props} > {rows.map((cells, r) => ( <div role="row" key={r} className="contents"> {cells} </div> ))} </div> </InventoryContext.Provider> ); } /** One square slot. Leave out name to render an empty, dimmed slot. */ function InventorySlot({ className, name, count, color = "#8a8f86", icon, onClick, ...props }: React.ComponentProps<"button"> & { /** Short item name. Omit for an empty slot. */ name?: string; count?: number; /** Swatch color for the item. */ color?: string; /** Optional content drawn inside the swatch. */ icon?: React.ReactNode; }) { const inventory = useInventory(); const index = React.useContext(SlotIndexContext); const selected = inventory.selected === index; const empty = !name; const sm = inventory.size === "sm"; return ( <div role="gridcell" aria-selected={selected} className="min-w-0"> <button ref={(el) => inventory.register(index, el)} type="button" data-slot="inventory-slot" data-selected={selected || undefined} data-empty={empty || undefined} tabIndex={index === inventory.focusIndex ? 0 : -1} aria-label={empty ? `Slot ${index + 1}, empty` : `Slot ${index + 1}, ${name}${count ? `, ${count}` : ""}`} onClick={(event) => { onClick?.(event); if (!event.defaultPrevented) inventory.select(index); }} className={cn( "group/slot relative flex aspect-square w-full cursor-pointer flex-col items-center justify-center gap-1 overflow-hidden rounded-sm border border-border bg-[#0d1210] p-1 outline-none transition-[border-color,background-color,box-shadow]", "shadow-[inset_2px_2px_0_rgba(0,0,0,0.45),inset_-1px_-1px_0_rgba(255,255,255,0.04)] hover:border-muted-foreground/60 hover:bg-[#131a17]", "focus-visible:ring-2 focus-visible:ring-ring/40", "data-[selected]:border-primary data-[selected]:bg-primary/8 data-[selected]:shadow-[inset_0_0_0_1px_var(--primary)]", "data-[empty]:bg-[#0d1210]/50", className, )} {...props} > {!empty && ( <> <span aria-hidden="true" className={cn( "flex items-center justify-center rounded-[2px] text-[#0d1210] [&_svg]:size-3/5", sm ? "size-[55%]" : "size-[46%]", "shadow-[inset_2px_2px_0_rgba(255,255,255,0.28),inset_-2px_-2px_0_rgba(0,0,0,0.38)]", )} style={{ backgroundColor: color }} > {icon} </span> {!sm && ( <span aria-hidden="true" className="w-full truncate px-0.5 text-center font-mono text-[9px] leading-none tracking-[0.04em] text-muted-foreground uppercase group-data-[selected]/slot:text-foreground" > {name} </span> )} {count != null && count > 1 && ( <span aria-hidden="true" className={cn( "absolute right-1 font-mono leading-none font-semibold text-foreground tabular-nums [text-shadow:1px_1px_0_#000]", sm ? "bottom-0.5 text-[9px]" : "top-1 text-[10px]", )} > {count} </span> )} </> )} </button> </div> ); } export { InventoryGrid, InventorySlot };Update the import paths to match your project
The source imports
cnfrom@/lib/utils.
Usage#
import { InventoryGrid, InventorySlot } from "@/components/ui/inventory-grid";<InventoryGrid aria-label="Turtle inventory" defaultValue={0}>
<InventorySlot name="Cobble" count={64} color="#8b8f88" />
<InventorySlot />
</InventoryGrid>The grid uses role=grid with one tab stop. Arrow keys move the selection, Home and End jump to the ends of a row, and Ctrl+Home or Ctrl+End jump to the first or last slot.
Set columns to change the width, for example columns={9} for a chest, and size="sm" to hide item names for tight layouts. Use value and onValueChange to control the selection.
Examples#
Compact 9 by 3 chest
"use client";
import { InventoryGrid, InventorySlot } from "@/components/ui/inventory-grid";
const palette = ["#8b8f88", "#2f3331", "#c9a48a", "#7a5a3c", "#9a7a4c", "#e8c98a", "#c2493a", "#6d9a5a"];
const names = ["Cobble", "Coal", "Iron ore", "Dirt", "Oak log", "Gold ore", "Redstone", "Kelp"];
const slots = Array.from({ length: 27 }, (_, i) => {
if ((i * 7) % 5 === 0 && i % 4 !== 1) return null;
const k = (i * 5) % palette.length;
return { name: names[k], color: palette[k], count: ((i * 23) % 64) + 1 };
});
export default function InventoryGridChest() {
return (
<div className="flex w-full max-w-md flex-col gap-2">
<div className="font-mono text-[10px] tracking-[0.2em] text-success uppercase">Storage chest, -12 64 30</div>
<InventoryGrid aria-label="Chest contents" columns={9} size="sm" defaultValue={0}>
{slots.map((slot, i) =>
slot ? <InventorySlot key={i} name={slot.name} color={slot.color} count={slot.count} /> : <InventorySlot key={i} />,
)}
</InventoryGrid>
</div>
);
}