Item grid
NewA game inventory page: category tabs, a grid of dark item tiles with power badges, an equipped fill and a gliding cursor, and the item’s stats below.
Traveler Sword
A plain, well-balanced sword carried by those who walk the long roads.
The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- A game’s inventory page, where the player stops to compare gear and choose what to carry, equip or eat.
- Items that split cleanly into a handful of categories, each browsed as one page of tiles by tabs or shoulder buttons.
- Gear the player reads by shape and a single number: art, power or amount, a bonus mark, and wear shown at a glance.
- Menus driven by a controller as much as a pointer: a cursor that moves tile by tile, face buttons for use and sort.
- Comparing the item under the cursor with the one in hand before committing, with a green or red arrow on the stat.
When not to use
- One item looked at in depth, with its full stats and lore. Use an item card.
- Buying or selling, where price and the player’s money lead. Use a shop list.
- A handful of items always on screen during play. Use a quick selector or a HUD slot.
- Long lists read by name, such as quests or settings. Use a list or a quest log; a grid of icons hides the words.
- Files, products or photos in an ordinary app. Use a plain card grid with the app’s own controls.
You can add this component with the shadcn CLI or by hand:
Into components/item-grid of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/item-grid.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { ItemGrid, type InventoryItem } from './item-grid';const pack: InventoryItem[] = [ { id: 'sword', name: 'Soldier Sword', category: 'weapons', icon: 'sword', power: 40, durability: 0.7, equipped: true }, { id: 'axe', name: 'Woodcutter Axe', category: 'weapons', icon: 'axe', power: 90, durability: 0.18 }, { id: 'apple', name: 'Apple', category: 'food', icon: 'apple', count: 12, hearts: 1 },];export function Inventory({ items, setItems }: { items: InventoryItem[]; setItems: (next: InventoryItem[]) => void }) { return ( <ItemGrid items={items} onItemsChange={setItems} onSelect={(item) => console.log('on', item.name)} /> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
The page a player opens to choose what to carry into the next fight. Category tabs run along the top, with the open one lit, its name above it and a stick hint showing the way to the next. The items fill a grid of dark tiles: each shows its art, its power or how many there are, and a mark for any bonus; the one in hand is filled blue, a worn one warns in amber and a broken one is cracked and red. A bright frame glides from tile to tile, and the item under it shows its name, its stats against the one in hand and a line of description. Equip, eat, sort and change category with the controller’s keys, a gamepad or the pointer. The page brings its own dark plate, so it reads on a light or a dark page.
- Every size is a multiple of one design unit, --u, worked out on the plate from the component’s width with a container query unit, so the page scales as one drawing and keeps the kit’s proportions: 67-unit tiles 10.5 units apart, a 29 × 19 badge flush with the tile’s lower right corner, 23-unit tab icons on a 46-unit pitch. Narrow, it is one column 425 units wide; from 720 px the details move beside the grid with a large view of the item (817 units).
- The cursor is a single element over the grid, placed by a transform from its column and row: a step glides in 150 ms with a small overshoot, after a sort it travels with its item in 420 ms, and on a new page it jumps. Against the grid’s edge it bumps 5 px towards the wall and springs back, played with the Web Animations API on the separate translate property so it never fights the glide. The frame is a 2-unit cream border with filled corners (four small gradients), a dark inner rim and a soft glow that breathes.
- Sorting steps through type, power and wear (or type and amount for things that stack). Each tile keeps its key, so React moves the same element; before paint, every tile’s old and new slot are compared and the tile is played from its old place to its new one (FLIP), slightly staggered. A new page cascades its tiles in from the top left corner, 22 ms per step, also in script, so a sort (which moves tiles in the DOM) never replays it.
- Equipping floods the tile blue from its centre (a layer scaling from 0.4 with a light rim), pops the tile to 0.9 and past 1, and sends out a ring of light; the other item of the category drains at the same time. A broken item refuses with a short shake. Eating takes one at a time, with the count bouncing, and the last one leaves the grid while the tiles after it slide back.
- The tab row’s lit line and the right stick hint slide under the open tab, its name swaps above it and its icon pops; dots over a tab count items not yet seen, and clear a moment after the category is opened. Each category keeps its own cursor and sort order.
- Input: arrows move the cursor while the grid has focus (Home and End jump to the ends); A, Enter or Space equip or eat; Y or S sort; Q and E, L and R, or Page Up and Page Down change category, and the tabs follow the arrows as a tab list. A connected gamepad drives it too (d-pad or stick with repeat, A, Y, the shoulders), and a click on a tile selects it, a second click uses it.
- Uncontrolled, it keeps its own items, and a demo plays a short browse while it is on screen and untouched: a refused broken sword, an equip, a bump against the edge, three sorts, a bow equipped, a run through the categories, two meals and back. Any real input hands it to the reader where the demo left it.
- All art is SVG drawn for this component: flat two-tone items with a thin dark outline laid on the diagonal, glows made of a wide faint copy of the shape (no blur filters), one-colour tab icons, and the face button and stick glyphs of the other game components. The plate, tiles and type keep their colours in both schemes, as a game menu does; only the plate’s edge, its shadow and the focus ring follow light-dark() tokens.
| Prop | Type | Default | Description |
|---|---|---|---|
items | InventoryItem[] | — | The inventory, controlled: every category’s items in their own order ({ id, name, category, icon, power?, count?, durability?, equipped?, modifier?, hearts?, fresh?, description? }). Turns the demo off; changes come back through onItemsChange. |
defaultItems | InventoryItem[] | a sample pack | Uncontrolled, the items it starts with. |
onItemsChange | (items: InventoryItem[]) => void | — | Called with the next items when the reader equips, unequips or eats something, or opens a category with new items. |
category | 'weapons' | 'bows' | 'shields' | 'armor' | 'materials' | 'food' | 'key' | — | The open category, controlled; pair it with onCategoryChange. |
defaultCategory | ItemCategory | 'weapons' | Uncontrolled, the category it opens on. |
onCategoryChange | (category: ItemCategory) => void | — | Called when the reader asks for another category (keys, tabs or a gamepad). |
onSelect | (item: InventoryItem) => void | — | Called when the cursor lands on an item. |
defaultSelected | string | — | Uncontrolled, the id of the item the cursor opens on (in that item’s category). Turns the demo off. |
defaultSort | 'type' | 'power' | 'durability' | 'count' | 'type' | The order the opening category starts in (power and durability for gear, count for things that stack). Turns the demo off. |
slots | number | 15 | Slots drawn per page at least; empty ones show as faint squares, and more rows appear when a category has more items. |
keys | boolean | true | Listen to the keyboard while the component has focus, and to a connected gamepad. |
autoplay | boolean | true | While on screen and untouched, a short browse: moves, a refused broken item, equip, sort, categories, eating. Any input stops it. |
colorScheme | 'light' | 'dark' | 'system' | — | Light or dark, or the reader’s; without it, the page’s color-scheme. The plate keeps its colours on both; its edge, shadow and the focus ring follow it. |
className | string | — | Added to the root. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useCallback, useEffect, useEffectEvent, useId, useLayoutEffect, useRef, useState, type CSSProperties,} from 'react';import { BrokenMark, Cracks, FaceGlyph, Heart, ItemArt, ModifierMark, StickGlyph, TabGlyph,} from './glyphs';/* Item grid (item-grid.tsx): an inventory page on its own dark plate, centred on a plain stage. Every size is a multiple of --u, one design unit (1 px at full size), set on the plate from the component's width (a container query unit), so the page scales as one drawing and keeps the kit's proportions: 67-unit tiles 10.5 apart, a 29 × 19 badge, a 46-unit tab pitch. Narrow, the page is one column (tabs, grid, details, hints: 425 units wide); from 720 px the details move beside the grid with a large view of the item (817 units). Colours are the tokens below. The plate and everything on it keep their colours in both schemes, as a game menu does: dark tiles, cream type, a blue equipped fill, a cream cursor. Only the plate's edge and shadow and the focus ring follow the scheme, through light-dark(). Motion: the cursor glides by a transform transition (an overshoot for a step, slower after a sort, none on a new page); a bump, a pop, a refusal shake and the sort's FLIP are played by the script with the Web Animations API; tiles cascade in, words swap, the equipped fill floods and the worn badge breathes as CSS animations and transitions. */.root { /* The plate. */ --plate: #191a17; --plate-top: #1f201c; --plate-edge: rgb(255 255 255 / 0); --plate-shade: rgb(30 26 16 / 0.28); /* Type and lines. */// Copyright (c) 2026 Bruno Delorence. MIT License./* * The item grid's art, all small SVGs drawn for this component: * * - Item glyphs, on a 64 × 64 box: flat shapes in two tones (a lit face and a shaded face) with a * thin dark outline, laid on the diagonal from lower left to upper right as an inventory shows a * weapon. Long things (blades, spears, bows) are drawn upright about the box's centre and turned * 45°. A few carry a glow: a wide, faint copy of the shape behind it (no blur filter, so the art * stays sharp at any pixel density). * - Tab icons, on a 24 × 24 box, in one colour (currentColor), bold and flat. * - Corner marks (a modifier on the item, a broken blade, a heart for food), on a 16 × 16 box. * - The controller glyphs of the hints: a face button disc with its letter, and the right stick * seen slightly from above with an arrow, as in the other game components. */export type ItemIcon = | 'sword' | 'glow-sword' | 'great-sword' | 'curved-sword' | 'spear' | 'axe' | 'club'// Copyright (c) 2026 Bruno Delorence. MIT License.import type { ItemIcon, Modifier, TabIcon } from './glyphs';/* * The inventory's model: categories (the tabs), items, and the sort orders. The default items are * invented: plain names a player would recognise by shape, none from any game. */export type ItemCategory = TabIcon;export type InventoryItem = { /** Stable id: keeps a tile (and the cursor on it) through sorts and changes. */ id: string; name: string; category: ItemCategory; icon: ItemIcon; /** Attack (weapons, bows) or defence (shields, armour): shown in the tile's badge. */ power?: number; /** How many: shown as ×n in the tile when more than one (materials, food, key items). */ count?: number; /** What is left, from 1 (new) to 0 (broken). Under 0.25 the badge warns; at 0 it is broken. */ durability?: number; /** In hand: the tile is filled blue. One per category. */// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. `visible` alone says whether it is on screen * in a visible tab, for clocks that should stop when nobody can see them. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden);- The categories are a real tab list (arrow keys move along it, the open tab is the one in the tab order), and the grid is its tab panel: a listbox whose options are the items, with the cursor’s item as the active descendant, so a screen reader reads each item as the cursor reaches it.
- Each item is named in full: name, attack or defence, how many, its bonus, and whether it is equipped, worn out or broken (“Woodcutter Axe, Attack 90, about to break”). Empty slots, the cursor and all art are hidden from assistive technology.
- A polite live region reports what actions do: equipping, eating (“Ate Apple. 11 left.”), a refusal for a broken item, the sort order and the category opened with its item count.
- Sort and Equip are also real buttons at the foot, with their controller keys in aria-keyshortcuts; the use button is disabled for items that cannot be used.
- Focus shows as an outline round the tab, the grid or the button that has it, blue on a light page and light blue on a dark one; the cursor frame shows the item inside the grid.
- Under reduced motion the demo does not play and every change lands at once: no glide, bump, pop, cascade or flight, and the worn badge does not breathe.