Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Stock grid

New

An editable sheet of a shop’s category mix: stage orders in one click, drag-fill a row, type in place, swap suppliers in the cell, sort by the biggest gap, undo anything.

stock-grid/stock-grid.tsx
Live · interactive

Stock Mix

Linden Row Grocer, $240k, orders v7

Aisle order
DrinksPantry
DairyChilled
BakeryFresh
ProduceFresh
FrozenChilled
SnacksPantry
HomeNon-food
CareNon-food
TotalCategories
Share%Target / actual
+2.4
+0.7
-0.2
Unpriced
-0.7
-1.2
-2.4
+0.2
98.4Budget 1.6
Supplier
DST
DIR
DIR
WHL
DST
DST
WHL
DST
Target%
15.0
12.0
7.0
11.0
9.0
15.0
17.0
14.0
100.0
Tolerancepp
2.0
1.0
1.0
1.0
2.0
2.0
2.0
2.0
Min order$k
1
1
1
1
2
1
1
1
Case cost$
13.60
22.40
17.85
25.90
31.20
18.70
26.80
35.40
Value$k
42
30
16
25
20
33
35
34
236
Order$k
-6
+6
0

Budget $6k, $5k after suggested orders and $115 fees

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 planner’s working sheet: a handful of items (categories, regions, channels) as columns and the same few numbers for each, edited in place and sent as one batch.
  • Targets against actuals where the drift decides the action: the grid tints each share by its gap and suggests the order that closes it.
  • Bulk edits along a row: copy one tolerance or supplier into the next three cells with the fill handle, or into every category with Copy to All.
  • Changes that have to be reviewed before they go out: corner marks on what changed, an unsent count, Undo on every toast, Revert to the last submission.

When not to use

  • Many rows of records (orders, products, people). Use a data table with sorting, paging and a row per record; this grid has a column per item and stops reading past about ten.
  • Free-form spreadsheets with formulas. The rows and their rules are fixed in code; give people a real spreadsheet instead.
  • Read-only reporting. A grid of editable cells invites changes; use a dashboard of charts and figures.
  • A single number to change. Use a form field or a stepper beside its label, with no selection model to learn.
  • Phones as the main place it is used. Under 760 px the grid scrolls sideways; a list of category cards with one edit sheet each reads better.

You can add this component with the shadcn CLI or by hand:

Into components/stock-grid of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/stock-grid.json

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

page.tsx
import { StockGrid } from './stock-grid';export function Page() {  return <StockGrid />;}

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.

A shop’s stock mix as a grid: each category a column, with its share of the stock (target over actual, tinted green, amber or red by how far it drifts), supplier, target, tolerance, minimum order, case cost, value and the order it needs. A side panel picks the season plan, counts the categories by stage and refreshes the costs one by one. Select a cell and drag its handle along the row to copy it, type a new number in place, open a supplier cell for its menu, re-sort the columns by the largest gap and watch them slide over. Changed cells get a corner mark, every change a toast with Undo, and a share opens a drawer with the category’s plans, cost log and history.

  • The model is plain data: the editable part (plan, what orders aim for, and per category its targets for each plan, tolerance, minimum order, supplier, share, cost and staged orders), the same as last submitted, the stock’s value and the cash left. Stages, gaps, totals and suggested orders are worked out from it on every render.
  • A category inside half its tolerance is on plan, inside its tolerance on watch, beyond it off plan, and gets a suggested order back to its target (or just inside the tolerance). Buys that would spend more than the budget are scaled down to fit, and the footer says so.
  • Every change goes through one commit that keeps the state before it, so each toast’s Undo, and Ctrl/Cmd+Z and Shift+Ctrl/Cmd+Z, step through the history. Changed cells are found by comparing with the last submission, which also counts the unsent changes.
  • The grid is a CSS grid of cells, the rows display: contents. One element draws the selection over it, placed by measuring the first and last cell of the range after each render and written to its style; dragging its handle shows a dashed outline over the target range and copies the first value into it on release.
  • Typing a number in a selected cell starts an edit in place (so do Enter, F2 and a double click); Enter or Tab keeps it, Escape drops it. A selected supplier cell shows a chevron and opens a menu of the three supplier kinds.
  • Sorting by the largest gap is a FLIP: each column’s left edge is measured before the change, and after it every cell of a column slides from where it was by a transform.
  • Toasts stack at the top of the card: a new one drops in front while the one before tucks behind it, a little smaller; the check in its icon draws itself.
  • Update Costs reprices the categories one after the other, a spinner in each share as it goes; a share opens a drawer over the blurred grid with an overview against every plan, the cost log and the history, Hold and Stage Order.
  • While on screen and untouched, a demo pointer stages the orders, fills a row, types two targets, swaps a supplier, reprices, looks through a drawer, sorts, switches plan, considers reverting and submits, then starts again; any input of the reader’s stops it for good.
  • Every colour is a light-dark() token on the root, so it follows the page’s color-scheme, or the colorScheme prop. Under 760 px the side panel moves above the grid, and the grid scrolls sideways inside the card with its row names held in place.
PropTypeDefaultDescription
autoplaybooleantrueWhile on screen and untouched, a demo pointer goes through staging, filling, editing, sorting and submitting. Any input of the reader’s stops it.
colorScheme'light' | 'dark' | 'system''system'Light or dark; by default it follows the page’s color-scheme.
classNamestring—Extra classes for the root.
defaultStateStockGridState—Opens in a given state, with the demo off: changes applied first, each with its toast (plan, orderTo, edits, hold, staged; toasts: false drops them), then sort and hidden, then select (a cell or a range), fill, edit, menu, drawer, dialog, or Update Costs (pricing) and Submit Orders (submitting) held mid-way.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

stock-grid.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useCallback,  useEffect,  useId,  useLayoutEffect,  useMemo,  useRef,  useState,  type CSSProperties,  type KeyboardEvent as ReactKeyboardEvent,  type PointerEvent as ReactPointerEvent,  type ReactNode,  type RefObject,} from 'react';import {  CATEGORIES,  FIELD_NAMES,  PLANS,  PRICE_LIST,  START,
data.ts
// Copyright (c) 2026 Bruno Delorence. MIT License./* * The stock grid's model: an invented corner shop's category mix, and the rules that read it. * * - A category holds a share of the stock (its actual, in %), a target share for each season plan, *   a tolerance in percentage points, a minimum order, a supplier and a case cost. * - Its stage follows from the gap between actual and target: inside half the tolerance it is on *   plan, inside the tolerance it is on watch, beyond it off plan. A category with no price yet is *   unpriced, one put on hold is on hold; neither gets an order. * - An off-plan category gets a suggested order, in $k, that brings it back to its target (or just *   inside its tolerance). Buys that would spend more than the budget are scaled down to fit. * - Everything here is a pure function of a Doc, the editable part of the state; the component *   keeps the Doc as of the last submission (the base) to count and mark what changed. */export type Supplier = 'DST' | 'WHL' | 'DIR';export type Plan = 'Winter' | 'Spring' | 'Summer' | 'Autumn';export type OrderTo = 'Target' | 'Edge';export type Stage = 'on' | 'watch' | 'off' | 'unpriced' | 'hold';export type Field = 'supplier' | 'target' | 'tol' | 'min' | 'cost' | 'value' | 'order';export const PLANS: Plan[] = ['Winter', 'Spring', 'Summer', 'Autumn'];export const SUPPLIERS: { id: Supplier; name: string }[] = [
drawer.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useId, useRef, type KeyboardEvent as ReactKeyboardEvent } from 'react';import {  PLANS,  categoryOf,  costLog,  fmt1,  signed,  supplierName,  type Doc,  type Entry,  type Stage,  type Suggestion,} from './data';import { Icon, type IconName } from './icons';import styles from './stock-grid.module.css';/* * The drawer of one category, over the blurred grid: its stage, its numbers against the current * plan and every other plan, its cost log and its history, with Hold and Stage Order at the foot. * It is a modal dialog: it takes the focus, keeps Tab inside, and Escape or the close button
icons.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License./* * The stock grid's icons: thin line drawings on a 16-unit grid, stroked in the current colour. * They are decorative (the words next to them, or the button's label, say what they mean); the * stylesheet sizes them. */export type IconName =  | 'columns'  | 'stage'  | 'revert'  | 'send'  | 'chevron'  | 'left'  | 'right'  | 'check'  | 'close'  | 'share'  | 'layers'  | 'target'  | 'tolerance'  | 'note'  | 'tag'
demo-pointer.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, or * glide the pointer somewhere. Stopping the demo (the reader takes over, or it scrolls away) ends * the step in progress at once: its promise rejects, which ends the script right there, and the * pointer fades out. * * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame. * The points of the flight are handed to whoever wants to move along with it (the eye of the * badge, which plays its own animation on the same clock). The arrow and the hand are HTML boxes * that swap by their visibility: a change of style inside an SVG would lay the SVG out again. */export type Point = { x: number; y: number };/** A glide's path and timing, for an animation that plays along with it. */export type Flight = { points: Point[]; timing: KeyframeAnimationOptions };export type Demo = {
use-autoplay.ts
// 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, or never when `idle` is null. */export function useAutoplay<T extends HTMLElement>(idle: number | null = 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);    const observer = new IntersectionObserver(
stock-grid.module.css
/* Stock grid (stock-grid.tsx): a card on a plain stage. The card has a title bar, a body (the   side panel and the grid) and a footer; toasts, menus, the drawer and the dialog sit over it on   the stage.   Sizes are px of a 13 px interface. The grid's rows have fixed heights, so the side panel's Costs   block lines up with the three cost rows; its columns share the width that is left, down to a   minimum, under which the grid scrolls sideways inside the card (the row names stay put).   Colours are the tokens below, light-dark(light, dark): the component takes the color-scheme it   inherits from the page, or the one its colorScheme prop sets (data-color-scheme).   Motion is transforms and opacity: the selection moves by a transform, columns slide (FLIP),   toasts drop in and stack, menus, the drawer and the dialog fade and rise. Under reduced motion   all of it is instant. */.root {  position: relative;  container-type: inline-size;  width: 100%;  box-sizing: border-box;  padding: 30px 16px 16px;  overflow: hidden;  overflow: clip;  border-radius: 18px;
  • The grid is role="grid" with row and column headers; it takes the focus as one stop and names the selected cell with aria-activedescendant. Arrow keys move the selection, Shift+arrows stretch it along the row, Home and End jump to the ends, Escape clears it.
  • Typing a digit, Enter or F2 edits a number in place in a labelled input; Enter or Tab keeps it, Escape drops it. On a supplier cell Enter opens a listbox; on a share it opens the drawer; on a suggested order it stages it.
  • Menus and lists take the focus on their checked item, move with the arrow keys, Home and End, and give the focus back to their trigger on Escape. The plan selects are buttons with aria-haspopup="listbox" and aria-expanded.
  • The drawer is a modal dialog (the card behind it is inert): its tabs are a tablist with arrow keys, Tab stays inside, Escape or the close button gives the focus back. Revert asks first in an alertdialog whose Cancel takes the focus.
  • Toasts are announced from a polite live region, as are the costs’ progress and the budget line; toasts behind the front one are hidden from assistive tech and inert.
  • Stages are written in words, not only colours; the demo pointer is aria-hidden, and under reduced motion there is no demo and no animation.