Charts and tablesAccount table
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callAccount 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.
| Account | Plan | Seats | Health | MRR |
|---|
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add account-tableReact
npx designme add account-table --reactThe 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.
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.
- Columns for account (with a coloured letter logo), plan tag, seats, health and MRR.
- Health is a bar coloured green from 80, amber from 60 and red below.
- Give a logo one of the avatar colours, like
var(--dm-av-b), and its letter takes that colour's own ink, white or dark, so it keeps its contrast. - A search box matches account names, and plan chips filter by plan; the chips default to the plans found in
accounts. - Clicking a row opens or closes the agent's
noteunder it; one row is open at a time. - An optional
actionbutton in the open note; if itsonClickreturns a promise the button spins, then showsdoneLabel. - Plan tags are coloured through
planTones, andemptyLabelshows when nothing matches.
- Each account row is focusable with tabIndex 0, exposes aria-expanded, and opens or closes with Enter or Space.
- The plan filter is a role="group" labelled "Plan", and each chip uses aria-pressed.
- The search input is labelled with its placeholder text, "Search accounts" by default.
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>
);
}
Props
Everything AccountTable takes.
| Prop | Type | What it does |
|---|---|---|
accountsrequired | AccountTableAccount[] | |
plans | string[] | The plan filters after All. Defaults to the plans in `accounts`, in the order they first appear |
planTones | Record<string, AccountTableTone> | The tag colour for each plan. Defaults to Enterprise purple, Pro blue, Team teal |
plan | string | The plan filter, or '' for All |
defaultPlan | string | |
onPlanChange | (plan: string) => void | |
query | string | The search text. Matches account names |
defaultQuery | string | |
onQueryChange | (query: string) => void | |
open | string | null | The account whose row is open, or null |
defaultOpen | string | 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` |
placeholder | string | |
emptyLabel | string | Shown when nothing matches the search and filter |
className | string |
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)); } });
});
Questions
What people ask about it.
- How do I add a "Draft email" button to the agent's note?
- Pass
actionwith alabel, an optionaldoneLabeland anonClickthat 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
openwith 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, andmrris shown as the string you pass, so format it first, like "$14,400". - Can I put links or formatting in the note?
- Yes.
notetakes any React node, so it can hold links, bold text or other elements.
More in Charts and tables
Lightweight charts and tables for dashboards and reports, drawn in plain SVG and HTML.