Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Shop list

New

A game vendor’s overlay: a list of goods with stock and price, a sliding cursor frame, the item’s detail, the coin balance and the vendor’s voice. Buying rolls the coins down; short of coins, it shakes.

shop-list/shop-list.tsx
Live · interactive
Armored Squash220
Dewmelon524
Sprintnut214
Emberstool218
Brawn Plantains1125
Price
20
In Inventory
×4

Armored Squash

  • Restores half a heart
  • Defense Up

A thick-skinned squash grown along the terrace walls. Cooked, its toughness carries over and hardens your guard.

Mara

Rain keeps the crowds away, so I’ve marked down the best of the harvest. Have a look!

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 vendor the player talks to in a game: a short list of goods to scroll, read and buy with one button, while the world waits behind it.
  • Goods whose effects matter more than their picture: the detail panel names what one does (a heart, Defense Up) before the coins are spent.
  • A shop with a running balance and limited stock, where buying must answer at once: the coins roll down, the stock ticks, the vendor thanks you.
  • A menu driven by a pad or the keyboard as much as by the pointer: a cursor that moves row to row, A to buy, B to leave.
  • A merchant with a voice: the vendor’s line types itself in under the goods and changes with what the player does.

When not to use

  • A real store with real money, a cart and a checkout. Use a product list with an add-to-cart button and a cart drawer.
  • Dozens of goods to filter or sort. Use an item grid with tabs or a searchable table; this list shows five to eight rows.
  • Selling from the player’s own bag, or trading item for item. Use an inventory grid with a sell action.
  • A single offer, such as a one-time upgrade. Use a game modal with a price and a confirm button.
  • Choosing among answers in a conversation, with nothing to pay. Use a choice list under the dialog box.

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

Into components/shop-list of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/shop-list.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

vendor.tsx
import { ShopList, type ShopItem } from './shop-list';const GOODS: ShopItem[] = [  {    id: 'squash',    name: 'Armored Squash',    price: 20,    stock: 2,    owned: 4,    glyph: 'squash',    effects: [{ icon: 'half-heart' }, { icon: 'shield', label: 'Defense Up' }],    description: 'A thick-skinned squash grown along the terrace walls.',  },  {    id: 'melon',    name: 'Dewmelon',    price: 24,    stock: 5,    glyph: 'melon',    effects: [{ icon: 'heart' }, { icon: 'frost', label: 'Heat Resist' }],    description: 'A melon heavy with cold dew, picked before sunrise.',  },];export function Vendor({ coins, onBuy }: { coins: number; onBuy: (id: string) => void }) {  return (    <ShopList      items={GOODS}      vendor="Mara"      coins={coins}      onBuy={(item) => onBuy(item.id)}      autoplay={false}    />  );}

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 screen a game opens when the player talks to a merchant. A header row (All Items, Stock, Price) tops a list of plates, each with its glyph, name, stock and price; a cream frame with white corner ticks marks the row under the cursor and slides from row to row. Beside it, the detail panel shows the item big on a turning burst of light, its price, how many the player already carries, its effects and a short description, under the coin balance. The vendor speaks in a rounded bubble below. Buying ticks the stock down, rolls the balance down with a floating −20 and pops the inventory count; without enough coins, the row, the cursor and the balance shake and flash red, and the vendor says so. Browse with the arrows or the mouse, buy with A, Enter or a second click.

  • Every length is a multiple of --px, one pixel of the layout it was measured on: rows 40 px tall on a 58 px pitch, a 348 px list beside a 342 × 249 px detail panel, a 481 × 98 px bubble. On a wide stage --px is min(1.12px, (100cqi − 48px) / 738), a container query unit, so the whole overlay scales as one; below 780 px the list, the panel and the bubble stack in one 348-unit column and --px follows the stage down to about 0.8 px at 320.
  • The cursor is one frame laid over the list, not a style on the selected row: --at holds the selected index and the frame’s translate is --at × the row pitch, eased with a small overshoot, so it slides from row to row. A 1.6-unit cream border and four white corner triangles (clip-path) make it; while it rests, the border and a soft light breathe every 1.6 s.
  • Moving the cursor remounts the big glyph and the text below it (keyed by item), which replays their keyframes: the glyph pops from 0.82, the name, effects and description fade in. The burst of light behind the glyph is a repeating conic gradient masked by a radial one, turning once every 36 s.
  • Buying (A, Enter, Space, a second click on the selected row or the Buy hint) checks stock, then coins. A purchase decrements the stock (the digit drops in), adds one to In Inventory (it pops green), sweeps a light across the row and rolls the balance to its new total over 520 ms with requestAnimationFrame, with a floating −20 under it. Short of coins, nothing changes: the row, the cursor and the balance shake sideways in 420 ms and the price and the balance flash red. A sold-out row dims, shows Sold out and is refused.
  • The vendor’s line types itself in two letters every 22 ms. The whole line is in the bubble from the start, the letters still to come only transparent, so the bubble never reflows and a screen reader reads it all at once.
  • Uncontrolled, a demo plays a short visit while the shop is on screen and untouched: browse down, buy plantains, come back for a squash, try a melon one coin short, settle for a nut, and the stall resets. Any input hands it to the reader for 7 s.
  • There is no world behind it here, so every part sits on its own dark plate (#33322e) and reads the same on a light page or a dark one; the plates’ edges and shadows follow the colour scheme through light-dark() tokens. The produce, the effect icons and the coins are small SVGs drawn for it; no filter touches them, so they stay sharp at any pixel density.
PropTypeDefaultDescription
itemsShopItem[]five invented produce itemsThe goods: { id, name, price, stock, owned?, glyph, effects?, description }. glyph is squash, melon, nut, cap or plantain; an effect is { icon, value?, label? }, icon one of heart, half-heart, shield, flame, frost, speed, blade, and a label makes it a green buff chip. Stock and owned are where it starts; a new array starts over.
vendorstring'Mara'The speaker’s name on the bubble’s tab, and the list’s accessible name.
linesShopLinesDEFAULT_LINESWhat the vendor says: { greeting, bought(item), short(item, coins), soldOut(item), back }.
coinsnumber—The balance, controlled: the shop then never changes it itself; update it from onBuy.
defaultCoinsnumber64Uncontrolled, the balance it starts with.
defaultIndexnumber0The item the cursor starts on, to open the shop on a given row; giving it also turns the demo off.
onBuy(item: ShopItem, result: { coins: number; stock: number; owned: number }) => void—Called after a purchase, with the balance, stock and count it leaves.
onSelect(item: ShopItem, index: number) => void—Called when the cursor moves to an item.
onBack() => void—Called on Back (B, Escape or the Back hint); the vendor says goodbye.
autoplaybooleantrueWhile on screen and untouched, a short visit: browse, buy twice, come up short, buy again, reset. Only with the default items.
colorScheme'light' | 'dark' | 'system'—Forces light or dark for the plates’ edges and shadows; by default it follows the page. The plates, art and text keep their colours.
classNamestring—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.

shop-list.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useCallback,  useEffect,  useEffectEvent,  useId,  useRef,  useState,  type CSSProperties,  type KeyboardEvent,  type PointerEvent,} from 'react';import {  CoinGold,  CoinOutline,  Effect,  FaceButton,  Pouch,  Produce,  type EffectIcon,  type ProduceGlyph,
art.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.import { useId } from 'react';/* * The shop's art, all small SVGs drawn for this component (every one is decorative: aria-hidden). * * - Produce glyphs, on a 48 × 48 box: a ribbed squash, a striped melon, a capped nut, a cluster of *   ember caps, a bunch of plantains. Flat shapes with one soft gradient each, a darker rim and a *   highlight, so they read at 30 px in a row and at 72 px in the detail panel. * - Effect icons on a 20 × 20 box (a heart, a shield, a flame, a snowflake, a winged boot, a blade), *   in the bold, flat, glowing spirit of a game's status icons. * - The coin: an upright oval with an inner ring, as a white outline for prices, and as a struck gold *   coin for the balance. The pouch, for "In Inventory". The face buttons, a light disc with its *   letter. * * Gradient ids come from useId, so any number of shops can share a page. */export type ProduceGlyph = 'squash' | 'melon' | 'nut' | 'cap' | 'plantain';export type EffectIcon = 'heart' | 'half-heart' | 'shield' | 'flame' | 'frost' | 'speed' | 'blade';const RIM = '#2b1a0c';
shop-list.module.css
/* Shop list (shop-list.tsx): a vendor overlay alone on the stage. Every length is a multiple of   --px, one pixel of the kit the layout was measured on: 1 px to 1.12 px on a wide stage (the list   and the detail panel side by side, 738 kit px), and on a narrow one (below 780 px, where they   stack, 348 kit px wide) whatever fits, down to about 0.8 px at 320.   Every part sits on its own dark plate (#33322e, as in the kit), so the overlay reads on a light   page or a dark one; the plates' edges and shadows follow the colour scheme through light-dark()   tokens, the art and the text keep their colours. No filter touches the art or the text: glows   are gradients and shadows drawn behind them, so everything stays sharp.   Motion: the cursor frame slides (a transition on translate) and breathes (keyframes); a press,   a purchase, a shake, a pop and the burst behind the big glyph are keyframes restarted by   remounting their element. data-paused (offscreen) stops the endless ones. */.root {  /* Text and art on the plates. */  --text: #d7d3c9;  --text-dim: #c4c0b5;  --text-hot: #f6f3e6;  --buff: #6fd49c;  --warn: #ff5d4f;  --glyph: #e1ded1;  --glyph-ink: #24221d;  /* The plates. */
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. `visible` and `reduced` are returned too, so * the component can pause its own idle motion offscreen and skip its typing under reduced motion. */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 goods are a listbox with one option per item, named with its stock and price (“Dewmelon, 5 in stock, 24 coins”); the cursor is aria-activedescendant, so the focus stays on the list.
  • With the list focused: the up and down arrows move the cursor (wrapping), Home and End jump, Enter, Space or A buys, B or Escape is Back. The Back and Buy hints are real buttons with aria-keyshortcuts.
  • A polite live region says what happened: “Bought Dewmelon for 24. 40 coins left, 4 in stock.”, “Not enough coins: Dewmelon costs 24, you have 19.”, or that an item is sold out.
  • The detail panel is a labelled section with the price and inventory count as a description list and the effects as a list; icon-only effects carry a hidden name. The vendor’s whole line is in the DOM from the start, only its untyped letters transparent.
  • All art is aria-hidden; the list and the hints show a :focus-visible ring.
  • Reduced motion: no slides, shakes, pops, sweeps, typing or turning light; values change in place and only the colour flashes remain. The demo never plays, and it pauses offscreen and in a hidden tab.