Records table
NewA 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.tsxCompany | |||||
|---|---|---|---|---|---|
| 4 days ago | Very strong | alpine-churn.example.com | |||
Amber Scoop — Prague | over 1 year ago | No communication | — | ||
Andes Snow Creamery — Quito | almost 2 years ago | Very weak | — | ||
| 11 months ago | Very weak | apricot-atlas.example.com | |||
| 9 days ago | Very strong | aurora-scoops.example.com | |||
| 5 weeks ago | Weak | baltic-berry.example.com | |||
| 9 days ago | Very strong | black-sesame.example.com | |||
| over 1 year ago | Very weak | blue-fig.example.com | |||
| 8 days ago | Very strong | boreal-batch.example.com | |||
| about 2 years ago | No communication | cacao-norte.example.com | |||
| over 1 year ago | Very weak | cape-vanilla.example.com | |||
| 6 days ago | Very strong | cedar-spoon.example.com | |||
| No contact | No communication | cloudberry-cone.example.com | |||
| 24 days ago | Weak | coconut-commons.example.com | |||
| about 1 month ago | Weak | copper-cone.example.com | |||
| 9 days ago | Very strong | coral-coast.example.com | |||
| 2 months ago | Weak | crimson-clover.example.com | |||
| 2 days ago | Very strong | delta-dairy.example.com | |||
| 3 days ago | Very strong | dolomite-dairy.example.com | |||
Dragonfruit Dock — Shenzhen | No contact | No communication | — | ||
| 15 days ago | Weak | ember-cone.example.com | |||
| 10 months ago | Very weak | equator-cream.example.com | |||
Fjord Fizz Ice — Oslo | No contact | No communication | — | ||
| 7 weeks ago | Weak | glacier-grove.example.com | |||
| 6 weeks ago | Weak | hibiscus-house.example.com | |||
Juniper & Cream — Vancouver | No contact | No communication | — | ||
| 3 weeks ago | Very strong | kumo-creamery.example.com | |||
| 7 days ago | Very strong | lagoon-ladle.example.com | |||
Lotus Leaf Scoops — Hanoi | 15 days ago | Weak | — | ||
| 8 months ago | Weak | lumen-soft-serve.example.com | |||
| almost 2 years ago | Very weak | mango-moon.example.com | |||
| 15 days ago | Weak | maple-orbit.example.com | |||
Midnight Milk — Tromsø | No contact | No communication | — | ||
Mint Medina — Tunis | No contact | No communication | — | ||
| 18 days ago | Weak | monsoon-mango.example.com | |||
| about 1 month ago | Very weak | mooncake-ice-cream.example.com | |||
| almost 2 years ago | No communication | nomad-nougat.example.com | |||
| 4 days ago | Very strong | olive-snow.example.com | |||
| 5 days ago | Very strong | orchard-cloud.example.com | |||
| 2 months ago | Weak | pacific-pear.example.com | |||
| 3 months ago | Very weak | palm-sugar.example.com | |||
| 12 months ago | Very weak | pampa-creamery.example.com | |||
Pine & Pistachio — Istanbul | about 1 month ago | Very weak | — | ||
| 10 days ago | Very strong | quartz-cone.example.com | |||
| about 1 month ago | Weak | red-lantern.example.com | |||
Rosewater Kulfi — Jaipur | 2 months ago | Very weak | — | ||
| almost 2 years ago | Very weak | saffron-sky.example.com | |||
Sahara Swirl — Marrakech | 5 months ago | Very weak | — | ||
| 8 months ago | Very weak | salt-and-silk.example.com | |||
| about 1 month ago | Weak | silk-road.example.com | |||
| 2 months ago | Weak | sol-y-nieve.example.com | |||
| over 2 years ago | No communication | sunbird.example.com | |||
| 9 months ago | Very weak | tamarind-tide.example.com | |||
| 2 months ago | Weak | tasman-sea.example.com | |||
| 6 days ago | Very strong | tropic-churn.example.com | |||
| 5 weeks ago | Weak | umber-cream.example.com | |||
Vanilla Vale — Antananarivo | No contact | No communication | — | ||
| 3 days ago | Very strong | willow-whip.example.com | |||
| 11 days ago | Very strong | yuzu-yard.example.com | |||
| 3 weeks ago | Weak | zenith-gelato.example.com | |||
| 60count | — | 55% average | 48 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:
npx shadcn@latest add https://brunodelorence.com/r/records-table.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
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
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.
| Prop | Type | Default | Description |
|---|---|---|---|
records | CompanyRecord[] | sixty sample companies | The 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. |
categoryColors | Record<string, string> | twelve sample categories | Each category’s base colour, any CSS colour; the tags mix it with the table. |
label | string | 'Companies' | Names the table and its scroller for screen readers. |
defaultState | RecordsState | — | 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. |
autoplay | boolean | true | While 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. |
className | string | — | 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.
// 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';// 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' },// 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;// 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'// 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 (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 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.