Mini Sheet
A tiny spreadsheet that really calculates: cell references, arithmetic, and SUM, AVG, MIN, MAX over ranges.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/mini-sheet.json'use client'
import { useRef, useState } from 'react'
import './mini-sheet.css'
export interface MiniSheetProps {
cells: Record<string, string>
cols?: number
rows?: number
fill?: 'dark' | 'light'
className?: string
}
type V = number | string
const COLS = 'ABCDEFGH'
function evaluate(raw: Record<string, string>, addr: string, seen: Set<string>): V {
const s = raw[addr] ?? ''
if (!s.startsWith('=')) return s === '' ? '' : isNaN(Number(s)) ? s : Number(s)
if (seen.has(addr)) return '#CYCLE'
const next = new Set(seen).add(addr)
const num = (a: string): number => { const v = evaluate(raw, a, next); if (typeof v === 'string' && v.startsWith('#')) throw new Error(v); return typeof v === 'number' ? v : 0 }
const range = (a: string, b: string) => { const c0 = COLS.indexOf(a[0]), c1 = COLS.indexOf(b[0]), r0 = Number(a.slice(1)), r1 = Number(b.slice(1)); const out: number[] = []; for (let c = Math.min(c0, c1); c <= Math.max(c0, c1); c++) for (let r = Math.min(r0, r1); r <= Math.max(r0, r1); r++) out.push(num(COLS[c] + r)); return out }
try {
let src = s.slice(1).toUpperCase().replace(/\s+/g, '')
src = src.replace(/(SUM|AVG|MIN|MAX)\(([A-H]\d+):([A-H]\d+)\)/g, (_, f: string, a: string, b: string) => { const v = range(a, b); const r = f === 'SUM' ? v.reduce((x, y) => x + y, 0) : f === 'AVG' ? v.reduce((x, y) => x + y, 0) / (v.length || 1) : f === 'MIN' ? Math.min(...v) : Math.max(...v); return `(${r})` })
src = src.replace(/[A-H]\d+/g, (a) => `(${num(a)})`)
let i = 0
const peek = () => src[i]
const expr = (): number => { let v = term(); while (peek() === '+' || peek() === '-') { const op = src[i++]; const t = term(); v = op === '+' ? v + t : v - t } return v }
const term = (): number => { let v = factor(); while (peek() === '*' || peek() === '/') { const op = src[i++]; const f = factor(); v = op === '*' ? v * f : v / f } return v }
const factor = (): number => { if (peek() === '-') { i++; return -factor() } if (peek() === '(') { i++; const v = expr(); if (src[i++] !== ')') throw new Error('#ERR'); return v } const m = /^\d+(\.\d+)?/.exec(src.slice(i)); if (!m) throw new Error('#ERR'); i += m[0].length; return Number(m[0]) }
const v = expr()
if (i !== src.length || !isFinite(v)) return '#ERR'
return Math.round(v * 1e6) / 1e6
} catch (e) { const m = (e as Error).message; return m.startsWith('#') ? m : '#ERR' }
}
/** Small formula-capable spreadsheet. */
export function MiniSheet({ cells, cols = 4, rows = 6, fill = 'dark', className }: MiniSheetProps) {
const [raw, setRaw] = useState(cells)
const [sel, setSel] = useState('A1')
const bar = useRef<HTMLInputElement>(null)
const val = (a: string) => evaluate(raw, a, new Set())
const refs = new Set((raw[sel] ?? '').startsWith('=') ? ((raw[sel].toUpperCase().match(/[A-H]\d+(:[A-H]\d+)?/g) ?? []).flatMap((r) => { if (!r.includes(':')) return [r]; const [a, b] = r.split(':'); const o: string[] = []; for (let c = COLS.indexOf(a[0]); c <= COLS.indexOf(b[0]); c++) for (let k = Number(a.slice(1)); k <= Number(b.slice(1)); k++) o.push(COLS[c] + k); return o })) : [])
const move = (dc: number, dr: number) => { const c = Math.min(cols - 1, Math.max(0, COLS.indexOf(sel[0]) + dc)), r = Math.min(rows, Math.max(1, Number(sel.slice(1)) + dr)); setSel(COLS[c] + r) }
const show = (v: V) => (typeof v === 'number' ? (Number.isInteger(v) ? v.toLocaleString('en-US') : v.toLocaleString('en-US', { maximumFractionDigits: 2 })) : v)
return (
<div className={['sd-msh', className].filter(Boolean).join(' ')} data-fill={fill}>
<label className="sd-msh__fx"><b>{sel}</b><i>fx</i><input ref={bar} aria-label={`Contents of ${sel}`} spellCheck={false} value={raw[sel] ?? ''} onChange={(e) => setRaw({ ...raw, [sel]: e.target.value })} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); move(0, 1) } }} /></label>
<table tabIndex={-1} onKeyDown={(e) => { const m: Record<string, [number, number]> = { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0] }; if (m[e.key]) { e.preventDefault(); move(...m[e.key]) } else if (e.key === 'Enter' || e.key === 'F2') { e.preventDefault(); bar.current?.focus() } }}>
<thead><tr><th />{Array.from({ length: cols }, (_, c) => <th key={c} scope="col">{COLS[c]}</th>)}</tr></thead>
<tbody>{Array.from({ length: rows }, (_, r) => <tr key={r}><th scope="row">{r + 1}</th>{Array.from({ length: cols }, (_, c) => { const a = COLS[c] + (r + 1); const v = val(a); const isErr = typeof v === 'string' && v.startsWith('#'); return (
<td key={a} data-sel={sel === a || undefined} data-ref={refs.has(a) || undefined}><button type="button" data-t={(typeof v === 'string' && !isErr && v !== '') || undefined} data-f={(raw[a] ?? '').startsWith('=') || undefined} data-e={isErr || undefined} aria-label={`${a}: ${v === '' ? 'empty' : v}`} onClick={() => setSel(a)} onDoubleClick={() => bar.current?.focus()}>{show(v)}</button></td>
) })}</tr>)}</tbody>
</table>
<p className="sd-msh__h">Try <code>=SUM(B1:B4)</code>, <code>=B5/4</code> or <code>=MAX(B1:B4)-MIN(B1:B4)</code></p>
</div>
)
}.sd-msh { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-msh[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-msh :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-msh :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-msh .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-msh { width: min(100%, 350px); padding: 12px; grid-template-columns: minmax(0, 1fr); border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); display: grid; gap: 8px; }
.sd-msh__fx { display: flex; align-items: center; gap: 6px; height: 38px; padding: 0 4px 0 8px; border-radius: 10px; background: var(--soft); }
.sd-msh__fx:focus-within { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-msh__fx b { font: 700 12px ui-monospace, monospace; min-width: 26px; color: var(--mute); }
.sd-msh__fx i { font: italic 600 13px ui-serif, Georgia, serif; color: var(--mute); }
.sd-msh__fx input { flex: 1; min-width: 0; border: 0; background: none; color: var(--fg); font: 14px ui-monospace, monospace; outline: 0; }
.sd-msh table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 12.5px; outline: 0; }
.sd-msh th { height: 22px; font: 600 10.5px ui-sans-serif, system-ui; color: var(--mute); }
.sd-msh thead th:first-child { width: 22px; }
.sd-msh td { padding: 0; border: 1px solid var(--line); }
.sd-msh td button { width: 100%; height: 30px; padding: 0 6px; text-align: right; font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
.sd-msh td button[data-t] { text-align: left; color: var(--mute) !important; }
.sd-msh td button[data-f] { font-weight: 700; }
.sd-msh td button[data-e] { text-align: center; font: 600 11px ui-monospace, monospace; }
.sd-msh td[data-sel] { box-shadow: inset 0 0 0 2px var(--fg); }
.sd-msh td[data-ref] { background: var(--soft); }
.sd-msh__h { margin: 0; font-size: 11.5px; color: var(--mute); }
.sd-msh__h code { font: 11px ui-monospace, monospace; color: var(--fg); }
@media (prefers-reduced-motion: reduce) { .sd-msh *, .sd-msh *::before, .sd-msh *::after { animation: none !important; transition: none !important; } }Overview
Select a cell to see its raw content in the formula bar; type a number, text or a formula starting with =. Dependent cells recalculate instantly.
Errors are shown in the cell as #REF, #ERR or #CYCLE rather than breaking the sheet. Arrow keys move the selection.
When to use
- Embedded calculators, teaching formulas, quick budgets.
When not to use
- Real spreadsheets with many rows.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/mini-sheet.json$
npx shadcn@latest add https://sundaeui.dev/r/mini-sheet.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/mini-sheet.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/mini-sheet.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy mini-sheet.tsx and mini-sheet.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { MiniSheet } from '@/components/sundae/mini-sheet'
<MiniSheet />Props
| Prop | Type | Default |
|---|---|---|
cells*Starting cells by address, e.g. { A1: "12", B1: "=A1*2" }. | Record<string, string> | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The grid is a table with column and row headers; cells are buttons named by address and value.
- The formula bar is a labelled input.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-msh; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Mini Sheet free?
- Yes. Mini Sheet is free for personal and commercial projects, with full source on this page.
- How do I install Mini Sheet?
- Run npx shadcn@latest add https://sundaeui.dev/r/mini-sheet.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Mini Sheet support keyboard and screen readers?
- The grid is a table with column and row headers; cells are buttons named by address and value. The formula bar is a labelled input.
- Does Mini Sheet respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Grid / List View Switcher
Components · RefinedEmoji Picker
Components · RefinedSelectable Grid
Components · RefinedToast Stack
Components · Refined- Pro
Sheet Drawer
Components · Refined Avatar Stack
Components · Refined