Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Lead table

New

A prospecting table that shows who is ready to buy: intent cards that filter in one click, bulk actions, a company drawer and Undo on every change.

lead-table/lead-table.tsx
Live · interactive

Prospects

Companies that fit your customer profile

Prospects: 30 companies, sorted by company
Actions
Manufacturing+5%1,45064Medium74
Retail+12%5852Medium8
Healthcare+38%8872High10
Logistics+13%34070High31
Healthcare+2%13054Medium23
Healthcare−2%4526Low11
Retail+3%90037Low52
Retail+1%1,20044Medium120
Manufacturing−6%2,10033Low95
Manufacturing+11%52081High57
Rows per page1–10 of 30 companies

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 accounts a sales team works through, where the first question is which ones are ready to buy.
  • Twenty to a few hundred companies, scored, with owners and a status each, paged ten to thirty at a time.
  • Work done in batches: pick a few rows, then set their status, assign them, export them or save them to a list.
  • Changes that should be easy to take back: each one reports itself in a toast with Undo.
  • A main view, about 900 to 1,100 px wide; on a phone the table scrolls sideways inside its own box.

When not to use

  • Thousands of rows, loaded from a server as the reader scrolls. Use a virtualised grid with server sorting.
  • Cells edited one by one like a spreadsheet. Use a data grid with inline editing.
  • A single account’s full history. Use a record page with a timeline.
  • A handful of items with nothing to score, assign or filter. Use a simple list.
  • Reports and trends across the pipeline. Use charts and a dashboard.

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

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

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

prospects.tsx
'use client';import { LeadTable, type Company } from './lead-table';const companies: Company[] = [  {    id: 'acme',    name: 'Northwind Parts',    domain: 'northwindparts.com',    icon: 'factory',    industry: 'Manufacturing',    city: 'Munich',    country: 'DE',    growth: 12,    size: 420,    intent: 81,    contacts: 36,    status: 'new',    owner: null,    funding: 'Series B',    revenue: 58,    founded: 2011,  },];export function Prospects() {  return (    <LeadTable      companies={companies}      onStatusChange={(list, status) => console.log(list.map((c) => c.id), status)}      onAssign={(list, owner) => console.log(list.map((c) => c.id), owner?.id)}      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 sales prospecting table for the companies that fit your customer profile. Three intent cards count the High, Medium and Low ones with a strip of dots, and pressing one filters the table to that level. Search, filter by status or industry, show your own accounts, pick the columns, sort and page. Select rows to delete, export, set a status, assign an owner or save them to a list; open a company for its firmographics, headcount, contacts and buying signals; hand one teammate’s open accounts to another, or add a company. Each change reports itself in a toast whose Undo puts it back.

  • The table is one pipeline over the rows: the search (name, industry, city or domain), the intent level, My Accounts, and the status and industry filters, then the sort, then the page. The intent cards run the same pipeline without their own filter, so their counts, dots and percents always describe what the other filters leave.
  • An intent score of 70 and up is High, 40 to 69 Medium, under 40 Low. A card’s strip has one dot per scored company (up to 30), lit for the level’s share, and it is a toggle button: pressed, the table keeps that level and Clear shows in the toolbar.
  • Every change goes through one function that keeps the rows and lists as they were with the toast that reports it; Undo puts them back. Status, owner, delete, hand-over, saving to a list, adding and editing all work that way, and each calls its callback with the companies it touched.
  • Selecting rows swaps the toolbar for a bulk bar with the count, the contacts and the average intent of the selection. Status, Assign and Save to List open menus with the same items as the cells’ own menus.
  • Menus sit on the stage, or in the panel they open from, never inside the table: its sideways scroll would clip them. Each is placed under its trigger in the stage’s own px, so it stays right when the gallery scales the stage, and flips above it when there is no room below.
  • The drawer and the dialogs are modal panels over the card, which blurs and goes inert behind them. The headcount chart is an SVG path through six month-end counts with a soft area under it; growth bars and intent rings are small SVGs coloured by tone.
  • Under 720 px the cards and toolbar wrap; the table always scrolls inside its own box, with the checkbox and Company columns held at its left, so the page never scrolls sideways.
  • The demo presses the High card, sets two companies to Contacted, gives the unassigned one an owner, opens its drawer, then hands accounts over, each with its toast, in a loop. It presses the real controls, so it runs the same code a reader does; any input of the reader’s stops it.
PropTypeDefaultDescription
companiesreadonly Company[]COMPANIESThe rows: name, domain, mark, industry, city and country, growth (% over six months, or null), size, intent score (0–100, or null), contacts, status, owner id (or null), funding, revenue and year founded.
ownersreadonly Owner[]OWNERSWho can own an account: an id, a name and an avatar colour. The one with you: true is the reader, for My Accounts and Assign to me.
listsreadonly SavedList[]LISTSThe saved lists Save to List offers, each with the ids saved to it.
titlestring'Prospects'The header’s title.
subtitlestring'Companies that fit your customer profile'The line under the title.
pageSizenumber10Rows per page at first: 10, 20 or 30.
columnsreadonly ColumnId[]—The columns shown at first besides Company; the reader changes them under Columns. Industry, Growth, Size, Intent, Contacts, Status and Owner by default; HQ, Funding, Revenue and Founded are there to add.
onStatusChange(companies: Company[], status: Status) => void—Called when one company or a selection gets a new status.
onAssign(companies: Company[], owner: Owner | null) => void—Called when companies get an owner, or none.
onHandOver(companies: Company[], from: Owner | null, to: Owner | null) => void—Called with the open accounts a hand-over moved.
onAdd(company: Company) => void—Called with the company Add Company created.
onDelete(companies: Company[]) => void—Called with the companies deleted.
onExport(companies: Company[]) => void—Called with the companies to export; writing the file is up to you.
onSaveToList(companies: Company[], list: SavedList) => void—Called when companies are saved to a list, new or not.
onOpen(company: Company) => void—Called when a company’s drawer opens.
autoplaybooleantrueWhile on screen and untouched, a demo pointer shows the table at work; any input hands it to the reader.
colorScheme'light' | 'dark' | 'system'—Light or dark colours, or the reader’s; without it, the page’s color-scheme.
defaultIntent'high' | 'medium' | 'low'—Opens filtered to this intent level; turns the demo off.
defaultQuerystring—Opens with this search typed in; turns the demo off.
defaultSelectedreadonly string[]—Opens with these rows selected, by id; turns the demo off.
defaultOpenstring—Opens with this company’s drawer showing, by id; turns the demo off.
defaultDialog'handover' | 'add'—Opens with Hand Over or Add Company showing; turns the demo off.
classNamestring—Classes for the stage.

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

lead-table.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useCallback,  useEffect,  useEffectEvent,  useId,  useRef,  useState,  type ReactNode,} from 'react';import {  COMPANIES,  LEVEL_WORD,  LISTS,  OWNERS,  STATUSES,  STATUS_WORD,  count,  firstName,  isClosed,  levelOf,
lead-table.module.css
/* Lead table (lead-table.tsx): a light, crisp prospecting table on a white stage, or at night a   near-black one. The card is a pale frame with the title, holding a white sheet: intent cards,   the toolbar, the table and its pages. Type is small (13 px in the table, 11.5 px in chips).   The drawer and the dialogs are layers on the stage, over the card, which blurs behind a grey   veil. Menus sit on the stage too (or in the panel they open from), placed by the script.   Under 720 px of stage, the header and toolbar wrap and the cards stack; the table always   scrolls inside its own box, with the checkbox and Company columns held at its left.   Colours are the tokens below, light-dark(light, dark) where the two differ: 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 card and its sheet, their lines. */  --stage: #ffffff;  --card: #fafafa;  --surface: #ffffff;  --surface-2: #f7f7f8;  --line: #ececee;  --edge: #e4e4e7;  --shadow: rgb(0 0 0 / 0.06);  --shadow-strong: rgb(0 0 0 / 0.12);  /* Text. */
data.ts
// Copyright (c) 2026 Bruno Delorence. MIT License./* * The lead table's data: the companies, the four owners, the saved lists, and the small rules that * read them (an intent score's level, a status that closes an account, the numbers a row shows). * Every company and person here is made up. */export type Level = 'high' | 'medium' | 'low';export type Status = 'new' | 'researching' | 'contacted' | 'qualified' | 'disqualified';/** The monochrome mark on a company's tile. */export type CompanyIcon =  | 'chart'  | 'flask'  | 'route'  | 'robot'  | 'database'  | 'layers'  | 'cloud'  | 'shield'  | 'card'  | 'wallet'  | 'target'
parts.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.import type { CSSProperties } from 'react';import {  COUNTRIES,  LEVEL_WORD,  MONTHS,  STATUS_WORD,  count,  growthTone,  headcountOf,  initialsOf,  levelOf,  sparkOf,  type Company,  type Owner,  type Status,} from './data';import { Icon } from './glyphs';import styles from './lead-table.module.css';/* * The small pieces the table, the drawer and the dialogs share: avatars, the round colour mark * beside a city, the growth bars, the intent ring, status chips, the dotted strip of an intent card
glyphs.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.import type { CompanyIcon } from './data';/* * The table's line glyphs, on a 24-unit grid, drawn with the current text colour: the controls' * icons and the companies' marks. All decorative: the button or the name beside them says it. */const UI = {  search: 'M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4',  x: 'M6 6l12 12M18 6L6 18',  filter: 'M4 5h16l-6.2 7.4V19l-3.6-1.8v-4.8z',  columns: 'M4 5h16v14H4zM9.5 5v14M14.5 5v14',  sliders: 'M4 7h9M17 7h3M4 17h3M11 17h9M15 5v4M9 15v4',  chevronDown: 'M7 10l5 5 5-5',  chevronLeft: 'M14.5 7l-5 5 5 5',  chevronRight: 'M9.5 7l5 5-5 5',  sort: 'M8 9.5l4-4 4 4M8 14.5l4 4 4-4',  arrowUp: 'M12 19V5M6.5 10.5L12 5l5.5 5.5',  arrowDown: 'M12 5v14M6.5 13.5L12 19l5.5-5.5',  check: 'M5 12.5l4.5 4.5L19 7.5',  plus: 'M12 5v14M5 12h14',  minus: 'M5 12h14',
layers.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useLayoutEffect,  useRef,  useState,  type KeyboardEvent as ReactKeyboardEvent,  type ReactNode,} from 'react';import { Icon } from './glyphs';import styles from './lead-table.module.css';/* * The layers over the table: menus, and the panels that take the screen (the company drawer, the * dialogs). The table's menus live on the stage, not inside the table, so its scrolling never clips * them; a panel's menus live in the panel. A menu is placed under its trigger in the px of the box * it sits in (the gallery may scale the whole stage), or over it when there is no room below. */const ITEMS =  '[role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"], [data-menu-field]';
drawer.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useId, useRef, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from 'react';import {  COUNTRIES,  contactsOf,  count,  initialsOf,  signalsOf,  type Company,  type Owner,  STATUS_WORD,  type SavedList,} from './data';import { Icon, Mark, type UiIcon } from './glyphs';import { Panel } from './layers';import { Avatar, HeadcountChart, Intent, StatusChip } from './parts';import styles from './lead-table.module.css';/* * The company drawer: who the company is, its people and what it did lately, over the blurred * table. Overview holds the firmographics, a six-month headcount chart and where outreach stands;
dialogs.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useId, useRef, useState, type FormEvent, type ReactNode } from 'react';import {  CITIES,  FUNDING,  INDUSTRIES,  STATUSES,  STATUS_WORD,  count,  domainOf,  isClosed,  levelOf,  type Company,  type Owner,  type Status,} from './data';import { Icon } from './glyphs';import { Menu, MenuItem, Panel } from './layers';import { Avatar, Flag, StatusChip } from './parts';import styles from './lead-table.module.css';
demo-pointer.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'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, or * glide the pointer somewhere. 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. * * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame. * The points of the flight are handed to whoever wants to move along with it. The arrow and the * hand are HTML boxes * that swap by their visibility: a change of style inside an SVG would lay the SVG out again. */export type Point = { x: number; y: number };/** A glide's path and timing, for an animation that plays along with it. */export type Flight = { points: Point[]; timing: KeyframeAnimationOptions };export type Demo = {
use-autoplay.ts
// Copyright (c) 2026 Bruno Delorence. MIT License.'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(
  • The card is a section named by its title, and the table a real table with a caption that says how many companies show, the level filter and the sort. Sortable headers are buttons, with aria-sort on the sorted one.
  • The intent cards are toggle buttons (aria-pressed) named with their count, share and how many are unassigned; My Accounts is a toggle too.
  • Status chips, owners, row actions, Filter, Columns, the view options and the bulk bar’s Status, Assign and Save to List open menus: arrow keys, Home and End move through the items, which are menu items, radios or checkboxes with aria-checked; Escape closes and gives the focus back.
  • The drawer and the dialogs are modal dialogs named by their titles: the focus moves in and stays inside, Escape closes, and the focus goes back to what opened them. The table behind them is inert. The drawer’s tabs are a tablist, moved through with the arrow keys.
  • Every change is announced in a polite live region, as the toast says it; Undo is a plain button in the toast. The range of rows shown is announced as it changes.
  • Every control has a visible focus ring. Under reduced motion the demo, the panels’ slide, the menus’ pop and the toast’s motion are off.
  • The demo pointer is decorative and hidden from assistive technology.