Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Row editor

A table row that lifts into a form in place: its values glide into the fields, and back into the row when you press Done.

row-editor/row-editor.tsx
Live · interactive
Monthly expenses
ExpenseMethodAmountEdit
Studio rentTransfer$940
Train passDebit card$96
Market runCash$42
Phone planCredit card$35
Climbing gymCredit card$68
Total$1,181

Arrow keys move between expenses; Enter edits one.

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 short table of records, each with a few fields, where people fix one row at a time.
  • Edits that should keep their context: the other rows stay in view, dimmed, while one is open.
  • Two to four fields a row, short text or numbers, saved together with Done.
  • Keyboard-heavy lists: arrow keys move between rows, Enter opens one, Escape backs out.

When not to use

  • Spreadsheet-like editing, cell by cell across many columns. Use a data grid with inline cells.
  • Records with many fields or long text. Open them in a side panel or a page of their own.
  • Tables of hundreds of rows: the lift and the blur are made for a few dozen at most.
  • Changes that must be checked by someone else before they count. Use a form with a review step.
  • A single value to change, such as a price. An inline field in the cell is lighter.

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

Into components/row-editor of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/row-editor.json

Either way it needs React 19.2 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

budget.tsx
'use client';import { RowEditor } from './row-editor';import { EXPENSES } from './expenses';export function Budget() {  return (    <RowEditor      label="Monthly expenses"      expenses={EXPENSES}      autoplay={false}      onChange={(rows) => console.log(rows)}    />  );}

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 small table of expenses with a pencil on each row. Edit one and it lifts into three labelled fields while the other rows dim and blur, its values gliding from their cells into the fields. Done writes them back: the values glide home as the form settles into a row, and the total updates.

  • Each row holds two layers, its line of cells and its form. The one in use sets the row’s height; the other lies over it, hidden.
  • Opening and saving are FLIP: each value is measured on screen, then the element that takes its place (a field, or a cell) plays from that spot and size to its own, with the Web Animations API.
  • The row’s height animates on the same spring. The table stays centred, so it grows as much upwards as downwards, and each value’s own move leaves out the part the table already carries.
  • The labels, the fields’ frames and the buttons fade in behind the values, a few milliseconds apart. A new total arrives with a little blur.
  • The other rows dim and blur without a filter: their text turns transparent with a blurred text-shadow, painted with the text, so no row needs a compositing layer of its own.
  • Every colour is a light-dark() token on the root, so it follows the page’s color-scheme, or the colorScheme prop, with no script involved. At night depth comes from surfaces a step lighter each (stage, table, lifted row) and faint hairlines rather than shadows.
PropTypeDefaultDescription
expensesExpense[]EXPENSESThe rows: an id, what the expense was for (name), how it was paid (method) and its amount in dollars.
labelstring'Monthly expenses'The table’s caption and accessible name.
defaultStateRowEditorState—Opens the table mid-edit: editing (the row open in its form, by id), draft (what its fields hold), typing (the amount 'selected' or 'typing') and focus (the row drawn with the focus ring). Setting it turns the demo off.
onChange(expenses: Expense[]) => void—Called with all the rows after a save.
autoplaybooleantrueWhile on screen and untouched, a demo cursor edits an amount, then puts it 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 of its own.

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

row-editor.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useEffectEvent,  useId,  useLayoutEffect,  useRef,  useState,  type ComponentType,  type CSSProperties,  type KeyboardEvent,} from 'react';import { DemoCursor, playDemo } from './demo-cursor';import { EXPENSES, money, type Expense } from './expenses';import { CardIcon, CheckIcon, CloseIcon, CoinIcon, PencilIcon, ReceiptIcon } from './icons';import { easingOf, spring } from './spring';import { useAutoplay } from './use-autoplay';import styles from './row-editor.module.css';/* * Row editor: a table of expenses whose rows open into a form in place. Press a row's pencil, or
row-editor.module.css
/* Row editor (row-editor.tsx): a light stage with the table on a white card, or at night a   near-black stage with the table on a dark grey one. A row open in its form carries data-editing,   and so does the table while one is.   Colours are the tokens below, each light-dark(light, dark): the component takes the   color-scheme it inherits from the page (light unless the page says otherwise), or the one its   colorScheme prop sets (data-color-scheme). */.root {  /* The stage, the table on it, and the row lifted out of the table: a step lighter each at night,     where shadows hardly show. */  --surface: #e4e4e7;  --surface-2: #fff;  --surface-3: #fff;  /* Names, amounts and the caption; methods and Cancel; headings, labels and the total's name;     icons, $ signs and the pencils. */  --ink: #1d1d1f;  --ink-soft: #48484a;  --ink-muted: #6e6e73;  --ink-faint: #aeaeb2;  /* While a row is open: the head and the total, the other rows' pencils, and the soft shadow     their text turns into. */  --ink-dim: #cdcdd2;  --ink-dimmer: #dedee2;
expenses.ts
/* * The sample expenses: what each one was for, how it was paid and how much it came to. */export type Expense = {  id: string;  /** What it was for. */  name: string;  /** How it was paid. */  method: string;  /** In dollars. */  amount: number;};export const EXPENSES: Expense[] = [  { id: 'studio', name: 'Studio rent', method: 'Transfer', amount: 940 },  { id: 'train', name: 'Train pass', method: 'Debit card', amount: 96 },  { id: 'market', name: 'Market run', method: 'Cash', amount: 42 },  { id: 'phone', name: 'Phone plan', method: 'Credit card', amount: 35 },  { id: 'climbing', name: 'Climbing gym', method: 'Credit card', amount: 68 },];/** 940 → "940", 1250 → "1,250", 42.5 → "42.50". */export const money = (amount: number) =>
icons.tsx
/* * The row editor's icons, drawn on a 16 × 16 grid with a 1.5 stroke (the pencil is filled). */type IconProps = { className?: string };/** Expense: a receipt. */export function ReceiptIcon({ className }: IconProps) {  return (    <svg className={className} viewBox="0 0 16 16" aria-hidden="true">      <path d="M3.75 2.25h8.5v11.5l-1.7-1.1-1.4 1.1L8 12.65l-1.15 1.1-1.4-1.1-1.7 1.1z" />      <path d="M6 5.75h4M6 8.5h2.5" />    </svg>  );}/** Method: a card. */export function CardIcon({ className }: IconProps) {  return (    <svg className={className} viewBox="0 0 16 16" aria-hidden="true">      <rect x="2" y="3.5" width="12" height="9" rx="2" />      <path d="M2 6.75h12M4.5 10h2.5" />    </svg>  );
spring.ts
/* * Springs as CSS easings. A spring pulls a value to its target and lets it settle: fast at first, * then slowing down, overshooting a little and coming back if it is bouncy. CSS `linear()` draws * any curve through a list of points, so the spring's motion is sampled once, and the same curve * serves CSS transitions and the Web Animations API. */export type Spring = {  /** The curve, as a CSS `linear()` easing. */  easing: string;  /** How long it takes to settle, in ms: the duration to play the curve over. */  duration: number;};/** Points sampled along the curve: plenty for a smooth motion. */const SAMPLES = 40;/** * A spring that covers most of the distance in about `response` ms. With a `bounce` of 0 it * settles without overshooting; at 0.3 it overshoots by about 5% and comes back. */export function spring(bounce: number, response: number): Spring {  // The damping ratio (1: no overshoot) and the natural frequency, in radians per second.  const damping = 1 - bounce;
demo-cursor.tsx
'use client';import type { Ref } from 'react';import styles from './demo-cursor.module.css';/* * The demo's cursor. While a demo plays on its own, an arrow glides to the controls and presses * them, so the demo reads like someone using the component. Each glide is a CSS transition, one * per axis with a different easing, which bends the path into a gentle arc: nothing runs between * moves. */export function DemoCursor({ ref }: { ref: Ref<HTMLSpanElement> }) {  return (    <span ref={ref} className={styles.cursor} data-state="hidden" aria-hidden="true">      <span className={styles.vertical}>        <span className={styles.ripple} />        <svg className={styles.arrow} viewBox="0 0 20 20">          <path d="M4 2.5 15 13h-4.6l2.4 5.2-2.4 1-2.4-5.2L4 17.2z" />        </svg>      </span>    </span>  );}
demo-cursor.module.css
/* The demo's cursor (demo-cursor.tsx): an arrow whose tip sits at (--x, --y) in its stage. The   outer box carries the horizontal move, the inner one the vertical move, eased differently. */.cursor {  /* The arrow and its edge stay the system's on both schemes, dark and edged in white, so it reads     on the dark surfaces and the light Done button alike; the ring where it presses turns light at     night. */  --pointer: #1d1d1f;  --pointer-edge: #fff;  --pointer-ring: rgb(0 0 0 / 0.14);  --x: 0px;  --y: 0px;  --move: 0ms;  position: absolute;  top: 0;  left: 0;  z-index: 10;  pointer-events: none;  opacity: 0;  transform: translateX(var(--x));  transition:    opacity 0.25s ease,    transform var(--move) cubic-bezier(0.45, 0, 0.25, 1);}
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 table is a grid with one tab stop: the arrow keys, Home and End move between rows, and Enter or F2 opens the focused row. The pencils are labelled buttons (“Edit Market run”).
  • The open row is a labelled form: Enter in a field saves, Escape cancels, and focus returns to the row. The other rows are inert while it is open.
  • Saves are announced, for example “Saved Market run: Cash, $58.”
  • Under reduced motion the row opens and closes at once, without travel or fades, and the demo does not play.