Matrix Rating
A survey matrix: statements down the side, one shared scale across the top, one native radio group per row, a pinned scale, answered-row progress, and a stacked fallback instead of sideways scrolling.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/matrix-rating.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "MatrixRating" component: a survey matrix with
statements down the side, one shared scale across the top, exactly one answer per row.
The only runtime dependency is a cn() class merger — no form library, no icon set, no
animation library.
Contract
- export interface MatrixRatingOption { value; label; hint?; short? }
value what gets stored; unique inside `scale`
label the column header AND the option half of every radio's accessible name
hint a small second line under the header, e.g. "1" or "never"
short compact wording for the stacked layout; falls back to label
- export interface MatrixRatingStatement { id; label; description?; required? }
description is described, never named; required is counted by showMissing.
- export type MatrixRatingAnswers = Record<string, string> // statement id -> value
SPARSE on purpose: a missing key is an unanswered row, which is the shape a
half-finished survey really has. Never materialise a full rows x scale product.
- export interface MatrixRatingChange { statementId; value: string | null }
- Default + named export of a forwardRef<HTMLDivElement, MatrixRatingProps> rendering a
<div>; MatrixRatingProps extends
Omit<React.HTMLAttributes<HTMLDivElement>, "onChange" | "defaultValue">, so the root
spreads the remaining native props.
- Props: statements; scale; value?; defaultValue?; onValueChange?(answers, change);
name?; label = "Rating matrix"; statementHeader?; allowClear = false;
disabled = false; showProgress = true; showMissing = false;
requiredHint = "Answer required";
progressLabel = (answered, total) => `${answered} of ${total} answered`;
missingLabel = count => "<count> required statement(s) still need an answer";
statementWidth = "16rem"; maxHeight?; stickyHeader = true;
emptyHint = "Nothing to rate."
- Controlled through `value`, uncontrolled through `defaultValue`. Both go through one
commit(statementId, next) that builds a NEW record — the caller's object is never
mutated — and reports { statementId, value } alongside it, so a host can log the one
answer that changed without diffing two records.
Behavior
- Every row is a NATIVE radio group: the row's inputs share a name
(`name[statementId]`, or a useId-derived one when no name prop is given). The browser
therefore owns the grouping, the "3 of 5" position announcement and the
one-Tab-stop-per-row order, and the table keeps its real headers. Do NOT put
role="radiogroup" on the <tr>: that drops the row out of the table's accessibility
tree and takes the column-header association down with it.
- The radio itself is sr-only inside a <label> that fills the cell, so the whole cell is
the hit target and the visual dot is a sibling span driven by `peer-focus-visible:`.
- Keyboard, with one rule holding it together: movement that stays inside a row commits
an answer, movement that crosses rows never does — auto-answering a statement someone
merely arrowed past would fabricate survey data.
Arrow Right / Left move one column, CLAMPED, and answer that row. Never cyclic:
one press too many must not swing "strongly agree" round to
"strongly disagree".
Arrow Down / Up same column, previous/next statement. Focus only.
Home / End first / last option of this row, and answer it.
Ctrl/Cmd + Home/End first cell of the first row / last cell of the last row.
Focus only.
Space answer the focused cell (taken over from the browser so the
inert guard, the clear toggle and the pointer path stay one
code path).
Delete / Backspace clear the row, when allowClear is on.
Everything routes through selectCell(row, col, toggle) -> commit(), so the callback,
the progress count and the announcement cannot drift apart.
- allowClear: a second press on the chosen option — pointer or Space — commits null and
the row goes blank again. Re-pressing a checked radio fires no change event, so read
it off the click and preventDefault there. Arrow keys pass toggle=false, otherwise
arrowing into the clamped edge would erase the answer it just made.
- Refusals, all quiet rather than thrown:
* an answer whose value is not in `scale` (a retired option, a typo, a key inherited
from Object.prototype) reads as UNANSWERED and does not count toward progress —
better a blank row than a confidently wrong one;
* an empty `scale` or an empty `statements` renders emptyHint instead of chrome
around nothing;
* `disabled` is the one inert state: focus and arrows still work, nothing commits.
Never the native disabled attribute — the browser blurs a disabled control to
<body>, and this one can go inert under the visitor's hands. Cancel the click's
default action instead and guard commit().
- showMissing is a verdict the HOST passes down, not validation the matrix invents:
while it is true, every required row that is still blank gets a destructive marker, an
aria-invalid radio and a described-by hint. It self-heals row by row as answers land.
- Focus and the pinned scale: focusCell() moves focus with preventScroll only when the
component's own box actually scrolls, then scrolls that box by exactly the overlap
with the sticky header — native focus scrolling knows nothing about sticky cells and
would park the focused row underneath the scale. Unbounded, the page is the scrollport
and the browser's own scrolling is already right.
- Nothing to clean up: no timers, no rAF, no listeners, no observers. The narrow layout
is a container query and the state is two derived counts, so unmounting frees the ref
map and that is the whole teardown.
ARIA contract
- A real <table>: sr-only <caption> = label, <th scope="col"> per option,
<th scope="row"> per statement, border-separate so a sticky cell keeps its own border.
- Each radio is aria-labelledby="<option header id> <statement label id>" — option
first, because arrowing across a row is the common move, so the part that changed is
spoken first and the statement anchors it.
- Below the breakpoint the <thead> is display:none, yet those ids keep naming the
radios: a node referenced by aria-labelledby contributes its text even when hidden.
That is what lets one DOM serve both layouts instead of shipping two copies of every
input (which would also submit every answer twice).
- description and the missing hint are aria-describedby, never part of the name;
`required` adds an aria-hidden asterisk plus an sr-only "(required)". No native
`required` attribute: the host owns the verdict, so the browser never has to point a
validation bubble at a clipped 1px input.
- The progress bar and its number are aria-hidden decoration. One always-mounted
sr-only role="status" carries the same fact once: the missing sentence when a required
row is blank, otherwise the progress sentence. A live region inserted at the same
moment as its text is unreliable, so it is rendered empty rather than conditionally.
- data-answered / data-total on the root and data-statement / data-value / data-missing
on every input, so hosts and e2e tests never have to read class names.
Rendering & styling
- Semantic tokens only, zero hex: bg-card shell and sticky header cells (a sticky cell
MUST be opaque or the scrolling rows show through it), border, bg-muted/40 for the
row wash, text-muted-foreground for secondary copy, border-primary + bg-primary/10 +
a bg-primary dot for the chosen option, text-destructive + border-destructive/50 for
a missing required row, hover:bg-accent/hover:text-accent-foreground on the other
cells, ring-ring for focus. bg-muted/30 tints the whole box while disabled.
- The row wash is hover: AND focus-within: on the <tr> — it is what stops an eye, or a
pointer, slipping a line on the way across a wide scale.
- One DOM, two layouts, driven by @container/matrix on the root: below @lg the table,
head, body, rows and cells drop to block/flex boxes, each row becoming a card whose
options wrap as chips carrying their own label (short ?? label); at @lg and up they
return to table / table-header-group / table-row / table-cell with table-fixed, and
the per-cell labels hide because the column headers are back. A container query, not a
media query: the same matrix may sit in a narrow panel on a wide screen.
- maxHeight is what makes the header pin — it turns the component's own box into the
scrollport. Without it the box carries NO overflow at all (any non-visible overflow
would make the never-scrolling box the sticky scrollport, and the header would simply
stop pinning while the page scrolls).
- Motion is decorative only: the dot scales 0 -> 1, the progress bar animates its width,
colours cross-fade. Every one of them carries motion-reduce:transition-none with the
end state still applied, so with motion off the matrix simply is what it is.
Customization levers
- The breakpoint IS the layout: `@lg/matrix:` is the whole reflow, and swapping it for
`@md/matrix:` or `@min-[38rem]/matrix:` in one find-and-replace moves the switch point.
Raise it when your labels are long, lower it when your scale is three columns wide.
- Copy: progressLabel and missingLabel are functions, so both sentences (and their
announcements) localise without touching the markup; requiredHint, statementHeader,
emptyHint and label are plain slots.
- Scale vocabulary: any length works — agreement fives, frequency fours, importance
threes. `hint` adds numeric anchors under the headers; `short` keeps the stacked chips
narrow when the full label is a sentence.
- Density: cells are py-3 / px-2 with a size-5 dot; drop to py-2 and size-4 for a long
questionnaire, or raise statementWidth past 16rem when statements run to two lines.
- Turn features off cleanly: showProgress={false} drops the bar (and its announcement),
stickyHeader={false} unpins the scale, allowClear leaves the row unclearable by
design, statementHeader defaults to an sr-only word so the corner cell can stay blank.
- Colour: the chosen cell follows bg-primary/10 + border-primary, so it inherits the
brand for free; point it at var(--chart-2) instead when the matrix sits beside charts
that already own that meaning.Concepts
- One native radio group per row — the row's inputs share a
name, so the browser hands over the grouping, the "3 of 5" position and the single Tab stop per row for free, while the table keeps realscope="col"/scope="row"headers. Paintingrole="radiogroup"onto the<tr>would buy the same group name at the price of the row leaving the table's accessibility tree, taking the column headers with it. - Crossing a row never writes — horizontal moves answer the statement they land on, vertical moves only move focus. Selection-follows-focus is right inside a group and disastrous across one: it would fill in every statement the visitor merely arrowed past.
- Hidden headers still name the control — the stacked layout hides the
theadwithdisplay:none, but each radio isaria-labelledbya span inside it, and a directly referenced node contributes its text even when hidden. One DOM serves both layouts; the two-copies alternative would submit every answer twice. - Container query, not a media query — the reflow reads the component's own width, so the same matrix stacks inside a narrow settings panel and stays a table on the page beside it. Stacking beats a horizontal scrollbar, which hides half the scale exactly when the labels matter most.
- Sticky needs a scrollport — the header only pins when
maxHeightturns the component's box into the scroller; any other overflow value on an unbounded box would silently kill the effect. Keyboard moves then scroll that box by the exact overlap, because native focus scrolling does not know the scale is floating above the row it just focused. - An unknown answer reads as blank — a stored value that is no longer in the scale checks nothing and counts as unanswered, which is the honest reading of a retired option; the same guard means a key inherited from
Object.prototypecan never light up a column.
Code Input
A monospace code field with an aligned line gutter, indent-aware Tab, bracket auto-close, regex syntax tinting and error markers fed by a diagnostics prop.
Transfer List
A dual listbox: two multi-selectable panes with per-side search and counts, move / move-all buttons between them, and a keyboard move that reports what travelled and where focus landed.