Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Sheet menu

New

A spreadsheet’s desktop menu bar: menus that switch as the pointer slides across the titles, submenus that open on a rest, and commands the toolbar mirrors.

sheet-menu/sheet-menu.tsx
Live · interactive
A1Task
ABCDEF
1
Task
Owner
Status
Hours
Rate
Cost
2
Research
Mara
Done
12
85
1020
3
Wireframes
Theo
Done
18
85
1530
4
Visual design
Ines
In review
26
95
2470
5
Prototype
Theo
Doing
14
95
1330
6
Testing
Mara
To do
8
85
680
7
8
9
10
11
12
13
14
15
16

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 desktop-style app window with more commands than a toolbar can hold: File, Edit, Format, Data.
  • Commands that act on a selection (a cell, a row, a shape), where the toolbar mirrors what is selected.
  • Readers who work from the keyboard: arrows across the titles, into submenus, a letter to jump, ⌘B and ⌘Z on the grid.
  • Settings that are on or off, or one of several: check items and radio items say which at a glance.
  • Deep but stable trees of commands, two levels at most, where a forgiving submenu delay saves misses.

When not to use

  • A site’s navigation. Menubar roles promise app behaviour; use links in a nav.
  • Two or three actions. Show them as buttons; a menu only hides them.
  • Touch-first or phone layouts. Hover-to-switch and submenus are a desktop habit; use a sheet or a list of actions.
  • Menus deeper than two levels. Flatten them, or move the rarer ones into a dialog.
  • The only way to reach a command. Keep its shortcut or a toolbar button too.

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

Into components/sheet-menu of any React project, with no setup:

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

planner.tsx
import { SheetMenu } from './sheet-menu';import { blankSheet } from './sheet';export function Planner() {  // An empty sheet; leave initialSheet out for the sample budget.  return <SheetMenu initialSheet={blankSheet()} 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.

A spreadsheet window with a real desktop menu bar. You can click a title and slide across the others, rest on Text and pick Italic, or drive it all from the keyboard. The commands format the selected cell or the sheet (text styles, colours, borders, font size, number formats, alignment, gridlines, frozen rows), the toolbar follows, and Undo takes any of it back.

  • One state holds which menu is open, which item is lit, which submenu is open and what is lit in it. A click opens a menu, and while one is open, hovering another title swaps it, as on a desktop.
  • Submenus wait for the pointer: one opens after its item has been hovered for 180 ms, and an open one closes 260 ms after the pointer moves to another item, which forgives a diagonal path to it.
  • Every command is a pure function from one sheet to the next, so Undo and Redo are two stacks of sheets. The toolbar’s pressed buttons, its font size, its number format and the menus’ checks are all read from the selected cell.
  • Every menu and submenu is in the page from the start, hidden and inert, and opening one shows it. An open menu measures itself once and shifts to stay inside the window; its submenus open on whichever side has room, or over it on a phone.
  • The selected cell’s frame and the frozen rows’ line move by their transform, and the stage is a single layout boundary, so a command lays out the window alone.
  • Every colour is a light-dark() token on the root, the cell formats, the toolbar’s colour bar and the app’s mark included, so the window follows the page’s color-scheme, or the colorScheme prop.
PropTypeDefaultDescription
initialSheetSheetcreateSheet()The sheet the window opens with: 16 rows by 6 columns of values and formats, plus gridlines, frozen rows and a colour scale. A small project budget when left out.
defaultStateSheetMenuState—Opens with a menu shown and a cell selected: menu (a menu bar menu’s id, or 'toolbar-size' | 'toolbar-number'), active and sub (its lit item and the one whose submenu is open), subActive (the submenu’s lit item) and at ({ row, col }, from 0). Turns the demo off; the menu stays open through presses outside until the window is used.
onCommand(command: string, sheet: Sheet) => void—Called when a command runs, from a menu, the toolbar or a shortcut, with its id and the sheet after it.
onSelect(cell: string) => void—Called when a cell is selected, with its name, as “A4”.
autoplaybooleantrueWhile on screen and untouched, a demo pointer selects a cell, opens Format, slides across the bar, runs Text › Italic, then hides the gridlines. The next round puts both back.
colorScheme'light' | 'dark' | 'system'—Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
classNamestring—Extra class for the root, a stage with the window.

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

sheet-menu.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  Fragment,  useEffect,  useId,  useLayoutEffect,  useRef,  useState,  type CSSProperties,  type KeyboardEvent,  type PointerEvent,} from 'react';import { DemoPointer, runDemo } from './demo-pointer';import { AppMark, Glyph, Smiley } from './icons';import { MENUS, TOOLBAR_MENUS, itemsOf, type Menu, type MenuEntry } from './menus';import {  COLUMNS,  NUMBER_FORMATS,  ROWS,  apply,  createSheet,
sheet-menu.module.css
/* Sheet menu (sheet-menu.tsx): a light grey window on a white stage, its menu bar and toolbar on   top and the grid in a white panel; at night a dark grey window on a near-black stage, the grid   in a near-black panel and the menus a step lighter. Sizes are in em of the menus' text: 13px   where the whole window fits, down to 10.5px on a phone, where the window shows the columns it   has room for.   The stage is one layout boundary of a set height, so what the demo changes (a menu shown, a   cell's format, the toolbar's state) lays out the stage alone; the pointer moves on a small   layer of its own. */.root {  /* Its colours, light and dark: each light-dark() pair resolves by the color-scheme the root     inherits from the page, or the one the colorScheme prop sets (below). */  /* The stage and the grid's panel; the window's chrome; the menus; the grid's headers. */  --surface: #fff;  --surface-2: #f4f4f4;  --menu: #fff;  --head: #fcfcfc;  /* Text; toolbar icons and checks; submenu chevrons; shortcuts and headers; fx; what is off. */  --ink: #1f1f1f;  --ink-soft: #3d3d3d;  --ink-chevron: #4a4a4a;  --ink-muted: #8a8a8a;  --ink-faint: #9a9a9a;
menus.ts
import { NUMBER_FORMATS, SIZES } from './sheet';/* * The menus: the menu bar's six, and the two the toolbar opens (font size and number format). * An entry runs the command named by its id (sheet.ts), or opens the submenu of its `items`. * Which entries are checked, unavailable or relabelled depends on the sheet, so the component * works it out as it draws them. */export type MenuEntry =  | { kind: 'separator'; id: string }  | {      kind: 'item';      /** The command it runs, unique within its menu. */      id: string;      label: string;      /** The shortcut as it is drawn: "⌘Z". */      shortcut?: string;      /** The same, as aria-keyshortcuts spells it. */      keys?: string;      /** Shows a check: one of several (radio) or on and off (checkbox). */      check?: 'radio' | 'checkbox';      /** Never available in this sheet. */      disabled?: boolean;
sheet.ts
/* * The sheet: sixteen rows of six columns, each cell a value and its format, plus the sheet's own * settings (gridlines, frozen rows, a colour scale on a column). Every command is a pure function * from one sheet to the next, so undo and redo are a matter of keeping the old ones. */export const ROWS = 16;export const COLUMNS = ['A', 'B', 'C', 'D', 'E', 'F'];export type NumberFormat = 'general' | 'number' | 'currency' | 'percent';export type Align = 'left' | 'center' | 'right';export type VAlign = 'top' | 'middle' | 'bottom';export type Wrap = 'overflow' | 'wrap' | 'clip';export type CellFormat = {  bold?: boolean;  italic?: boolean;  underline?: boolean;  strike?: boolean;  /** Text in the accent colour. */  color?: boolean;  /** A pale fill behind the cell. */  fill?: boolean;  /** A frame around the cell. */
icons.tsx
import type { ReactNode } from 'react';/* * The window's drawings: line icons on a 16 px grid in the text's colour (the text colour's bar is * drawn in the colour it sets), the app's mark and the status chip's face. They are decorative; * every button carries its own name. Their other colours are tokens of the window's stylesheet. */function Icon({ className, children }: { className?: string; children: ReactNode }) {  return (    <svg      className={className}      viewBox="0 0 16 16"      fill="none"      stroke="currentColor"      strokeWidth="1.25"      strokeLinecap="round"      strokeLinejoin="round"      aria-hidden="true"    >      {children}    </svg>  );}
demo-pointer.tsx
'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, * glide the pointer onto a target, press, calling the window's own handlers as a hand would. * 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. * * The pointer moves by animations the compositor plays, so the glides cost no layout, and it is * an HTML box around a still drawing: a change of style inside an SVG lays the SVG out again. */export type Point = { x: number; y: number };export type Demo = {  /** Resolves after `ms`. */  wait: (ms: number) => Promise<void>;  /** Glides the pointer onto `target`: its middle, or a point given as shares of its size. */  moveTo: (target: Element | null | undefined, at?: Point) => Promise<void>;  /** Presses and lets go; `action` runs as the pointer goes down. */  press: (action: () => void) => Promise<void>;  /** Fades the pointer out where it is. */
use-autoplay.ts
'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;
  • The menu bar is an ARIA menubar with a single tab stop. Its titles carry aria-haspopup and aria-expanded, and its items are menuitem, menuitemcheckbox or menuitemradio with aria-checked; unavailable items are aria-disabled, and shortcuts are exposed as aria-keyshortcuts.
  • The keys work as on a desktop: ← and → move across the titles, and between menus once one is open; ↓, Enter or Space open a menu, and ↑, ↓, Home and End move inside it; → opens a submenu and ← closes it. Enter runs a command, Escape steps back, a letter jumps to the item it starts, and the focus returns to the title or the cell.
  • The toolbar is an ARIA toolbar with arrow keys and pressed states, Text color, Fill color and Borders included. The grid’s selected cell takes the focus, moves with the arrow keys and answers ⌘B, ⌘I, ⌘U, ⌘Z, ⇧⌘Z, ⌘C, ⌘X, ⌘V and Delete, and each command the reader runs is announced (“Italic on in A4”, “Borders on in A4”, “Gridlines hidden”).
  • Under reduced motion the menus appear at once and the demo does not play.