Skip to content
Star on GitHub
Charts and tablesAccount table
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Account table

Customer accounts with health, plan and an agent note per row. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add account-table
AccountPlanSeatsHealthMRR
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add account-table

React

$npx designme add account-table --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

A table of customer accounts with plan, seats, a health bar and monthly revenue, which people can search and filter by plan. Clicking a row opens a note on what the agent noticed about that account, with an optional button for the next step, such as drafting an email. It suits a customer success or sales view where an agent watches accounts for risk.

  • When an agent monitors customer accounts and flags the ones that need attention.
  • When account managers need to search and filter accounts by plan.
  • When each account's agent finding should lead to one action, like drafting a check-in email.
03

Usage

Import it and drive it from your own state.

Import

import { AccountTable } from '@/components/designme/account-table/AccountTable';

Example

The demo above as a React component, 29 lines you can copy.

'use client';
// The preview from designme.dev, rebuilt from the React component: the rows come in, then the agent opens the account it is worried about.
import { useRef, useState } from 'react';
import { AccountTable, type AccountTableAccount } from './AccountTable';
import { useStory } from '../lib/useScript';

const ACCOUNTS: AccountTableAccount[] = [
  { name: 'Northwind', color: 'var(--dm-av-b)', plan: 'Enterprise', seats: 120, health: 92, mrr: '$14,400', note: 'Expanding. Design team added 18 seats this month.' },
  { name: 'Lumen Labs', color: 'var(--dm-av-l)', plan: 'Pro', seats: 14, health: 48, mrr: '$672', note: 'Usage fell 40% after their lead designer left. Worth a check in.' },
  { name: 'Fable & Co', color: 'var(--dm-av-m)', plan: 'Team', seats: 32, health: 81, mrr: '$1,920', note: 'Steady. Asked about the Figma sync twice.' },
  { name: 'Orbit', color: 'var(--dm-av-p)', plan: 'Pro', seats: 8, health: 67, mrr: '$384', note: 'Trial of the agent ends Friday. 3 runs so far.' },
  { name: 'Kite Health', color: 'var(--dm-av-r)', plan: 'Enterprise', seats: 64, health: 88, mrr: '$7,680', note: 'Renewal in 6 weeks, champion is happy.' },
  { name: 'Paper Plane', color: 'var(--dm-av-t)', plan: 'Team', seats: 21, health: 34, mrr: '$1,260', note: 'Two failed payments. Billing contact bounced.' }
];

/* ---------- Account table: search, plan filters, and a row that opens on what the agent noticed ---------- */
export default function AccountTableDemo() {
  const [open, setOpen] = useState<string | null>(null);
  // the preview opens one account's note on its own, unless you take over first
  const ref = useRef<HTMLDivElement>(null);
  useStory(ref, async ({ wait }) => { await wait(1200); setOpen(o => o ?? 'Lumen Labs'); });
  return (
    <div ref={ref} style={{ width: '100%' }}>
      <AccountTable accounts={ACCOUNTS} plans={['Team', 'Pro', 'Enterprise']} open={open} onOpenChange={setOpen}
        action={{ label: 'Draft email', doneLabel: 'Draft ready', onClick: () => new Promise(r => setTimeout(r, 800)) }} />
    </div>
  );
}
04

Props

Everything AccountTable takes.

PropTypeWhat it does
accountsrequiredAccountTableAccount[]
plansstring[]The plan filters after All. Defaults to the plans in `accounts`, in the order they first appear
planTonesRecord<string, AccountTableTone>The tag colour for each plan. Defaults to Enterprise purple, Pro blue, Team teal
planstringThe plan filter, or '' for All
defaultPlanstring
onPlanChange(plan: string) => void
querystringThe search text. Matches account names
defaultQuerystring
onQueryChange(query: string) => void
openstring | nullThe account whose row is open, or null
defaultOpenstring | null
onOpenChange(name: string | null) => void
action{ label: string; doneLabel?: string; onClick: (account: AccountTableAccount) => void | Promise<unknown> }A button in the open row's note, like Draft email. Return a promise and it spins until it settles, then shows `doneLabel`
placeholderstring
emptyLabelstringShown when nothing matches the search and filter
classNamestring
05

Code

The full source, as the CLI installs it.

AccountTable.tsxReact · 145 lines
'use client';
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { avatarInk, enter, Spin } from '../lib/motion';
import '../designme.css';
import './account-table.css';

/** The tag colours a plan can take */
export type AccountTableTone = 'blue' | 'purple' | 'green' | 'orange' | 'red' | 'yellow' | 'pink' | 'teal';

export interface AccountTableAccount {
  /** Also the account's key, and its first letter makes the logo */
  name: string;
  /** The logo's colour, like 'var(--dm-av-b)'. An avatar colour brings its own ink for the letter, any other colour takes white */
  color: string;
  plan: string;
  seats: number;
  /** 0 to 100. Green from 80, amber from 60, red below */
  health: number;
  /** Already formatted, like "$14,400" */
  mrr: string;
  /** What the agent noticed, shown when the row opens */
  note?: ReactNode;
}

export interface AccountTableProps {
  accounts: AccountTableAccount[];
  /** The plan filters after All. Defaults to the plans in `accounts`, in the order they first appear */
  plans?: string[];
  /** The tag colour for each plan. Defaults to Enterprise purple, Pro blue, Team teal */
  planTones?: Record<string, AccountTableTone>;
  /** The plan filter, or '' for All */
  plan?: string;
  defaultPlan?: string;
  onPlanChange?: (plan: string) => void;
  /** The search text. Matches account names */
  query?: string;
  defaultQuery?: string;
  onQueryChange?: (query: string) => void;
  /** The account whose row is open, or null */
  open?: string | null;
  defaultOpen?: string | null;
  onOpenChange?: (name: string | null) => void;
  /** A button in the open row's note, like Draft email. Return a promise and it spins until it settles, then shows `doneLabel` */
  action?: { label: string; doneLabel?: string; onClick: (account: AccountTableAccount) => void | Promise<unknown> };
  placeholder?: string;
  /** Shown when nothing matches the search and filter */
  emptyLabel?: string;
  className?: string;
}

const TONE: Record<AccountTableTone, string> = { blue: 'dm-b', purple: 'dm-l', green: 'dm-m', orange: 'dm-p', red: 'dm-r', yellow: 'dm-y', pink: 'dm-k', teal: 'dm-t' };
const TONES: Record<string, AccountTableTone> = { Enterprise: 'purple', Pro: 'blue', Team: 'teal' };
const healthColor = (h: number) => h >= 80 ? 'var(--dm-atb-ok)' : h >= 60 ? 'var(--dm-atb-warn)' : 'var(--dm-atb-bad)';

/** Accounts you can search and filter by plan; a row opens to show what the agent noticed. */
export function AccountTable({
  accounts, plans, planTones = TONES, plan, defaultPlan = '', onPlanChange, query, defaultQuery = '', onQueryChange,
  open, defaultOpen = null, onOpenChange, action, placeholder = 'Search accounts', emptyLabel = 'No accounts match', className
}: AccountTableProps) {
  const [filter, setFilter] = useControllable(plan, defaultPlan, onPlanChange);
  const [text, setText] = useControllable(query, defaultQuery, onQueryChange);
  const [opened, setOpened] = useControllable(open, defaultOpen, onOpenChange);
  const tabs = plans ?? [...new Set(accounts.map(a => a.plan))];
  const s = text.trim().toLowerCase();
  const rows = accounts.filter(a => (!filter || a.plan === filter) && (!s || a.name.toLowerCase().includes(s)));

  // the rows stagger in again whenever the search or the filter changes
  const look = `${filter}\u0000${s}`;
  const [seen, setSeen] = useState(look);
  const [pass, setPass] = useState(0);
  if (seen !== look) { setSeen(look); setPass(p => p + 1); }

  // the action button resets whenever another row opens
  const [draft, setDraft] = useState<'idle' | 'busy' | 'done'>('idle');
  const [draftFor, setDraftFor] = useState(opened);
  if (draftFor !== opened) { setDraftFor(opened); setDraft('idle'); }
  const alive = useRef(true);
  useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
  const run = (a: AccountTableAccount) => {
    if (!action || draft === 'busy') return;
    const r = action.onClick(a);
    if (r && typeof (r as Promise<unknown>).then === 'function') {
      setDraft('busy');
      (r as Promise<unknown>).then(() => { if (alive.current) setDraft('done'); }, () => { if (alive.current) setDraft('idle'); });
    } else setDraft('done');
  };

  const toggle = (name: string) => setOpened(opened === name ? null : name);

  return (
    <div data-dm="" className={cx('dm-atb dm-panel', className)}>
      <div className="dm-atb-h">
        <label className="dm-atb-s"><Icon name="search" size="xs" /><input placeholder={placeholder} aria-label={placeholder} value={text} onChange={e => setText(e.target.value)} /></label>
        <div className="dm-atb-fl" role="group" aria-label="Plan">
          {['', ...tabs].map(p => (
            <button type="button" key={p || 'all'} className={cx('dm-chip dm-sm', filter === p && 'dm-on')} aria-pressed={filter === p} onClick={() => setFilter(p)}>{p || 'All'}</button>
          ))}
        </div>
      </div>
      <div className="dm-atb-sc">
        <table>
          <thead><tr><th>Account</th><th>Plan</th><th>Seats</th><th>Health</th><th className="dm-r">MRR</th></tr></thead>
          <tbody key={pass}>
            {rows.length ? rows.map((a, i) => {
              const isOpen = opened === a.name, tone = planTones[a.plan];
              return (
                <Fragment key={a.name}>
                  <tr className={cx('dm-row', isOpen && 'dm-open', enter(i * 35).className)} style={enter(i * 35).style} tabIndex={0} aria-expanded={isOpen}
                    onClick={() => toggle(a.name)} onKeyDown={e => { if ((e.key === 'Enter' || e.key === ' ') && e.target === e.currentTarget) { e.preventDefault(); toggle(a.name); } }}>
                    <td><span className="dm-nm"><span className="dm-lg" style={{ background: a.color, color: avatarInk(a.color) }}>{a.name[0]}</span>{a.name}</span></td>
                    <td><span className={cx('dm-tag', tone && TONE[tone])}>{a.plan}</span></td>
                    <td className="dm-num">{a.seats}</td>
                    <td><span className="dm-hb"><i><b style={{ width: a.health + '%', background: healthColor(a.health) }} /></i><span className="dm-num">{a.health}</span></span></td>
                    <td className="dm-r dm-num">{a.mrr}</td>
                  </tr>
                  {isOpen && (
                    <tr className="dm-det">
                      <td colSpan={5}>
                        <div className="dm-fold"><div>
                          <div className="dm-atb-note dm-enter">
                            <Icon name="spark" size="xs" />
                            <span>{a.note}</span>
                            {action && (
                              <button type="button" className="dm-pill dm-soft dm-sm" onClick={e => { e.stopPropagation(); run(a); }}>
                                {draft === 'busy' ? <Spin /> : draft === 'done' ? <><Icon name="check" size="xs" />{action.doneLabel ?? 'Done'}</> : action.label}
                              </button>
                            )}
                          </div>
                        </div></div>
                      </td>
                    </tr>
                  )}
                </Fragment>
              );
            }) : <tr className="dm-atb-empty"><td colSpan={5}>{emptyLabel}</td></tr>}
          </tbody>
        </table>
      </div>
    </div>
  );
}
account-table.cssReact · 47 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Account table ---------- */
.dm-atb{overflow:hidden}
/* the account table's colours run brighter than the shared tones: its plan tags take a stronger tint with saturated text
   (4.5:1 and APCA Lc 60 on the tint, in every style and theme) and the health bars vivid marks (3:1) */
.dm-atb{--dm-atb-ok:#00a44b;--dm-atb-warn:#cd7a00;--dm-atb-bad:#f03e48;
  --dm-pb:#0663d2;--dm-pb-bg:color-mix(in srgb,#4890fa 15%,transparent);--dm-pl:#7d2dfc;--dm-pl-bg:color-mix(in srgb,#9776fa 15%,transparent);--dm-pm:#087744;--dm-pm-bg:color-mix(in srgb,#1fad66 15%,transparent);--dm-pp:#a34f06;--dm-pp-bg:color-mix(in srgb,#dc7019 15%,transparent);
  --dm-pr:#c50b2d;--dm-pr-bg:color-mix(in srgb,#fb4652 15%,transparent);--dm-py:#886006;--dm-py-bg:color-mix(in srgb,#bb881a 15%,transparent);--dm-pk:#b80a75;--dm-pk-bg:color-mix(in srgb,#fb27a2 15%,transparent);--dm-pt:#08727c;--dm-pt-bg:color-mix(in srgb,#1fa5b1 15%,transparent)}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]) .dm-atb{--dm-atb-ok:#22c55e;--dm-atb-warn:#f59e0b;--dm-atb-bad:#fb4849;
  --dm-pb:#a4c9fe;--dm-pb-bg:color-mix(in srgb,#4890fa 22%,transparent);--dm-pl:#c9befe;--dm-pl-bg:color-mix(in srgb,#9776fa 22%,transparent);--dm-pm:#19e285;--dm-pm-bg:color-mix(in srgb,#1fad66 22%,transparent);--dm-pp:#feb68b;--dm-pp-bg:color-mix(in srgb,#dc7019 22%,transparent);
  --dm-pr:#feb1ad;--dm-pr-bg:color-mix(in srgb,#fb4652 22%,transparent);--dm-py:#feba28;--dm-py-bg:color-mix(in srgb,#bb881a 22%,transparent);--dm-pk:#feabcf;--dm-pk-bg:color-mix(in srgb,#fb27a2 22%,transparent);--dm-pt:#19daeb;--dm-pt-bg:color-mix(in srgb,#1fa5b1 22%,transparent)}}
:root[data-theme="dark"] .dm-atb{--dm-atb-ok:#22c55e;--dm-atb-warn:#f59e0b;--dm-atb-bad:#fb4849;
  --dm-pb:#a4c9fe;--dm-pb-bg:color-mix(in srgb,#4890fa 22%,transparent);--dm-pl:#c9befe;--dm-pl-bg:color-mix(in srgb,#9776fa 22%,transparent);--dm-pm:#19e285;--dm-pm-bg:color-mix(in srgb,#1fad66 22%,transparent);--dm-pp:#feb68b;--dm-pp-bg:color-mix(in srgb,#dc7019 22%,transparent);
  --dm-pr:#feb1ad;--dm-pr-bg:color-mix(in srgb,#fb4652 22%,transparent);--dm-py:#feba28;--dm-py-bg:color-mix(in srgb,#bb881a 22%,transparent);--dm-pk:#feabcf;--dm-pk-bg:color-mix(in srgb,#fb27a2 22%,transparent);--dm-pt:#19daeb;--dm-pt-bg:color-mix(in srgb,#1fa5b1 22%,transparent)}
.dm-atb-h{display:flex;align-items:center;gap:var(--dm-s-2);padding:10px;flex-wrap:wrap;border-bottom:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-atb-s{display:flex;align-items:center;gap:var(--dm-s-2);height:32px;padding:0 10px;border-radius:var(--dm-r-field);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line) 50%,transparent);color:var(--dm-muted);flex:1;min-width:160px;transition:box-shadow var(--dm-t-fast)}
@supports (corner-shape:squircle){.dm-atb-s{corner-shape:squircle;border-radius:var(--dm-r-field-sq)}}
.dm-atb-s input{flex:1;min-width:0;border:0;outline:0;background:transparent;font:inherit;font-size:13px;color:var(--dm-fg)}
.dm-atb-s:has(input:focus){box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-field-line-strong) 50%,transparent)}
.dm-atb-s input::placeholder{color:var(--dm-muted)}
.dm-atb-fl{display:flex;gap:var(--dm-s-1);flex-wrap:wrap}
/* the filters are white chips with a dimmed grey edge; the picked one's edge is a step stronger */
.dm-atb-fl .dm-chip{background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-pick-line) 50%,transparent)}
@media (hover:hover) and (pointer:fine){.dm-atb-fl .dm-chip:hover{background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-faint) 50%,transparent)}}
.dm-atb-sc{overflow-x:auto}
.dm-atb table{width:100%;border-collapse:collapse;font-size:13px;min-width:560px}
.dm-atb th{text-align:left;font-weight:500;font-size:12px;color:var(--dm-muted);padding:9px var(--dm-s-3);white-space:nowrap}
.dm-atb th.dm-r,.dm-atb td.dm-r{text-align:right}
.dm-atb td{padding:9px var(--dm-s-3);border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent);white-space:nowrap}
.dm-atb tr.dm-row{cursor:pointer;transition:background .2s}
@media (hover:hover) and (pointer:fine){.dm-atb tr.dm-row:hover{background:var(--dm-row)}}
/* a row that takes the focus fills the scrolling table, which would cut an outside ring: its ring is drawn inside */
.dm-atb tr.dm-row:focus-visible{outline-offset:-2px}
.dm-atb tr.dm-row.dm-open{background:var(--dm-wash)}
.dm-atb .dm-nm{display:flex;align-items:center;gap:9px;font-weight:500}
/* brand tiles sit a shade darker than the brand colour, so their white letter reads at 4.5:1 or more */
.dm-atb .dm-lg{width:24px;height:24px;border-radius:7px;display:grid;place-items:center;font-size:10px;font-weight:500;color:#fff}
.dm-atb .dm-hb{display:flex;align-items:center;gap:var(--dm-s-2)}
.dm-atb .dm-hb i{width:54px;height:5px;border-radius:3px;background:var(--dm-line);overflow:hidden;display:block}
.dm-atb .dm-hb i b{display:block;height:100%;border-radius:3px}
.dm-atb .dm-hb span{font-size:12px;color:var(--dm-fg2)}
.dm-atb tr.dm-det td{padding:0;border-top:0}
.dm-atb-note{display:flex;align-items:center;gap:10px;padding:10px var(--dm-s-3) var(--dm-s-3) 45px;font-size:12.5px;color:var(--dm-fg2);white-space:normal}
.dm-atb-note>svg{color:var(--dm-accent-fg,var(--dm-accent));flex:none}
.dm-atb-note span{flex:1}
.dm-atb-empty td{text-align:center;color:var(--dm-muted);padding:var(--dm-s-6)}
account-table.htmlHTML · 9 lines
<div class="card wide" data-c="account-table">
  <div class="stage-in">
    <div class="atb panel">
      <div class="atb-h"><div class="atb-s"><svg class="i xs"><use href="#i-search"/></svg><input placeholder="Search accounts" aria-label="Search accounts"></div><div class="atb-fl"><button class="chip sm on" data-p="">All</button><button class="chip sm" data-p="Team">Team</button><button class="chip sm" data-p="Pro">Pro</button><button class="chip sm" data-p="Enterprise">Enterprise</button></div></div>
      <div class="atb-sc"><table><thead><tr><th>Account</th><th>Plan</th><th>Seats</th><th>Health</th><th class="r">MRR</th></tr></thead><tbody></tbody></table></div>
    </div>
  </div>
</div>
account-table.cssHTML · 46 lines
/* ---------- Account table ---------- */
.atb{overflow:hidden}
/* the account table's colours run brighter than the shared tones: its plan tags take a stronger tint with saturated text
   (4.5:1 and APCA Lc 60 on the tint, in every style and theme) and the health bars vivid marks (3:1) */
.atb{--atb-ok:#00a44b;--atb-warn:#cd7a00;--atb-bad:#f03e48;
  --pb:#0663d2;--pb-bg:color-mix(in srgb,#4890fa 15%,transparent);--pl:#7d2dfc;--pl-bg:color-mix(in srgb,#9776fa 15%,transparent);--pm:#087744;--pm-bg:color-mix(in srgb,#1fad66 15%,transparent);--pp:#a34f06;--pp-bg:color-mix(in srgb,#dc7019 15%,transparent);
  --pr:#c50b2d;--pr-bg:color-mix(in srgb,#fb4652 15%,transparent);--py:#886006;--py-bg:color-mix(in srgb,#bb881a 15%,transparent);--pk:#b80a75;--pk-bg:color-mix(in srgb,#fb27a2 15%,transparent);--pt:#08727c;--pt-bg:color-mix(in srgb,#1fa5b1 15%,transparent)}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]) .atb{--atb-ok:#22c55e;--atb-warn:#f59e0b;--atb-bad:#fb4849;
  --pb:#a4c9fe;--pb-bg:color-mix(in srgb,#4890fa 22%,transparent);--pl:#c9befe;--pl-bg:color-mix(in srgb,#9776fa 22%,transparent);--pm:#19e285;--pm-bg:color-mix(in srgb,#1fad66 22%,transparent);--pp:#feb68b;--pp-bg:color-mix(in srgb,#dc7019 22%,transparent);
  --pr:#feb1ad;--pr-bg:color-mix(in srgb,#fb4652 22%,transparent);--py:#feba28;--py-bg:color-mix(in srgb,#bb881a 22%,transparent);--pk:#feabcf;--pk-bg:color-mix(in srgb,#fb27a2 22%,transparent);--pt:#19daeb;--pt-bg:color-mix(in srgb,#1fa5b1 22%,transparent)}}
:root[data-theme="dark"] .atb{--atb-ok:#22c55e;--atb-warn:#f59e0b;--atb-bad:#fb4849;
  --pb:#a4c9fe;--pb-bg:color-mix(in srgb,#4890fa 22%,transparent);--pl:#c9befe;--pl-bg:color-mix(in srgb,#9776fa 22%,transparent);--pm:#19e285;--pm-bg:color-mix(in srgb,#1fad66 22%,transparent);--pp:#feb68b;--pp-bg:color-mix(in srgb,#dc7019 22%,transparent);
  --pr:#feb1ad;--pr-bg:color-mix(in srgb,#fb4652 22%,transparent);--py:#feba28;--py-bg:color-mix(in srgb,#bb881a 22%,transparent);--pk:#feabcf;--pk-bg:color-mix(in srgb,#fb27a2 22%,transparent);--pt:#19daeb;--pt-bg:color-mix(in srgb,#1fa5b1 22%,transparent)}
.atb-h{display:flex;align-items:center;gap:var(--s-2);padding:10px;flex-wrap:wrap;border-bottom:1px solid color-mix(in srgb,var(--line) 50%,transparent)}
.atb-s{display:flex;align-items:center;gap:var(--s-2);height:32px;padding:0 10px;border-radius:var(--r-field);background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line) 50%,transparent);color:var(--muted);flex:1;min-width:160px;transition:box-shadow var(--t-fast)}
@supports (corner-shape:squircle){.atb-s{corner-shape:squircle;border-radius:var(--r-field-sq)}}
.atb-s input{flex:1;min-width:0;border:0;outline:0;background:transparent;font:inherit;font-size:13px;color:var(--fg)}
.atb-s:has(input:focus){box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--field-line-strong) 50%,transparent)}
.atb-s input::placeholder{color:var(--muted)}
.atb-fl{display:flex;gap:var(--s-1);flex-wrap:wrap}
/* the filters are white chips with a dimmed grey edge; the picked one's edge is a step stronger */
.atb-fl .chip{background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--pick-line) 50%,transparent)}
@media (hover:hover) and (pointer:fine){.atb-fl .chip:hover{background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--faint) 50%,transparent)}}
.atb-sc{overflow-x:auto}
.atb table{width:100%;border-collapse:collapse;font-size:13px;min-width:560px}
.atb th{text-align:left;font-weight:500;font-size:12px;color:var(--muted);padding:9px var(--s-3);white-space:nowrap}
.atb th.r,.atb td.r{text-align:right}
.atb td{padding:9px var(--s-3);border-top:1px solid color-mix(in srgb,var(--line) 50%,transparent);white-space:nowrap}
.atb tr.row{cursor:pointer;transition:background .2s}
@media (hover:hover) and (pointer:fine){.atb tr.row:hover{background:var(--row)}}
/* a row that takes the focus fills the scrolling table, which would cut an outside ring: its ring is drawn inside */
.atb tr.row:focus-visible{outline-offset:-2px}
.atb tr.row.open{background:var(--wash)}
.atb .nm{display:flex;align-items:center;gap:9px;font-weight:500}
/* brand tiles sit a shade darker than the brand colour, so their white letter reads at 4.5:1 or more */
.atb .lg{width:24px;height:24px;border-radius:7px;display:grid;place-items:center;font-size:10px;font-weight:500;color:#fff}
.atb .hb{display:flex;align-items:center;gap:var(--s-2)}
.atb .hb i{width:54px;height:5px;border-radius:3px;background:var(--line);overflow:hidden;display:block}
.atb .hb i b{display:block;height:100%;border-radius:3px}
.atb .hb span{font-size:12px;color:var(--fg2)}
.atb tr.det td{padding:0;border-top:0}
.atb-note{display:flex;align-items:center;gap:10px;padding:10px var(--s-3) var(--s-3) 45px;font-size:12.5px;color:var(--fg2);white-space:normal}
.atb-note>svg{color:var(--accent-fg,var(--accent));flex:none}
.atb-note span{flex:1}
.atb-empty td{text-align:center;color:var(--muted);padding:var(--s-6)}
account-table.jsHTML · 26 lines
DM.def('account-table', (r, c) => {
  r.innerHTML = r.innerHTML.replace('{search}', I('search','xs'));
  const A = [
    ['Northwind','b','Enterprise',120,92,'$14,400','Expanding. Design team added 18 seats this month.'],
    ['Lumen Labs','l','Pro',14,48,'$672','Usage fell 40% after their lead designer left. Worth a check in.'],
    ['Fable & Co','m','Team',32,81,'$1,920','Steady. Asked about the Figma sync twice.'],
    ['Orbit','p','Pro',8,67,'$384','Trial of the agent ends Friday. 3 runs so far.'],
    ['Kite Health','r','Enterprise',64,88,'$7,680','Renewal in 6 weeks, champion is happy.'],
    ['Paper Plane','t','Team',21,34,'$1,260','Two failed payments. Billing contact bounced.']
  ];
  const tb = $('tbody', r), q = $('.atb-s input', r); let plan = '', open = null;
  const hc = h => h >= 80 ? 'var(--atb-ok)' : h >= 60 ? 'var(--atb-warn)' : 'var(--atb-bad)';
  function paint(anim){
    const s = q.value.trim().toLowerCase(), rows = A.filter(a => (!plan || a[2] === plan) && (!s || a[0].toLowerCase().includes(s)));
    tb.innerHTML = rows.length ? rows.map(a => `<tr class="row${open === a[0] ? ' open' : ''}" data-n="${a[0]}"><td><span class="nm"><span class="lg" style="background:var(--av-${a[1]});color:var(--av-${a[1]}-ink)">${a[0][0]}</span>${a[0]}</span></td><td><span class="tag ${a[2] === 'Enterprise' ? 'l' : a[2] === 'Pro' ? 'b' : 't'}">${a[2]}</span></td><td class="num">${a[3]}</td><td><span class="hb"><i><b style="width:${a[4]}%;background:${hc(a[4])}"></b></i><span class="num">${a[4]}</span></span></td><td class="r num">${a[5]}</td></tr>` + (open === a[0] ? `<tr class="det"><td colspan="5"><div class="fold"><div><div class="atb-note">${I('spark','xs')}<span>${a[6]}</span><button class="pill soft sm">Draft email</button></div></div></div></td></tr>` : '')).join('') : '<tr class="atb-empty"><td colspan="5">No accounts match</td></tr>';
    if (anim) stagger($$('tr.row', tb), 35);
    $$('tr.row', tb).forEach(tr => tr.onclick = () => { open = open === tr.dataset.n ? null : tr.dataset.n; paint(); const d = $('.atb-note', tb); if (d) enter(d); });
    const dr = $('.atb-note .pill', tb); if (dr) dr.onclick = e => { e.stopPropagation(); dr.innerHTML = '<span class="spin"></span>'; c.later(800, () => { dr.innerHTML = I('check','xs') + 'Draft ready'; }); };
  }
  q.oninput = () => paint(true);
  $$('.atb-fl .chip', r).forEach(ch => ch.onclick = () => { $$('.atb-fl .chip', r).forEach(x => x.classList.toggle('on', x === ch)); plan = ch.dataset.p; paint(true); });
  paint(true);
  // the preview opens one account's note on its own, unless you take over first
  c.auto(1200, () => { if (!open){ open = 'Lumen Labs'; paint(); enter($('.atb-note', tb)); } });
});
06

Questions

What people ask about it.

How do I add a "Draft email" button to the agent's note?
Pass action with a label, an optional doneLabel and an onClick that receives the account. Return a promise and the button spins until it resolves, then shows the done label. If it rejects, the button goes back to its label.
Can the agent open a row by itself?
Yes. Control open with the account's name and update it from your own state, for example when the agent flags an account. Set it to null to close all rows.
Does the table sort or format MRR for me?
No. Rows appear in the order of accounts, and mrr is shown as the string you pass, so format it first, like "$14,400".
Can I put links or formatting in the note?
Yes. note takes any React node, so it can hold links, bold text or other elements.
07

More in Charts and tables

Lightweight charts and tables for dashboards and reports, drawn in plain SVG and HTML.

Line chartTwo series over time with a hover readout and toggles.
Bar chartWeekly agent runs, split by outcome, with a range switch.
Donut chartWhere the agent's time went, by kind of task.
Funnel chartSignup to paid, with the biggest drop called out by the agent.
Top pagesMost visited pages with share of traffic and a quick sort.
Health scoreA design system score made of a few checks you can open.
Activity heatmapWhen the agent and the team were busy, by day and hour.
Stat cardsFour headline numbers with sparklines and a period switch.