# Item card

> A game inventory’s detail card: name, rarity stars, stat and effect chips and an italic description, re-entering with a stagger as the cursor moves.

The card a game’s inventory shows for whatever the cursor rests on: the name in bold italic, one to three stars on its top edge, the number that matters for its kind (attack, defence, or hearts for food), the equipped value it would replace, effect chips with coloured icons (Durability Up, Climb Speed Up, a meal’s timed boost with its hourglass) and a short italic description. Each new item re-enters row by row: the name slides in, the rule draws, the stars pop, the chips rise, an upgrade flashes. Pick an item from the slots below, or use the arrow keys.

A React component by Bruno Delorence: https://brunodelorence.com/components/item-card. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: Games · Menus.

## Anatomy

1. **Plate**: The opaque card, 31.125 em wide (498 px at 16 px): darker under the text, lighter to the right, a thin outline and a light hairline inside.
2. **Rarity stars**: One to three stars sitting on the plate’s top edge, `role="img"` with “3 stars”; three get a glint across them.
3. **Name**: The item’s name, a heading in bold italic: it slides in from the left whenever the item changes.
4. **Rule**: A 1 px light line over a 2 px dark one, drawn from the left after the name.
5. **Stat chip**: The number that matters for the kind beside a white stat icon (a sword for attack, a shield or tunic for defence), as “equipped ▸ new” when `compare` is set.
6. **Effect chip**: A coloured icon and a label: green for a boost, white for a trait.
7. **Description**: The italic flavour text in a 308 px column, fading in last.
8. **Inventory slots**: A `radiogroup` of square slots under the card; the cursor is a cream frame. Click, tap or arrow keys pick the item. Left out when `bare`.

## When to use

- The detail pane of a game inventory: the card for whatever the cursor rests on, swapped as fast as the cursor moves.
- Answering “is this worth carrying?” at a glance: the one number that matters for its kind, against what is equipped.
- Items whose effects need a word or a timer: a trait such as Durability Up, or a meal’s boost with its level and mm:ss.
- A shop, chest or loot screen that shows one item at a time beside a grid or list of slots.
- Flavour text the player reads only when they linger, under the numbers they read first.

## When not to use

- Browsing many items at once to compare. Use an item grid, with this card beside it for the one in focus.
- A short note on something pointed at in the world or a HUD. Use a tooltip or an action prompt.
- Buying and selling, where the price and the player’s money lead. Use a shop list.
- A full character sheet with every stat. Use a stats panel or a menu tab.
- A product card outside a game. Use a plain card; this plate, the stars and the chips speak the language of a game menu.

## Installation

Install it with the shadcn CLI, into components/item-card of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/item-card.json
```

Or copy all the source files below into one folder of your project. Either way it needs React 19 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: 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

```tsx
import { ItemCard, type Item } from './item-card';

const ITEMS: Item[] = [
  {
    name: 'Tempered Longsword',
    kind: 'weapon',
    stars: 3,
    value: 38,
    compare: 22,
    effects: [{ icon: 'critical', label: 'Critical Hit' }],
    description: 'Forged twice over in a mountain kiln, its edge holds through a long campaign.',
  },
  {
    name: 'Spiced Mountain Skewer',
    kind: 'food',
    hearts: 6,
    effects: [{ icon: 'attack', level: 2, duration: 1800 }],
    description: 'Grilled meat and fiery peppers on a stick.',
  },
];

export function ItemDetail({ hovered }: { hovered: string }) {
  return <ItemCard bare items={ITEMS} selected={hovered} />;
}
```

## States

- **Upgrade**: `compare={22}` under a value of 38: the arrow nudges and the new value flashes green.
- **Even**: `compare` equal to `value`: both boxes, no flash.
- **No comparison**: No `compare`: the stat icon and the value alone, as for an item with nothing equipped.
- **Food**: `hearts={9}`: five hearts drawn, the first with a count of the rest; then a timed Attack Up with its hourglass.
- **Timed level 2**: A timed effect with `level: 2`: its icon twice, then 10:00.
- **Material**: No stars, no stat: a level-3 timed effect and the description.
- **Two effects**: A trait in white (Durability Up) next to a boost in green (Stealth Up).
- **Bare**: `bare`: the card alone, without the slots and the stage’s room.
- **Small**: `--item-card-size: 12px` on a parent: every size is in em, so the whole card scales.
- **Light**: `colorScheme="light"`: a soft shadow and a dark ring around the cursor. The plate keeps its colours.
- **Dark**: `colorScheme="dark"`: a deeper shadow, a lifted outline and no ring around the cursor.

## How it works

- An item is data: a name, a kind (weapon, shield, armour, food, material), 0–3 stars, a value and the equipped value to compare, hearts for food, a list of effects and a description. The kind picks the white stat icon beside the value: a sword for attack, a shield or a tunic for defence.
- Every size is in em of a 16 px card, the kit’s own px divided by 16: a 498 × 132 plate, a 23 px bold italic name, a 1 px rule with a 2 px dark line beneath it, 20 px chips, 13 px italic text on a 14.7 px pitch in a 308 px column. A narrow stage draws it at 14 px through a container query, and --item-card-size sets it by hand.
- The plate is opaque: a thin dark outline, a light hairline inside it, and a fill that stays dark under the text and lightens towards the right edge. The chips are darker still, lit from below by a lighter rim on their sides and foot. So the card reads the same on a light page or a dark one; only its shadow, its outer edge, the stars’ outline and the slots’ focus ring follow the colour scheme through light-dark() tokens.
- An effect with a label is a coloured icon and words, green for a boost, white for a trait. A timed effect (a meal’s) repeats its icon once per level (one to three), then a green hourglass and mm:ss in tabular figures. Hearts draw up to five; past five, the first heart carries a small count. All icons are small flat SVGs in a 24-unit box, filled with currentColor; a thin dark line separates overlapping parts.
- The content is keyed by the item, so a new item remounts it and replays its entry: the name slides in 0.35 em from the left (240 ms), the rule draws from the left (320 ms), the stars pop from 0.4 with an overshoot 55 ms apart, the chips rise 45 ms apart with their icons popping, and the description fades in last. Three stars get one glint across them. A value higher than the compared one nudges the arrow and flashes its box green. There is no exit: a new item replaces the old at once, as fast cursor moves demand.
- On the stage, a row of inventory slots picks the item, the cursor a cream frame that snaps on with a small pop. While the card is on screen and untouched, the cursor walks the slots every 2.6 s; any click, tap or key hands it to the reader. bare draws the card alone, for the selected item.

## API reference

- **items** (`Item[]`, default `a sword, a shield, a tunic, two meals and a flower`): The items, each { name, kind, description, stars?, value?, compare?, hearts?, effects?, id? }. kind is weapon, shield, armor, food or material. An effect is { icon, label?, level?, duration?, tone? }: icon is critical, durability, durability-plus, climb, attack, defense, stealth, speed, shock, heat, cold, flame, throw, heart, extra-heart or stamina; duration in seconds makes it a timer chip.
- **selected** (`string`): The id (or name) of the item shown, controlled: changing it replays the card’s entry. The demo is off.
- **defaultSelected** (`string`, default `the first item`): Uncontrolled, the item it starts on.
- **onSelectedChange** (`(id: string, item: Item) => void`): Called when a slot is picked by click, tap or the arrow keys, or by the demo.
- **bare** (`boolean`, default `false`): The card alone, without the slots and the stage’s room, to place in a menu of your own. Its size is 16 px, or --item-card-size.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, the cursor walks the slots every 2.6 s. Uncontrolled only.
- **colorScheme** (`'light' | 'dark' | 'system'`): Forces light or dark for the card’s shadow and edge, the stars’ outline and the slots’ ring; by default it follows the page. The plate, text and chips keep their colours.
- **className** (`string`): Added to the root.

## Accessibility

- The card is real text: the name is a heading, the chips a list, the description a paragraph. Each chip carries hidden words for its icons and boxes (“Attack 38, equipped 22”, “Restores 9 hearts”, “Attack up, for 30:00”); the stars are an image named “3 stars”; all art is aria-hidden.
- The slots are a radio group named Inventory, each radio named by its item, with one tab stop: the arrow keys move and pick, Home and End jump to the ends, and the focus follows.
- A polite live region reads the whole item (name, stars, stats, effects, description) when the reader picks one; the demo’s own moves are not announced.
- Every control shows a :focus-visible ring.
- Reduced motion: no slides, draws, pops, glints or flashes; the content crossfades in 120 ms. The demo never plays.

## Source

### item-card/item-card.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import {
  useCallback,
  useEffect,
  useId,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
} from 'react';
import {
  CompareArrow,
  EFFECT_TINT,
  Hourglass,
  Icon,
  Star,
  type EffectIconName,
  type IconName,
  type StatIconName,
} from './icons';
import { useAutoplay } from './use-autoplay';
import styles from './item-card.module.css';

/*
 * Item card: the detail card of a game's inventory, for whatever the cursor rests on. It answers
 * "what is this, and is it worth carrying?" at a glance:
 *
 * - The name in bold italic, its rarity as 1–3 stars sitting on the card's top edge, a rule.
 * - A row of stat chips: the number that matters for its kind (attack for a weapon, defence for a
 *   shield or armour) beside a white stat icon, optionally "old ▸ new" against what is equipped;
 *   hearts restored for food; and effect chips, each a coloured icon and a label ("Durability Up",
 *   green for a boost) or, for a meal's timed effect, the icon repeated per level, an hourglass
 *   and mm:ss.
 * - An italic description, the flavour the player reads only when they linger.
 *
 * The card is a dark plate with a thin dark outline and a light hairline inside, darker on the
 * left (where the text is) and lighter towards the right. It draws its own plate, so it reads on a
 * light page or a dark one; only its shadow and the stars' outline follow the colour scheme.
 *
 * Motion: when the item changes, the content (keyed by the item) remounts and replays its entry,
 * staggered by row: the title slides in from the left, the rule draws, the stars pop one by one,
 * the chips rise and pop their icons, the description fades in last. A better value than the
 * compared one nudges its arrow and flashes. No exit: a new item replaces the old at once, as fast
 * cursor moves demand.
 *
 * On the stage, a row of inventory slots under the card picks the item (click, tap, arrow keys);
 * a demo walks the cursor along them while the card is on screen and untouched. `bare` draws the
 * card alone, for the item given by `selected` (or the first).
 */

export type ItemKind = 'weapon' | 'shield' | 'armor' | 'food' | 'material';

export type ItemEffect = {
  /** Which effect: picks its icon and colour. */
  icon: EffectIconName;
  /** The words beside the icon ("Climb Speed Up"). Without it and with a duration, a timer chip. */
  label?: string;
  /** For a timed effect: how many times the icon shows (its strength), 1–3. */
  level?: 1 | 2 | 3;
  /** For a timed effect (food): how long it lasts, in seconds, shown as mm:ss. */
  duration?: number;
  /** The label's colour: green for a boost, white for a trait. Defaults by icon. */
  tone?: 'green' | 'white';
};

export type Item = {
  /** Stable id; defaults to the name. */
  id?: string;
  name: string;
  kind: ItemKind;
  /** Rarity, 0–3 stars. */
  stars?: 0 | 1 | 2 | 3;
  /** Attack (weapon) or defence (shield, armour). */
  value?: number;
  /** The equipped item's value, shown as "compare ▸ value". */
  compare?: number;
  /** Hearts restored (food). Up to five are drawn; past five, the first heart carries a count. */
  hearts?: number;
  effects?: ItemEffect[];
  description: string;
};

export const DEFAULT_ITEMS: Item[] = [
  {
    name: 'Tempered Longsword',
    kind: 'weapon',
    stars: 3,
    value: 38,
    compare: 22,
    effects: [{ icon: 'critical', label: 'Critical Hit' }],
    description:
      'Forged twice over in a mountain kiln, its edge holds through a long campaign. Strikes that land after a perfect dodge cut far deeper.',
  },
  {
    name: 'Lacquered Kite Shield',
    kind: 'shield',
    stars: 2,
    value: 26,
    effects: [{ icon: 'durability', label: 'Durability Up' }],
    description:
      'Layers of resin over hardwood make it light yet stubborn. It shrugs off blows that would split a plainer shield in two.',
  },
  {
    name: 'Ridgewalker Tunic',
    kind: 'armor',
    stars: 1,
    value: 3,
    compare: 3,
    effects: [{ icon: 'climb', label: 'Climb Speed Up' }],
    description:
      'Cut loose at the shoulders and stitched with grip at the palms, it was made for those who would rather go up than around.',
  },
  {
    name: 'Spiced Mountain Skewer',
    kind: 'food',
    hearts: 9,
    effects: [{ icon: 'attack', level: 1, duration: 1800 }],
    description:
      'Grilled meat and fiery peppers on a stick. It warms the blood and puts a little more weight behind every swing for a while.',
  },
  {
    name: 'Frostcap Stew',
    kind: 'food',
    hearts: 3,
    effects: [{ icon: 'heat', level: 2, duration: 600 }],
    description:
      'A chilled stew of pale mushrooms that grow under the snowline. Eat it before a long walk across the scorched flats.',
  },
  {
    name: 'Storm Lily',
    kind: 'material',
    effects: [{ icon: 'shock', level: 3, duration: 330 }],
    description:
      'A flower that only opens during thunderstorms. Cooked into a meal, it hardens the body against lightning for a short time.',
  },
];

const keyOf = (item: Item) => item.id ?? item.name;

const STAT_ICON: Partial<Record<ItemKind, StatIconName>> = {
  weapon: 'sword',
  shield: 'shield',
  armor: 'tunic',
};
const SLOT_ICON: Record<ItemKind, IconName> = {
  weapon: 'sword',
  shield: 'shield',
  armor: 'tunic',
  food: 'apple',
  material: 'crystal',
};
const SLOT_TINT: Partial<Record<ItemKind, string>> = {
  food: '#f1362f',
  material: '#b98cff',
};
const STAT_NAME: Record<ItemKind, string> = {
  weapon: 'Attack',
  shield: 'Defence',
  armor: 'Defence',
  food: '',
  material: '',
};

/** Effects whose label is green by default (a boost); the rest are white (a trait). */
const GREEN = new Set<EffectIconName>([
  'critical',
  'climb',
  'attack',
  'defense',
  'heart',
  'extra-heart',
  'stealth',
  'speed',
]);
const EFFECT_NAME: Record<EffectIconName, string> = {
  critical: 'Critical hit',
  durability: 'Durability up',
  'durability-plus': 'Durability up plus',
  climb: 'Climb speed up',
  attack: 'Attack up',
  defense: 'Defence up',
  stealth: 'Stealth up',
  speed: 'Speed up',
  shock: 'Shock resistance',
  heat: 'Heat resistance',
  cold: 'Cold resistance',
  flame: 'Flame guard',
  throw: 'Long throw',
  heart: 'Hearts',
  'extra-heart': 'Extra hearts',
  stamina: 'Stamina',
};

const clock = (seconds: number) => {
  const s = Math.max(0, Math.round(seconds));
  return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
};

/** What a screen reader hears for an item. */
export function describeItem(item: Item) {
  const parts = [item.name];
  if (item.stars) parts.push(`${item.stars} star${item.stars > 1 ? 's' : ''}`);
  if (item.value !== undefined) {
    const stat = STAT_NAME[item.kind] || 'Value';
    parts.push(
      item.compare !== undefined
        ? `${stat} ${item.value}, equipped ${item.compare}`
        : `${stat} ${item.value}`,
    );
  }
  if (item.hearts) parts.push(`restores ${item.hearts} heart${item.hearts > 1 ? 's' : ''}`);
  for (const effect of item.effects ?? []) {
    const name = effect.label ?? EFFECT_NAME[effect.icon];
    const level = effect.level && effect.level > 1 ? ` level ${effect.level}` : '';
    parts.push(
      effect.duration !== undefined
        ? `${name}${level} for ${clock(effect.duration)}`
        : `${name}${level}`,
    );
  }
  return `${parts.join(', ')}. ${item.description}`;
}

/** The card itself: plate, stars, name, rule, chips and description. */
function Card({ item, first }: { item: Item; first: boolean }) {
  const stat = STAT_ICON[item.kind];
  const up = item.compare !== undefined && item.value !== undefined && item.value > item.compare;
  const stars = Math.max(0, Math.min(3, item.stars ?? 0));
  const hearts = item.hearts ?? 0;
  const drawn = Math.min(5, Math.ceil(hearts));
  let chip = 0;
  const order = () => chip++;

  return (
    <div className={styles.card} data-part="card" data-first={first || undefined}>
      <div className={styles.content} key={keyOf(item)} data-kind={item.kind}>
        {stars > 0 && (
          <div
            className={styles.stars}
            data-part="stars"
            data-shine={stars === 3 || undefined}
            role="img"
            aria-label={`${stars} star${stars > 1 ? 's' : ''}`}
          >
            {Array.from({ length: stars }, (_, i) => (
              <span key={i} className={styles.star} style={{ '--i': i } as CSSProperties}>
                <Star />
              </span>
            ))}
          </div>
        )}
        <h3 className={styles.name} data-part="name">
          {item.name}
        </h3>
        <div className={styles.rule} data-part="rule" aria-hidden="true" />
        <ul className={styles.chips} data-part="stats">
          {item.value !== undefined && (
            <li
              className={styles.stat}
              data-part="stat"
              style={{ '--i': order() } as CSSProperties}
            >
              <span className={styles.live}>
                {STAT_NAME[item.kind] || 'Value'} {item.value}
                {item.compare !== undefined ? `, equipped ${item.compare}` : ''}
              </span>
              {stat && <Icon name={stat} className={styles.statIcon} />}
              {item.compare !== undefined && (
                <>
                  <span className={styles.box} data-part="compare" aria-hidden="true">
                    {item.compare}
                  </span>
                  <CompareArrow className={styles.compareArrow} />
                </>
              )}
              <span
                className={styles.box}
                data-part="value"
                data-up={up || undefined}
                aria-hidden="true"
              >
                {item.value}
              </span>
            </li>
          )}
          {item.hearts !== undefined && (
            <li
              className={styles.chip}
              data-part="hearts"
              style={{ '--i': order() } as CSSProperties}
            >
              <span className={styles.live}>
                Restores {hearts} heart{hearts === 1 ? '' : 's'}
              </span>
              {drawn === 0 ? (
                <Icon name="heart" className={styles.icon} color="#3a3d33" />
              ) : (
                Array.from({ length: drawn }, (_, i) => (
                  <span key={i} className={styles.heart} aria-hidden="true">
                    <Icon name="heart" className={styles.icon} color={EFFECT_TINT.heart} />
                    {i === 0 && hearts > 5 && (
                      <span className={styles.heartCount}>{Math.ceil(hearts) - 4}</span>
                    )}
                  </span>
                ))
              )}
            </li>
          )}
          {(item.effects ?? []).map((effect, index) => {
            const timed = effect.duration !== undefined && !effect.label;
            const tone = effect.tone ?? (timed || GREEN.has(effect.icon) ? 'green' : 'white');
            const level = Math.max(1, Math.min(3, effect.level ?? 1));
            return (
              <li
                key={index}
                className={styles.chip}
                data-part="effect"
                data-tone={tone}
                style={{ '--i': order() } as CSSProperties}
              >
                {!effect.label && (
                  <span className={styles.live}>
                    {EFFECT_NAME[effect.icon]}
                    {level > 1 ? ` level ${level}` : ''}
                    {effect.duration !== undefined ? ', for ' : ''}
                  </span>
                )}
                <span className={styles.icons} aria-hidden="true">
                  {Array.from({ length: timed ? level : 1 }, (_, i) => (
                    <Icon
                      key={i}
                      name={effect.icon}
                      className={styles.icon}
                      color={
                        timed && (effect.icon === 'attack' || effect.icon === 'defense')
                          ? '#fff'
                          : EFFECT_TINT[effect.icon]
                      }
                    />
                  ))}
                </span>
                {effect.label && <span className={styles.label}>{effect.label}</span>}
                {effect.duration !== undefined && (
                  <span className={styles.timer} data-part="timer">
                    <Hourglass className={styles.hourglass} />
                    {clock(effect.duration)}
                  </span>
                )}
              </li>
            );
          })}
        </ul>
        <p className={styles.description} data-part="description">
          {item.description}
        </p>
      </div>
    </div>
  );
}

export type ItemCardProps = {
  className?: string;
  /** The items on the stage's slots, or the one card's pool when bare. */
  items?: Item[];
  /** The id (or name) of the item shown, controlled. */
  selected?: string;
  /** The item it starts on, uncontrolled. */
  defaultSelected?: string;
  /** Asked when a slot is picked (click, tap, arrow keys). */
  onSelectedChange?: (id: string, item: Item) => void;
  /** The card alone, without the slots. */
  bare?: boolean;
  /** While on screen and untouched, the cursor walks the slots (uncontrolled only). */
  autoplay?: boolean;
  colorScheme?: 'light' | 'dark' | 'system';
};

/** How long the demo cursor rests on each item. */
const DWELL = 2600;

export function ItemCard({
  className,
  items = DEFAULT_ITEMS,
  selected: selectedProp,
  defaultSelected,
  onSelectedChange,
  bare = false,
  autoplay = true,
  colorScheme,
}: ItemCardProps) {
  const controlled = selectedProp !== undefined;
  const [own, setOwn] = useState(() => defaultSelected ?? (items[0] ? keyOf(items[0]) : ''));
  const current = controlled ? selectedProp : own;
  const index = Math.max(
    0,
    items.findIndex((item) => keyOf(item) === current),
  );
  const item = items[index];

  const { ref, take, playing } = useAutoplay<HTMLDivElement>();
  const demo = playing && autoplay && !controlled && !bare && items.length > 1;
  const [first, setFirst] = useState(true);
  const [said, setSaid] = useState('');
  const slotRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const labelId = useId();

  // The plate's own entrance plays once; later swaps only replay the content.
  useEffect(() => {
    const id = window.setTimeout(() => setFirst(false), 400);
    return () => window.clearTimeout(id);
  }, []);

  const pick = useCallback(
    (next: number, byReader: boolean) => {
      const target = items[(next + items.length) % items.length];
      if (!target) return;
      const id = keyOf(target);
      if (!controlled) setOwn(id);
      onSelectedChange?.(id, target);
      if (byReader) setSaid(describeItem(target));
    },
    [controlled, items, onSelectedChange],
  );

  // The demo: the cursor steps to the next slot every DWELL ms.
  useEffect(() => {
    if (!demo) return;
    const id = window.setInterval(() => pick(index + 1, false), DWELL);
    return () => window.clearInterval(id);
  }, [demo, index, pick]);

  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    const step: Record<string, number> = {
      ArrowRight: 1,
      ArrowDown: 1,
      ArrowLeft: -1,
      ArrowUp: -1,
    };
    let next: number | undefined;
    if (event.key in step) next = index + step[event.key];
    else if (event.key === 'Home') next = 0;
    else if (event.key === 'End') next = items.length - 1;
    if (next === undefined) return;
    event.preventDefault();
    take();
    const wrapped = (next + items.length) % items.length;
    pick(wrapped, true);
    slotRefs.current[wrapped]?.focus();
  };

  if (!item) return null;

  return (
    <div
      ref={ref}
      className={`${styles.root} ${className ?? ''}`}
      data-color-scheme={colorScheme}
      data-bare={bare || undefined}
      onPointerDown={take}
    >
      <Card item={item} first={first} />
      {!bare && (
        <div
          className={styles.slots}
          role="radiogroup"
          aria-labelledby={labelId}
          data-part="slots"
          style={{ '--count': items.length } as CSSProperties}
          onKeyDown={onKeyDown}
        >
          <span id={labelId} className={styles.live}>
            Inventory
          </span>
          {items.map((entry, i) => {
            const on = i === index;
            return (
              <button
                key={keyOf(entry)}
                ref={(element) => {
                  slotRefs.current[i] = element;
                }}
                type="button"
                role="radio"
                aria-checked={on}
                aria-label={entry.name}
                tabIndex={on ? 0 : -1}
                className={styles.slot}
                data-part="slot"
                data-on={on || undefined}
                onClick={() => {
                  take();
                  pick(i, true);
                }}
              >
                <Icon
                  name={SLOT_ICON[entry.kind]}
                  className={styles.slotIcon}
                  color={SLOT_TINT[entry.kind]}
                />
                {entry.value !== undefined && (
                  <span className={styles.slotValue}>{entry.value}</span>
                )}
              </button>
            );
          })}
        </div>
      )}
      <p className={styles.live} aria-live="polite">
        {said}
      </p>
    </div>
  );
}
```

### item-card/icons.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

import type { ReactNode } from 'react';

/*
 * The card's icons: flat, bold shapes in a 24-unit box. The stat icons (sword, shield, tunic) are
 * white; each effect has its own colour so it reads at a glance (blue gem for durability, green
 * slope for climbing, yellow bolt for shock...). Shapes are filled with currentColor, so a chip
 * tints its icon; the small up/down arrows that say "boost" or "resist" stay white. A thin dark
 * line (the card's own ink) separates overlapping parts, as on the slots' art. All decorative:
 * the words beside them say what they are.
 */

const INK = '#2b2a27';

/** A straight sword, point up, along the vertical axis of a 24 box (rotated by its caller). */
const blade = (
  <>
    <path d="M12 1 14.6 4v10.6H9.4V4L12 1Z" />
    <rect x="6.2" y="14.6" width="11.6" height="2.4" rx="0.6" />
    <rect x="10.8" y="17" width="2.4" height="3.6" />
    <circle cx="12" cy="21.6" r="1.7" />
    <path d="M12 3.6v10" stroke={INK} strokeWidth="1" />
  </>
);

/** A heater shield whose left half is hollow, so it reads as a shield and not a blob. */
const shieldShape = (
  <path
    fillRule="evenodd"
    d="M12 1.6 20.6 4.6v7.2c0 5.4-3.6 9.2-8.6 10.8-5-1.6-8.6-5.4-8.6-10.8V4.6L12 1.6Zm0 3.4-5.6 2V12c0 3.6 2.2 6.3 5.6 7.6V5Z"
  />
);

/** The white arrow at the lower right of a boost (up) or a resistance (down). */
const arrow = (down = false) => (
  <path
    fill="#fff"
    stroke={INK}
    strokeWidth="1.1"
    paintOrder="stroke"
    transform={down ? 'rotate(180 18.5 18.5)' : undefined}
    d="M18.5 12.6 23 17.4h-2.8v5.2h-3.4v-5.2H14l4.5-4.8Z"
  />
);

export type StatIconName = 'sword' | 'shield' | 'tunic';

export type EffectIconName =
  | 'critical'
  | 'durability'
  | 'durability-plus'
  | 'climb'
  | 'attack'
  | 'defense'
  | 'stealth'
  | 'speed'
  | 'shock'
  | 'heat'
  | 'cold'
  | 'flame'
  | 'throw'
  | 'heart'
  | 'extra-heart'
  | 'stamina';

/** Each effect's colour (its icon's fill). */
export const EFFECT_TINT: Record<EffectIconName, string> = {
  critical: '#ffffff',
  durability: '#3cd3fc',
  'durability-plus': '#fcc413',
  climb: '#88d201',
  attack: '#ffffff',
  defense: '#ffffff',
  stealth: '#d60ef9',
  speed: '#0090ff',
  shock: '#eff110',
  heat: '#ff9600',
  cold: '#96ebf0',
  flame: '#f83e3c',
  throw: '#fcc413',
  heart: '#f1362f',
  'extra-heart': '#ffe465',
  stamina: '#13ff59',
};

const SHAPES: Record<StatIconName | EffectIconName | 'apple' | 'crystal', ReactNode> = {
  sword: <g transform="translate(12 12) rotate(45) scale(1.36) translate(-12 -12)">{blade}</g>,
  shield: shieldShape,
  // A tunic: short sleeves, a round collar.
  tunic: (
    <path d="M8.2 2.6 2.4 5.6.9 11.6l3.9 1 .9-2.6v11.4h12.6V10l.9 2.6 3.9-1-1.5-6-5.8-3c-.6 1.9-2 3-3.8 3s-3.2-1.1-3.8-3Zm2.3 9.4h3v3h-3v-3Z" />
  ),
  // Two swords crossed, with a short burst where they meet.
  critical: (
    <>
      <g transform="translate(12 12) rotate(-45) scale(1.24) translate(-12 -12)">{blade}</g>
      <g
        transform="translate(12 12) rotate(45) scale(1.24) translate(-12 -12)"
        stroke={INK}
        strokeWidth="1.4"
        paintOrder="stroke"
      >
        {blade}
      </g>
    </>
  ),
  // A cut gem.
  durability: (
    <>
      <path d="M6 3h12l4.8 6L12 22 1.2 9 6 3Z" />
      <path
        d="M1.6 9h20.8M6 3l2.6 6L12 3l3.4 6L18 3M8.6 9 12 21.4 15.4 9"
        fill="none"
        stroke={INK}
        strokeOpacity="0.45"
        strokeWidth="1.1"
        strokeLinejoin="round"
      />
    </>
  ),
  'durability-plus': (
    <>
      <path d="M5 3h12l4.4 5.6L11 21 .6 8.6 5 3Z" />
      <path
        d="M1 8.6h20M5 3l2.4 5.6L11 3l3.4 5.6L17 3M7.4 8.6 11 20"
        fill="none"
        stroke={INK}
        strokeOpacity="0.45"
        strokeWidth="1.1"
        strokeLinejoin="round"
      />
      {arrow()}
    </>
  ),
  // A slope with a ledge: two peaks.
  climb: (
    <>
      <path d="M1 21 9.4 5l4.4 7.6L16 9l7 12H1Z" />
      <path d="M9.4 11.2 6 17.6h7l-3.6-6.4Z" fill={INK} fillOpacity="0.28" />
    </>
  ),
  attack: (
    <>
      <g transform="translate(10 11) rotate(45) scale(0.98) translate(-12 -12)">{blade}</g>
      {arrow()}
    </>
  ),
  defense: (
    <>
      <g transform="translate(10.4 11) scale(0.84) translate(-12 -12)">{shieldShape}</g>
      {arrow()}
    </>
  ),
  // Two zigzag lines: moving unheard.
  stealth: (
    <path
      d="M2 9.4 5.4 6l3.4 3.4L12.2 6l3.4 3.4L19 6l3 3M2 18l3.4-3.4L8.8 18l3.4-3.4 3.4 3.4 3.4-3.4 3 3"
      fill="none"
      stroke="currentColor"
      strokeWidth="2.6"
      strokeLinejoin="round"
    />
  ),
  // A jagged arrow up and to the right.
  speed: (
    <path d="M1.5 21.5 8 12.4l2 3.4 5.4-7.4H12.6L22.5 2l-1.8 10.6-2.2-2-6.6 8.6-2-3.4-8.4 5.7Z" />
  ),
  shock: <path d="M14.4 1.4 4.4 13.4h6.4L8.6 22.6l11-12.8h-6.6l1.4-8.4Z" />,
  // A sun (core and rays) and a down arrow: resists heat.
  heat: (
    <>
      <circle cx="10" cy="10" r="4.4" />
      <g>
        {[0, 45, 90, 135, 180, 225, 270, 315].map((angle) => (
          <circle
            key={angle}
            cx="10"
            cy="1.7"
            r="1.55"
            transform={angle ? `rotate(${angle} 10 10)` : undefined}
          />
        ))}
      </g>
      {arrow(true)}
    </>
  ),
  // A snowflake and a down arrow: resists cold.
  cold: (
    <>
      <g fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round">
        <path d="M10 1.6v17M2.6 5.8l14.8 8.5M2.6 14.3l14.8-8.5" />
        <path d="M7.6 2.8 10 5l2.4-2.2M7.6 17.3 10 15l2.4 2.3" />
      </g>
      {arrow(true)}
    </>
  ),
  // A flame with an eye of dark in its bowl.
  flame: (
    <path
      fillRule="evenodd"
      d="M12.6 1.4c.8 3.6-1 5.6-.8 8.2.2 2 1.4 3 2.8 3 1.6 0 2.6-1.6 2.6-4 1.6 1.8 2.6 4 2.6 6.6 0 4.4-3.4 7.6-7.8 7.6s-7.8-3.2-7.8-7.6c0-6.4 8.6-8.6 8.4-13.8Zm-.6 13.4c-1.8 0-3 1.4-3 3.2s1.3 3 3 3 3-1.2 3-3c0-2.4-1.6-3.6-3-3.2Z"
    />
  ),
  // A curved arrow: a long throw.
  throw: (
    <path d="M2.6 20.8C2.4 12.4 7.6 6.6 15 6.4V2.4l7.2 6.8-7.2 6.8v-4.2c-5 .2-8.6 3.6-8.8 9H2.6Z" />
  ),
  heart: <path d="M12 21.6 3.4 13A5.2 5.2 0 0 1 12 5.6 5.2 5.2 0 0 1 20.6 13L12 21.6Z" />,
  'extra-heart': <path d="M12 21.6 3.4 13A5.2 5.2 0 0 1 12 5.6 5.2 5.2 0 0 1 20.6 13L12 21.6Z" />,
  // A ring with a wedge left out at the top right: a stamina wheel, nearly full.
  stamina: (
    <>
      <path d="M12 4.2a7.8 7.8 0 1 0 7.8 7.8" fill="none" stroke="currentColor" strokeWidth="4.4" />
      <circle cx="12" cy="12" r="2.2" />
    </>
  ),
  // Slot art: an apple with a leaf, a faceted crystal.
  apple: (
    <>
      <path d="M12 7.4c2.4-1.6 7.8-1.4 8.4 4.2.6 5.4-3.6 10.4-6.2 10.4-1 0-1.4-.4-2.2-.4s-1.2.4-2.2.4c-2.6 0-6.8-5-6.2-10.4.6-5.6 6-5.8 8.4-4.2Z" />
      <path d="M12 7.2c0-2.2.6-4 2-5.2" fill="none" stroke="#7a4b23" strokeWidth="1.6" />
      <path d="M13.6 4.6c1.4-2 3.8-2.6 5.6-2-1 2.2-3.2 3-5.6 2Z" fill="#7cc23a" />
      <path
        d="M7 10.4c.4-1.2 1.4-2 2.6-2.2"
        fill="none"
        stroke="#fff"
        strokeOpacity="0.55"
        strokeWidth="1.4"
        strokeLinecap="round"
      />
    </>
  ),
  crystal: (
    <>
      <path d="M12 1.4 18.6 8 12 22.6 5.4 8 12 1.4Z" />
      <path
        d="M5.4 8h13.2M12 1.4V22.6M9 8l3 14.6L15 8"
        fill="none"
        stroke={INK}
        strokeOpacity="0.4"
        strokeWidth="1"
      />
    </>
  ),
};

export type IconName = keyof typeof SHAPES;

export function Icon({
  name,
  className,
  color,
}: {
  name: IconName;
  className?: string;
  color?: string;
}) {
  return (
    <svg
      className={className}
      viewBox="0 0 24 24"
      fill="currentColor"
      aria-hidden="true"
      style={color ? { color } : undefined}
    >
      {SHAPES[name]}
    </svg>
  );
}

/** A solid hourglass: two caps and the glass, sand gathered low. */
export function Hourglass({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
      <path d="M5 1.6h14v3h-1.4c0 3.6-2.8 5.6-3.8 7.4 1 1.8 3.8 3.8 3.8 7.4H19v3H5v-3h1.4c0-3.6 2.8-5.6 3.8-7.4-1-1.8-3.8-3.8-3.8-7.4H5v-3Z" />
      <path d="M8.6 18.4h6.8M9.6 16.4h4.8" stroke="#2b2a27" strokeOpacity="0.5" strokeWidth="1" />
    </svg>
  );
}

/** A five-pointed star, for the item's rarity. */
export function Star({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 24 24" aria-hidden="true">
      <path
        d="M12 1.8 15.3 8.5l7.4 1-5.4 5.2 1.3 7.4-6.6-3.5-6.6 3.5 1.3-7.4-5.4-5.2 7.4-1L12 1.8Z"
        strokeLinejoin="round"
      />
    </svg>
  );
}

/** The small triangle between a compared value and the new one. */
export function CompareArrow({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 6 8" fill="currentColor" aria-hidden="true">
      <path d="M0.5 0.5 5.5 4 0.5 7.5Z" />
    </svg>
  );
}
```

### item-card/use-autoplay.ts

```tsx
// 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.
 */
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);
    const observer = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, playing: visible && !reduced && !taken };
}
```

### item-card/item-card.module.css

```css
/* Item card (item-card.tsx): an inventory's detail card, on its own dark plate, centred on a plain
   stage with a row of inventory slots under it. Every size of the card is in em of a 16 px card
   (the kit's sizes, px / 16), so a narrow stage draws it a little smaller (a container query) and
   --item-card-size sets it by hand.

   The plate, the text and the chips keep their colours on any page: a game draws this card over
   its own dark screen, and the kit's plate is already dark. Only the plate's shadow, its outer
   edge, the stars' outline (they sit on the plate's top edge, half outside) and the slots' focus
   ring follow the colour scheme, through light-dark() tokens. No filter touches a shape.

   Motion: the plate rises once; each item's content is keyed, so a new item replays its entry
   keyframes, staggered by row (--i on the stars and chips). */

.root {
  /* The plate: fill (darker where the text is), outer edge, inner hairline, shadow. */
  --plate: #33322f;
  --plate-far: #57544f;
  --edge: #43423e;
  --hairline: #71706d;
  --shade: rgb(30 28 22 / 0.22);
  /* The text, the rule and its shadow. */
  --text: #e9e1d1;
  --rule: #7f7f7c;
  --rule-shade: #1d1c1a;
  /* The chips. */
  --chip-top: #1d1d1b;
  --chip-bottom: #2e2d2b;
  --chip-edge: #2a2927;
  --chip-rim: #373634;
  --green: #6fd49c;
  --white: #ffffff;
  /* The stars and their outline. */
  --star: #e2ded3;
  --star-edge: #3a3935;
  /* The slots. */
  --slot: #2c2b28;
  --slot-edge: #4a4944;
  --slot-on: #e9e1d1;
  --slot-ring: #2b2a27;
  --focus: #2f6fdf;
  position: relative;
  container-type: inline-size;
  isolation: isolate;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 30px;
  width: 100%;
  min-height: 360px;
  padding: 40px 16px 32px;
  box-sizing: border-box;
  color: var(--text);
  font-family:
    system-ui,
    -apple-system,
    'Segoe UI',
    Roboto,
    sans-serif;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --edge: light-dark(#43423e, #4d4c47);
    --shade: light-dark(rgb(30 28 22 / 0.22), rgb(0 0 0 / 0.55));
    --star-edge: light-dark(#3a3935, #1a1a18);
    --slot-edge: light-dark(#4a4944, #55534e);
    --slot-ring: light-dark(#2b2a27, transparent);
    --focus: light-dark(#2f6fdf, #8db4ff);
  }
}
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}
.root[data-bare] {
  display: block;
  min-height: 0;
  padding: 0;
  container-type: normal;
}

/* The plate. Stars hang over its top edge, so it keeps room above for them. */
.card {
  position: relative;
  box-sizing: border-box;
  width: min(100%, 31.125em);
  min-height: 8.25em;
  padding: 0.375em 1em 0.5em 1.3125em;
  border: 1px solid var(--edge);
  background: linear-gradient(90deg, var(--plate) 0 50%, var(--plate-far));
  box-shadow:
    inset 0 0 0 1px var(--hairline),
    0 0.5em 1.5em var(--shade),
    0 0.0625em 0.25em var(--shade);
  font-size: var(--item-card-size, 16px);
  line-height: 1;
}
.root:not([data-bare]) .card {
  margin-top: 0.5em;
}
.card[data-first] {
  animation: plate-in 260ms cubic-bezier(0.2, 0.8, 0.3, 1) both;
}
@keyframes plate-in {
  from {
    opacity: 0;
    translate: 0 0.4em;
  }
}

.content {
  max-width: 19.25em;
}

/* Stars: on the plate's top edge, 21 px in, 16 px apart. */
.stars {
  position: absolute;
  top: -0.5625em;
  left: 1.125em;
  display: flex;
  gap: 0.0625em;
}
.star {
  display: block;
  width: 1em;
  height: 1em;
  color: var(--star);
  animation: star-pop 340ms cubic-bezier(0.3, 1.6, 0.5, 1) calc(90ms + var(--i) * 55ms) both;
}
.star svg {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
  fill: currentColor;
  stroke: var(--star-edge);
  stroke-width: 1.6px;
  stroke-linejoin: round;
  paint-order: stroke;
}
@keyframes star-pop {
  from {
    opacity: 0;
    scale: 0.4;
  }
}
/* Three stars: one glint runs across them after they land. */
.stars[data-shine] .star {
  animation:
    star-pop 340ms cubic-bezier(0.3, 1.6, 0.5, 1) calc(90ms + var(--i) * 55ms) both,
    glint 420ms ease-out calc(520ms + var(--i) * 70ms);
}
@keyframes glint {
  40% {
    color: #ffffff;
    scale: 1.14;
  }
}

/* The name: bold italic, 22 px. */
.name {
  margin: 0;
  font-size: 1.4625em;
  font-style: italic;
  font-weight: 650;
  line-height: 1.2;
  letter-spacing: -0.005em;
  color: var(--text);
  animation: slide-in 240ms cubic-bezier(0.2, 0.8, 0.3, 1) both;
}
@keyframes slide-in {
  from {
    opacity: 0;
    translate: -0.35em 0;
  }
}

/* The rule under the name, with its dark line beneath, drawn from the left. */
.rule {
  height: 1px;
  margin-top: 0.125em;
  background: var(--rule);
  box-shadow:
    0 2px 0 #2a2926,
    0 3px 0 var(--rule-shade);
  transform-origin: left;
  animation: draw 320ms cubic-bezier(0.3, 0.7, 0.2, 1) 50ms both;
}
@keyframes draw {
  from {
    scale: 0 1;
  }
}

/* The chip row. */
.chips {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.25em 0.375em;
  margin: 0.25em 0 0;
  padding: 0;
  list-style: none;
}
.chips > li {
  animation: rise 260ms cubic-bezier(0.2, 0.8, 0.3, 1) calc(110ms + var(--i) * 45ms) both;
}
@keyframes rise {
  from {
    opacity: 0;
    translate: 0 0.2em;
  }
}

/* A stat: the white icon, then the value box(es). */
.stat {
  display: flex;
  align-items: center;
  gap: 0.375em;
}
.statIcon {
  flex: none;
  width: 1em;
  height: 1em;
  margin-inline: 0.125em 0;
  translate: 0 -0.125em;
  color: var(--white);
}
.box,
.chip {
  box-sizing: border-box;
  display: inline-flex;
  align-items: center;
  height: 1.25em;
  border: 1px solid var(--chip-edge);
  background: linear-gradient(var(--chip-top), var(--chip-bottom));
  /* The kit's chips are lit from below: a lighter rim on the sides and foot, none on top. */
  box-shadow:
    inset 2px 0 0 var(--chip-rim),
    inset -2px 0 0 var(--chip-rim),
    inset 0 -2px 0 var(--chip-rim);
}
/* A value box: 13 px digits in a 32 × 20 box. */
.box {
  justify-content: center;
  height: 1.539em;
  min-width: 2.462em;
  padding: 0 0.385em;
  color: var(--white);
  font-size: 0.8125em;
  font-variant-numeric: tabular-nums;
}
.box[data-up] {
  animation: up-flash 640ms ease-out 380ms;
}
@keyframes up-flash {
  30% {
    color: #ffffff;
    text-shadow: 0 0 0.5em rgb(150 255 200 / 0.9);
    box-shadow:
      inset 0 0 0 1px var(--green),
      0 0 0.5em rgb(111 212 156 / 0.45);
  }
}
.compareArrow {
  flex: none;
  width: 0.25em;
  height: 0.3125em;
  margin-inline: -0.125em;
  color: var(--white);
}
.stat:has([data-up]) .compareArrow {
  animation: nudge 420ms cubic-bezier(0.3, 1.4, 0.5, 1) 360ms;
}
@keyframes nudge {
  45% {
    translate: 0.15em 0;
  }
}

/* An effect, hearts or timer chip. */
.chip {
  gap: 0.375em;
  height: 1.4286em;
  padding: 0 0.375em;
  font-size: 0.875em;
  font-weight: 500;
  white-space: nowrap;
}
.chip[data-tone='green'] {
  color: var(--green);
}
.chip[data-tone='white'] {
  color: var(--white);
}
.icons {
  display: inline-flex;
  gap: 0.0714em;
}
.chip[data-part='hearts'] {
  gap: 0.1429em;
}
.icon {
  display: block;
  flex: none;
  width: 1em;
  height: 1em;
}
.chips > li .icons,
.heart {
  animation: icon-pop 320ms cubic-bezier(0.3, 1.6, 0.5, 1) calc(170ms + var(--i) * 45ms) both;
}
@keyframes icon-pop {
  from {
    opacity: 0;
    scale: 0.5;
  }
}
.heart {
  position: relative;
  display: block;
}
.heartCount {
  position: absolute;
  inset: 0.18em 0 0;
  display: grid;
  place-items: center;
  color: #ffffff;
  font-size: 0.5em;
  font-weight: 700;
  font-style: normal;
}
.timer {
  display: inline-flex;
  align-items: center;
  gap: 0.0714em;
  margin-inline-start: 0.2em;
  color: var(--green);
  font-variant-numeric: tabular-nums;
}
.hourglass {
  width: 0.93em;
  height: 0.93em;
}
.label {
  line-height: 1;
}

/* The description: italic 13 px on a 14.7 px pitch. */
.description {
  margin: 0.0625em 0 0;
  font-size: 0.8125em;
  font-style: italic;
  line-height: 1.128;
  color: var(--text);
  animation: fade 260ms ease-out 170ms both;
}
@keyframes fade {
  from {
    opacity: 0;
  }
}

/* The inventory slots: dark squares; the cursor is a cream frame. */
.slots {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 8px;
  max-width: 498px;
}
.slot {
  position: relative;
  display: grid;
  place-items: center;
  width: 52px;
  height: 52px;
  margin: 0;
  padding: 0;
  border: 1px solid var(--slot-edge);
  background: linear-gradient(#262523, #34332f);
  box-shadow: inset 0 0 0 1px #1d1c1a;
  cursor: pointer;
  touch-action: manipulation;
  transition:
    border-color 150ms,
    background-color 150ms;
}
.slot::after {
  content: '';
  position: absolute;
  inset: -3px;
  border: 2px solid var(--slot-on);
  box-shadow:
    0 0 0 1px var(--slot-ring),
    0 0 0.6em rgb(255 244 214 / 0.35),
    inset 0 0 0.4em rgb(255 244 214 / 0.25);
  opacity: 0;
  scale: 1.12;
  pointer-events: none;
  transition:
    opacity 120ms,
    scale 200ms cubic-bezier(0.3, 1.6, 0.5, 1);
}
.slot[data-on]::after {
  opacity: 1;
  scale: 1;
}
@media (hover: hover) {
  .slot:hover {
    border-color: #77756e;
  }
}
.slot:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 6px;
}
.slotIcon {
  width: 30px;
  height: 30px;
  color: #ffffff;
}
.slotValue {
  position: absolute;
  right: 3px;
  bottom: 2px;
  color: #ffffff;
  font-size: 10px;
  font-weight: 500;
  line-height: 1;
  font-variant-numeric: tabular-nums;
}

.live {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* A narrow stage draws the card at 14 px and the slots smaller. */
@container (max-width: 430px) {
  .card {
    font-size: var(--item-card-size, 14px);
  }
  .slots {
    gap: 6px;
  }
  /* Six slots stay on one row on a phone: each takes its share of the row, 30-42 px. */
  .slot {
    width: clamp(30px, (100cqi - (var(--count, 6) - 1) * 6px) / var(--count, 6), 42px);
    height: auto;
    aspect-ratio: 1;
  }
  .slotIcon {
    width: 22px;
    height: 22px;
    translate: 0 -2px;
  }
}

/* Reduced motion: the content crossfades; no slides, draws, pops, glints or flashes. */
@media (prefers-reduced-motion: reduce) {
  .card[data-first],
  .name,
  .rule,
  .chips > li,
  .description,
  .star,
  .stars[data-shine] .star,
  .chips > li .icons,
  .heart,
  .box[data-up],
  .stat:has([data-up]) .compareArrow {
    animation: fade 120ms ease-out both;
  }
  .slot::after {
    transition: opacity 120ms;
  }
}
```