The house standard for any multi-column list, in any app and any stack: sticky high-contrast header, click-to-sort, a per-column menu (filter by value, search, date range, derived columns), frozen first column, add-column that reaches the database, smart widths with Excel-style wrap, a 3-line cap per cell with hover reveal, resizable rows and columns, right-aligned tabular figures, and one date format. Use BEFORE building or changing any table, grid, or list with more than one column — dashboards, admin screens, inventory, reports, invoices, anything with rows. Also use when a table already exists and is being reviewed, extended, or restyled, and whenever someone says the list should behave "like Excel" or "like Sheets". Ships a browser-driven verification suite that is the done-gate: a styled dead control photographs exactly like a live one.
Resources
8Install
npx skillscat add criptso/data-table-standard Install via the SkillsCat registry.
Here's a thinking process: 1. **Analyze the Request:** - **Goal:** Write a 2-3 sentence plain-text summary in English. - **Subject:** An AI agent skill for a public software directory (based on the provided `SKILL.md` excerpt about `data-table`). - **Content to cover:** What the skill does, what problem it solves, when to use it.
Data tables
Published at https://github.com/Criptso/data-table-standard. Every edit to this directory is
committed and pushed automatically, so treat it as public the moment you save.
People who live in Sheets and Excel judge every other list against them. One that cannot be
sorted, filtered or reshaped forces them to export the data to work with it, so a generated app
fails the same way every time. Each rule here was missing at least once and cost a round trip.
Stylise them into the app's own visual language. This is a behaviour standard, not a
skin — a generic grey grid dropped into a designed product fails a different rule.
When this applies
Any list with more than one column, whatever the domain and whatever the stack. If you are
about to write <table>, a data grid, a .map() over rows, or reach for a grid component,
read this first and plan the schema for custom fields and per-user column preferences from the
start — retrofitting persistence is the expensive half.
The 19 rules
Header band, and its contrast is measurable. Distinct background, bold, tracked, sticky
on scroll, with a clear separator from the rows. Target ≥7:1 against its own background.
This is not taste: a table shipped with a header at 3.19:1 while its rows sat at 6.45:1, and
on a monitor turned down the header was simply invisible. Measure it; do not judge by eye on
a bright screen. The old form of this rule also demanded the header out-contrast the rows,
which quietly forbade a light band — once the band lightens, text on it necessarily
contrasts less. What replaces that half is rule 16: the band must be its own shade.Column titles centred, bold, UPPERCASE unless that table's brief says otherwise.
Click the title to sort, asc/desc, with a direction indicator, and the active column
visibly marked. Corollary that bites: if the default sort is on a field with no visible
column, nobody can tell how the list is ordered — either sort by a visible column or say
somewhere what the order is.A per-column menu, opened from a mark beside the title, Sheets style: tick/untick the
distinct values as a filter (with select-all and counts), search within the column, sort,
and derive a new column together with another column. The menu is where a column becomes
manipulable — not a second home for sorting. Two things the search inside it has to do, or
it is a second control rather than the same one: it matches the word the cell DISPLAYS,
not only the value the row stores — a status printed "In market" over a row that saysin_marketmust be findable by typing what is on screen — and it narrows the tick list as
you type, so the values and the search read as one thing. A list that does not move while
the rows do says the field is dead. And when the filters empty the body, the table states
"No rows match the filters" instead of showing nothing: an empty body reads as broken, and
the user cannot tell it from having no data at all.A "Columns" button that creates columns, not only hides them. A new user column becomes
a real field in the database (a migration or a custom-fields table), not a UI-only ghost.
Show/hide of existing columns lives here too — and hiding a column drops that column's sort
and its filter, because that header was the only thing on screen saying either one existed:
a list ordered by an invisible column, or trimmed to 14 of 35 rows with nothing explaining
why, is the same bug twice. A column the user made can also be removed, not merely
hidden: he made it, so he can unmake it, and hide-only leaves it in the stored document
forever with no control anywhere that deletes it. A base column has data behind it and only
hides.Drag & drop reordering, order persisted per user.
The first column freezes on horizontal scroll. "First" means the current position, not a
named column: if the user drags another column into position 1, that one freezes. With the
sticky header this is Excel's freeze panes, so the shared corner must sit above both.Three lines of text per cell, so no single long value blows up its row and the list stays
scannable. The cap bounds the height — it does not pad short rows out to three lines, and a
row of one-word cells should stay short. Hovering a
cell reveals the rest — no click, no drawer. Only cells actually clipped may react, or short
cells sprout tooltips for nothing. The user can still raise a row's height permanently, and
that height persists.Smart widths with Excel wrap. The table fits its window without cutting information:
short values (dates, numbers, ticks, ids) keep their natural width and never wrap; long text
yields first, shrinking and then wrapping over several lines. The user can always drag a
column wider, persisted — the safety net for when the algorithm guesses wrong.The menu mark is a CIRCLE, not a chevron. The sort indicator beside it is already a
triangle, and two triangles a few pixels apart read as one control. Make it slightly larger
than the arrow, and fill it while that column filters (○ → ●) so the header says two
different things in two different shapes: what orders the list, and what hides rows.Date columns filter by range, not by ticking values — every row tends to carry its own
date, so the list is as long as the table and answers nothing. Offer periods (last 7 days /
30 days / 3 months / 12 months) and an exact from/to on the browser's native date input. A
period is just a "from" N days back with the end open, so it writes into the from box and
stays adjustable. "To" covers its whole day.Every search box carries a ✕ inside its right edge, and the box itself is findable by a
hook —type="search"ordata-search— never by an English word in its placeholder: a
table written in another language is still a table that has to be checked., appearing only when there is text,
Escape does the same. It must restore the rows, not merely blank the field — a
button that empties the box while the filter stays applied is worse than no button, because
then you believe you removed it. Test those two things separately. Hide the browser's own
search-cancel glyph so there is one control, not two.Subtle vertical rules between header cells so each column's extent is visible. A short
dark tick, not a full-height line: at full height it turns the band into a grid and competes
with the rule underneath. Around half the cell's height, and quieter than that rule.Dates display as "d Mmm YYYY" —
4 Aug 2026,16 Aug 2026, no leading zero — whatever
the storage format is. Storage keeps its own shape; only the display is normalised. An
unparseable date shows as it arrived, never as "Invalid Date", and range filtering works on
the stored value rather than the printed text. The month is written in the interface's own
language — a Romanian table prints19 aug 2026and obeys this rule exactly as much as an
English one. What the rule fixes is the shape: day, month in letters, four-digit year. The
letters are the point, because03/04/2026is the 3rd of April to one reader and the 4th of
March to another, and a mixed-language interface is its own defect. Detection has to follow:Date.parseonly knows English months, so a check built on it silently SKIPS a localised
column — and a skipped check reads exactly like a passing one.Numeric columns align right, in a monospaced face, with
tabular-nums. Digits then sit
in the same column down the whole list, so two magnitudes are comparable at a glance instead
of being read digit by digit — which is the entire reason a price column exists. Proportional
figures ragged on the left are what a spreadsheet has never once done. The values are what
aligns right; the title above them is a title and stays centred with the rest (rule 2). Note
that either mechanism does the job —font-variant-numeric: tabular-numsorfont-feature-settings: "tnum"— and that one of them inherited frombodycovers the whole
table.The header band is its own shade, measured. Its background differs from the row
background by a visible step — ΔL* ≥ 5, which for a dark theme means roughly 10–20%
lighter than the rows. "A different colour" is not enough: two colours can differ in hue and
still read as the same surface on a screen turned down, which is where every one of these
header failures has happened. The direction is a design choice — a light band over dark rows
or the reverse — but the step has to be there, and it has to survive the theme swap.The column menu also places the text. Alongside sorting and filtering, the menu carries:
horizontal alignment — left, centre, right, chosen per column; and a wrap toggle,
deciding whether a value keeps to one line or runs onto several. Vertical alignment is not
a choice: cells are always centred vertically, so the eye tracks one line across a row of
mixed-height cells. None of it applies to the header, which is fixed: centred on both
axes, always wrapping (rule 2 makes it uppercase and bold, and a title that cannot wrap
forces a column wider than its data ever needed). A numeric column starts right-aligned
(rule 15) and a text column starts left-aligned; the menu is where that default is overridden,
and the choice persists with the rest of the layout (rule 6). Draw the four as icons in the
spreadsheet idiom — the lines of a paragraph, ragged the way the button aligns them, and a
return arrow for wrap — so the row costs a strip instead of a paragraph of buttons. An icon
button still needs anaria-labeland atitle: a drawing says nothing to a screen reader,
and nothing to the person hovering it either.The column menu wears the header band's colour, and opens beside its column. Two things
a spreadsheet gets right and a first draft usually does not. Painted in the row colour, the
panel floats on the table with nothing marking where it begins; painted in the band's colour
it reads as an extension of the header it came from. And a menu that opens over its own
column hides the very values the filter is about — put it against the column's right edge,
flipping to the left when the viewport runs out, and keep its top under the header band.When the time of day is information, the cell carries the clock — under the day, not
beside it. In a log, a journal, an audit trail, an order book, WHEN is data: the same
event at 06:00 and at 14:40 is two different events, and a day-only column hides that.
Two things make it work. Stack the clock under the date rather than appending it, because
a date column is usually a never-shrink column — appending widened one table 22px past its
container, which nobody saw by eye and the driven gate caught. And put a real space between
the two, not only a<br>:textContentjoins them, so15 Aug 202611:35Zis what a
checker parses, a screen reader announces and a copy-paste yields. The date's own shape
(rule 14) is unchanged — the clock is a second line, not a different format. Say which
clock it is (12:10Z, or the zone in the header) and keep ONE across the product; two
clocks that disagree by three hours are worse than none. A value naming no time of day gets
an empty second line, never a fabricated00:00— that invents precision the record does
not have. Detection must follow the format: a date checker that only knows the bare day
stops recognising the column the moment a clock joins it, and a column it cannot see is a
column it SKIPS — which reads exactly like a column that passed.
Traps that have already cost time
Read these before writing the code; every one shipped at least once.
- Dates and time zones. JavaScript reads
2026-06-28as UTC midnight butJune 28, 2026as
local midnight. Mixing the two shifts a day — enough to print the wrong date and to drop a
row from a range whose bounds came from the date picker. Reduce every date to its calendar day
anchored at UTC before displaying, sorting or comparing it. Then both range bounds are plainly
inclusive. Sorting dates as text lies too:July 7, 2023sorts after2026-06-03. - A missing date renders as EMPTY, never as a dash. Rule 14 covers the date that cannot be
read; this is the one that is not there. One "—" in a column of dates and the column stops
reading as dates at all — to the eye, and to anything that decides what a column holds by
looking at its values, this verifier included. An em dash elsewhere is fine and useful; in a
date column it costs the column its identity. Leave the cell blank. - The sticky header and the frozen first column must share ONE scrollport.
position: sticky
resolves against the nearest scrolling ancestor, so anoverflow-xbox that does not also own
the vertical scroll kills the header exactly where the freeze begins: the page scrolls instead
of the box, the band has nothing to stick to, and it leaves the top with a thousand pixels of
rows still on screen. The vicious part is that a computed-style check stays green throughout —position: stickyis set, the element simply has nothing to stick against. Give the box a
bounded height as well asoverflow: auto, and drive both planes at once, at several
viewport widths: a table that fits today starts scrolling the moment a derived column
arrives, and reading the CSS will never tell you which box moved. - A menu that re-renders closes itself. If clicking inside the menu rebuilds its innerHTML,
the clicked node is detached by the time the event reachesdocument, so an outside-click
handler sees a click from nowhere and closes the menu. Every tick closed it. Stamp the event
in the capture phase, before any handler can orphan the target. - A menu taller than the window hides its own lower controls. Cap the height and let it
scroll. - The value list must exclude its own column's filter. Otherwise unticking a value makes it
vanish from the list along with its rows, and there is no way to tick it back. Excel excludes
the column's own filter when building its list; do the same. - Persist a value filter as the EXCLUDED set, never as the accepted one. They look identical
on the screen where the ticks were made and diverge everywhere else. Store the accepted values
and every value that had not been seen at that moment is stranded outside the set forever:
filter Region, untick one City while Region is narrowing the list, then clear Region — every
other region's cities are gone, with nothing on screen to tick them back. Rows that arrive
later go the same way, which is worse, because nobody was there to watch it happen. A value
passes unless it is named in the excluded list; an empty list is no filter at all. - Sorting must not fire from the menu handle or the resize grip. They sit inside the header
cell, and if they share a listener node,stopPropagationwill not save you — guard on the
target. - The clamp cannot live on the
<td>. A cell has to staydisplay: table-cell; put the
line clamp on an inner box. - A debounced layout write dies with the document. Column order, widths and row heights are
written behind a debounce so a drag does not PUT once per pixel — and a page that unloads inside
that window takes the pending write with it. The column dragged one moment before a reload came
back forgotten, and the persistence looked broken when only its last 300ms were. Flush whatever
is queued onpagehideand onvisibilitychange(a backgrounded tab is frozen and may
never firepagehide), and send it withkeepalive, or the navigation cancels the request the
flush just started. Make the flush idempotent — whichever event comes first wins and the other
finds nothing queued. Found because this verifier reloads inside that window itself, so any
check of persistence is a check of the flush whether you meant it or not. - A derived column is a view, not data. Recompute it from the row; storing the result
duplicates values the source columns already own.
Reference implementation
reference/mechanics.md carries the fiddly parts as copyable snippets — freeze panes, the
clamp, the column menu, the date handling — taken from a vanilla-JS table over a plain<table> that implements all 15 rules and has hit every trap above.
There is deliberately no shared component yet: the rules are stack-agnostic, the code is not,
and one consumer does not justify a library. Extract one when a second table in the same stack
needs it — by then you will know what is worth abstracting.
After every change to a table's formatting — ask
Whenever you change how a list looks or behaves — a colour, a spacing, a new control, a
different date format, anything — ask the person you are working with whether the change
belongs in this standard before you finish the turn. They decide; you propose.
Ask it concretely. Not "should I save this?" but the rule as it would be written:
Add to the standard as rule 16: "a currency column names its unit once, in the title, never
in every row"? Or does it stay in this project only?
Say which way you lean and why — a change driven by one project's data is usually local; a
change driven by how someone reads a table is usually a rule. (Rule 15 arrived exactly this way,
as "numeric columns align right", from one dashboard where they did not.)
If he says yes, the change lands in three places or it will drift apart:
- this file — the rule, with the reason it exists
scripts/verify-table.mjs— a check for it, or the rule is a wish rather than a gate- wherever your own agent memory keeps the canonical list, if you keep one
A rule with no check in the verifier is the failure mode this standard exists to prevent.
The done-gate
Verify by driving the table, never by screenshot. Sorting, dragging, resizing and filtering
are exactly the controls that ship as markup and CSS with no listener behind them, and a dead
control photographs exactly like a live one.
npm install # once, in this directory
node scripts/verify-table.mjs <url> [config.json]It opens the page in headless Chrome, measures the header contrast, and drives every rule it can
find, printing PASS / FAIL / SKIP with the numbers it measured. Rules whose feature is absent
are reported as MISSING, not skipped quietly — that is the point of the gate. Selectors are
configurable for apps that do not use a plain <table>; run it with --help for the shape.
Run it under a second time zone as well when the table has dates:
TZ=America/Los_Angeles node scripts/verify-table.mjs <url>Report the output as evidence. "I checked it" is not evidence.
When you add a check, two things about the driving itself. One: the checks share a page, so
one that tidies up disarms the next. Rule 11's block used to close the column menu with Escape,
and the menu holds the only search box on the page — so rule 12 found nothing, reported SKIP, and
the two behaviours it exists for went untested for as long as the output looked clean. Every block
that needs a control open now opens it itself. Two: a check that cannot fail is decoration. Break
the behaviour on purpose, watch the line turn FAIL, and only then trust the PASS — and where a
behaviour genuinely cannot be driven headlessly, say so as an explicit SKIP with the reason,
never by leaving it out.