Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Records table

New

A CRM grid of companies with coloured category tags, sortable columns and an agent that fills a new column row by row.

records-table/records-table.tsx
Live · interactive
Companies
Company
B2BGelatoWholesale
4 days agoVery strongalpine-churn.example.com
Amber Scoop — Prague
GelatoB2B
over 1 year agoNo communication—
Andes Snow Creamery — Quito
GelatoCatering
almost 2 years agoVery weak—
SorbetImports
11 months agoVery weakapricot-atlas.example.com
GelatoSeasonal
9 days agoVery strongaurora-scoops.example.com
Dairy-freeSeasonalB2C
5 weeks agoWeakbaltic-berry.example.com
VeganCafeB2C
9 days agoVery strongblack-sesame.example.com
GelatoCafe
over 1 year agoVery weakblue-fig.example.com
Dairy-freeLocalSeasonal
8 days agoVery strongboreal-batch.example.com
B2BLocalWholesale
about 2 years agoNo communicationcacao-norte.example.com
WholesaleImports
over 1 year agoVery weakcape-vanilla.example.com
CafeLocalSeasonal
6 days agoVery strongcedar-spoon.example.com
Dairy-freeSeasonal
No contactNo communicationcloudberry-cone.example.com
VeganB2CCafe
24 days agoWeakcoconut-commons.example.com
GelatoCafeB2C
about 1 month agoWeakcopper-cone.example.com
SorbetLocal
9 days agoVery strongcoral-coast.example.com
GelatoWholesaleCatering
2 months agoWeakcrimson-clover.example.com
B2BWholesaleLocal
2 days agoVery strongdelta-dairy.example.com
GelatoWholesale
3 days agoVery strongdolomite-dairy.example.com
Dragonfruit Dock — Shenzhen
SorbetB2BWholesale
No contactNo communication—
B2CVegan
15 days agoWeakember-cone.example.com
B2BCateringLocal
10 months agoVery weakequator-cream.example.com
Fjord Fizz Ice — Oslo
Dairy-freeSeasonal
No contactNo communication—
SeasonalLocalCatering
7 weeks agoWeakglacier-grove.example.com
SorbetCafe
6 weeks agoWeakhibiscus-house.example.com
Juniper & Cream — Vancouver
Dairy-freeCatering
No contactNo communication—
B2CCafeVegan
3 weeks agoVery strongkumo-creamery.example.com
GelatoSeasonalCatering
7 days agoVery stronglagoon-ladle.example.com
Lotus Leaf Scoops — Hanoi
VeganCafe
15 days agoWeak—
Dairy-freeCafe
8 months agoWeaklumen-soft-serve.example.com
SorbetVegan
almost 2 years agoVery weakmango-moon.example.com
B2BWholesaleSeasonal
15 days agoWeakmaple-orbit.example.com
Midnight Milk — Tromsø
Dairy-freeVeganWholesale
No contactNo communication—
Mint Medina — Tunis
Dairy-freeVeganLocal
No contactNo communication—
SorbetVeganCatering
18 days agoWeakmonsoon-mango.example.com
B2BWholesale
about 1 month agoVery weakmooncake-ice-cream.example.com
ImportsB2B
almost 2 years agoNo communicationnomad-nougat.example.com
GelatoCafeLocal
4 days agoVery strongolive-snow.example.com
GelatoSeasonalCafe
5 days agoVery strongorchard-cloud.example.com
SorbetSeasonal
2 months agoWeakpacific-pear.example.com
B2CVegan
3 months agoVery weakpalm-sugar.example.com
B2CLocal
12 months agoVery weakpampa-creamery.example.com
Pine & Pistachio — Istanbul
GelatoCatering
about 1 month agoVery weak—
B2CWholesale
10 days agoVery strongquartz-cone.example.com
CafeVegan
about 1 month agoWeakred-lantern.example.com
Rosewater Kulfi — Jaipur
B2CSeasonal
2 months agoVery weak—
ImportsCatering
almost 2 years agoVery weaksaffron-sky.example.com
Sahara Swirl — Marrakech
SorbetLocal
5 months agoVery weak—
ImportsCateringGelato
8 months agoVery weaksalt-and-silk.example.com
SorbetImports
about 1 month agoWeaksilk-road.example.com
GelatoLocal
2 months agoWeaksol-y-nieve.example.com
GelatoCafe
over 2 years agoNo communicationsunbird.example.com
VeganSorbetB2C
9 months agoVery weaktamarind-tide.example.com
GelatoLocal
2 months agoWeaktasman-sea.example.com
SorbetLocalB2C
6 days agoVery strongtropic-churn.example.com
B2BWholesaleCafe
5 weeks agoWeakumber-cream.example.com
Vanilla Vale — Antananarivo
ImportsLocal
No contactNo communication—
Dairy-freeVeganCafe
3 days agoVery strongwillow-whip.example.com
SorbetCafeSeasonal
11 days agoVery strongyuzu-yard.example.com
GelatoSeasonal
3 weeks agoWeakzenith-gelato.example.com
60count—55% average48 links

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 list of records with several properties each, read across a row: companies, people, deals.
  • Columns filled by an agent or a rule, where each header says how and can fill its column again.
  • Picking a few records to act on together: ticked rows, a header box that selects them all.
  • Reading the same list by different orders, recent or strong first, without losing the original one.
  • Wide data in a small card or side panel: one scroller both ways, the company column and the header kept in view.

When not to use

  • A handful of records with two or three facts each. Use a list or cards.
  • Editing cell by cell like a spreadsheet: this table reads and calculates, it has no cell editor.
  • Hundreds of rows or more: every row is drawn. Page it, or use a virtualised grid.
  • Comparing numbers at a glance. Use a chart, and keep the table for the detail.
  • A table on a phone as the main view: a column at a time fits better as a list of cards.

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

Into components/records-table of any React project, with no setup:

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

accounts.tsx
import { RecordsTable, type CompanyRecord } from './records-table';const companies: CompanyRecord[] = [  {    name: 'Harbor Freeze',    city: 'Lisbon',    site: 'harbor-freeze.example.com',    categories: ['Gelato', 'Wholesale'],    last: [3, '3 days ago'],    strength: 'very-strong',  },  {    name: 'Pine Cone Dairy',    city: 'Oslo',    categories: ['Local'],    strength: 'none',  },];export function Accounts({ onPick }: { onPick: (ids: string[]) => void }) {  return (    <RecordsTable      records={companies}      categoryColors={{ Gelato: 'oklch(0.62 0.18 293)', Wholesale: 'oklch(0.76 0.13 70)', Local: 'oklch(0.7 0.13 162)' }}      onSelectionChange={onPick}      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 grid of sixty companies that scrolls both ways under a sticky header, company column and footer of calculations. Rows tick and tint, two columns sort, every column’s edge drags, and tags that no longer fit fold into a +N chip. Each column’s header opens how an agent fills it — type, tool, inputs and a prompt with chips — and the + adds a Competitors column that the agent fills a row every 120 ms.

  • One scroller holds a fixed-layout table. Its header sticks to the top, the footer of calculations to the bottom and the company column to the left, each painted opaque, with a faint 5 px shadow off the company column’s edge; the corner cells sit above both.
  • A row’s number and its checkbox share one 24 px grid cell: the number gives way to the box under the pointer, in focus or once ticked. Ticked rows mix 7 % of the accent into the card in srgb; the header’s box selects all and shows a dash for some (and is indeterminate for screen readers).
  • Tags take one base colour per category and mix it into the card in srgb: 34 % at night with its words 86 % of it on white, 18 % by day with a 32 % edge and words darkened by the ink. Each tag and +N chip is measured once, hidden (again when the fonts arrive), so a cell shows as many tags as fit whole, with a chip for the rest, without measuring 60 rows.
  • Every column’s right edge is a separator: drag it (with pointer capture) or focus it and press the arrows, from 120 to 520 px, and a 2 px accent bar shows while it is in use. Table options compact the columns, so the tags fold, or put them back.
  • Last interaction and Connection strength sort: the arrow fades in on the header’s hover and stays while it sorts, turns over in 160 ms for the other way, and a third press restores the book’s order. Rows without a value stay last either way.
  • A header opens its column’s settings under it, tinting the column with 8 % of the accent and an accent line along its top. Type, Tool and Inputs open small menus beside the panel (or over it when the component is too narrow); the prompt is a contentEditable line whose mentioned columns stand as chips, read back as the reader types.
  • Go calculate fills the column again: each waiting cell says “Calculating…” beside a dot breathing between 35 % and full (1.1 s), and one row fills every 120 ms in the order shown. The + adds a Competitors column, of the type picked, and opens its settings at once; it can be pinned first or hidden again.
  • Menus pop in from their button’s corner, 160 ms from 95 %, and are placed inside the component (clamped to its edges, following the grid as it scrolls). A soft highlight glides between their rows by top and height (220 ms).
  • While on screen and untouched, a demo uses the table: it ticks two rows, sorts by the last interaction both ways, adds and calculates the Competitors column, compacts the columns so the tags fold, resets them and tidies up. Its actions never reach the callbacks and are not announced; any real input hands the table to the reader.
  • Every colour is a light-dark() token on the root, so the table follows the page’s color-scheme or the colorScheme prop. On a card under 440 px the company column starts at half its width, so the others still show.
PropTypeDefaultDescription
recordsCompanyRecord[]sixty sample companiesThe companies, in the book’s order: { id?, name, city?, site?, categories, last?: [days, label], strength }. strength is very-strong, strong, weak, very-weak or none.
categoryColorsRecord<string, string>twelve sample categoriesEach category’s base colour, any CSS colour; the tags mix it with the table.
labelstring'Companies'Names the table and its scroller for screen readers.
defaultStateRecordsState—Opens in a state, with the demo off: selected rows, sort, compact widths, competitors (or 'pinned'), a column held calculating, unique counts, scroll (px or 'end') and an open menu or column panel, held until a press inside the table.
onSelectionChange(ids: string[]) => void—Called with the ids (or names) of the ticked rows when the reader changes them.
onSortChange(sort: RecordsSort) => void—Called when the reader sorts: { key: last | strength, direction }, or null for the book’s order.
onCalculate(column: string) => void—Called when the reader presses Go calculate, with the column’s name.
autoplaybooleantrueWhile on screen and untouched, a demo selects, sorts, adds and calculates a column, and folds the tags by itself.
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.

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

records-table.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useCallback,  useEffect,  useEffectEvent,  useLayoutEffect,  useMemo,  useRef,  useState,  type CSSProperties,  type KeyboardEvent as ReactKeyboardEvent,  type PointerEvent as ReactPointerEvent,} from 'react';import {  ColumnPanel,  PROPERTY_TYPES,  typeIcon,  type ColumnSettings,  type PropertyType,} from './column-panel';import { CATEGORY_COLORS, COMPANIES, COMPETITORS, type CompanyRecord, type Strength } from './data';
column-panel.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useId, useRef, useState, type CSSProperties, type ReactNode } from 'react';import { GlideMenu, type MenuItem } from './glide-menu';import { Icon, type IconName } from './icons';import styles from './records-table.module.css';/* * A column's settings: how an agent fills it. Its type, the tool or model it uses, whether it checks * its answers against sources, which other columns it reads, and the prompt, where those columns * stand as small chips. Go calculate fills the column again; Pin moves it next to the company; a * column added from the table can be hidden again. * * - Type, Tool and Inputs open small menus beside the panel (or over it when there is no room), *   which pop in from their corner in 140 ms. * - The prompt is a contentEditable line: typed text and the chips of the columns it mentions. *   What the reader types is read back from it as they type; it is drawn afresh only when another *   column's settings come in. */export const PROPERTY_TYPES: { type: PropertyType; icon: IconName }[] = [  { type: 'Text', icon: 'text' },
glide-menu.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useRef,  useState,  type CSSProperties,  type KeyboardEvent as ReactKeyboardEvent,  type ReactNode,} from 'react';import { Icon } from './icons';import styles from './records-table.module.css';/* * A short menu under a small heading. A soft highlight sits under the row the pointer or the focus * is on and glides to the next by its top and height (220 ms), fading out when both leave. Up and * down arrows, Home and End move the focus between the rows; Escape is the opener's to handle. */export type MenuItem = {  key: string;  label: string;
icons.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.import type { ReactNode } from 'react';/* * The table's line icons, drawn on a 24-unit grid with round ends, in the colour of the text around * them. Every one is decorative: the control it sits in carries the words. */export type IconName =  | 'text'  | 'file'  | 'collection'  | 'single'  | 'multi'  | 'url'  | 'reference'  | 'json'  | 'splitter'  | 'date'  | 'globe'  | 'cursor'  | 'sparkle'  | 'plus'
data.ts
// Copyright (c) 2026 Bruno Delorence. MIT License./* * The sample book: sixty ice-cream makers, sellers and cafés (invented, with example.com sites), the * colour of each category, and what the demo's "Competitors" column finds for them. *//** How strong a relationship is, from the last interaction. */export type Strength = 'very-strong' | 'strong' | 'weak' | 'very-weak' | 'none';export type CompanyRecord = {  /** Names the row in selections; the name is used when absent. */  id?: string;  name: string;  /** Shown after the name: “Alpine Churn — Zürich”. */  city?: string;  /** The company's website, without the scheme: “alpine-churn.example.com”. */  site?: string;  categories: string[];  /** When they last heard from you: days ago (for sorting) and how it reads. */  last?: [days: number, label: string];  strength: Strength;};
records-table.module.css
/* Records table (records-table.tsx): a soft grey stage with a 480 px card in its middle, the grid   of companies scrolling inside it both ways, or at night a graphite stage with the card a step   lighter.   Colours are the tokens below, 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). Tags, the selected rows and the selected column mix their colour into   the card's in srgb, so each scheme keeps its own strength of tint.   Memory. The grid is one scroller: the header, the company column and the footer stick to its   edges, painted opaque so nothing shows through. Motion is opacity and transforms (the menus'   pop-in, the sort arrow, the calculating dot) plus the menus' highlight gliding by top and height   within one short list. */.root {  /* The stage, the card, the hover and the footer's and prompt's inset. */  --canvas: oklch(96.1% 0.002 247.84);  --surface: oklch(100% 0 0);  --hover: oklch(97% 0.002 247.839);  --inset: oklch(97.9% 0.002 247.839);  --mark: oklch(96.1% 0.001 286.375);  /* Words: strong, quiet and faint. */  --ink: oklch(24.7% 0.006 258.361);  --ink-2: oklch(50.6% 0.01 264.477);
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;
  • A real table with a caption, column headers and the company as each row’s header, inside a focusable, labelled scroll region that arrow keys scroll both ways.
  • Every row has a labelled checkbox (“Select Alpine Churn — Zürich”), reachable with Tab even while the row number covers it; the header’s box is indeterminate when some rows are ticked. A polite live region says how many are selected.
  • Sortable headers carry aria-sort, and the sort is a separate button (“Sort by Last interaction, now most recent first”); the order is announced. Column edges are separators with their width as a value: arrows resize by 10 px (Shift by 40), Home and End go to the limits.
  • Headers are buttons that open their column’s settings as a labelled dialog; the + and the options open menus with menuitem, menuitemradio and menuitemcheckbox roles. Arrow keys, Home and End move between rows, Escape closes the innermost menu and gives the focus back to its button, and a press outside closes them.
  • Under reduced motion the menus appear at once, the calculating dot holds still and the demo never plays. Focus rings are visible on every control.