Inputs

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/** One column of the shared scale. */
export interface MatrixRatingOption {
  /** Stored answer. Must be unique inside `scale`. */
  value: string
  /** Column header, and the option half of every radio's accessible name. */
  label: string
  /** Small second line under the header — a scale anchor such as "1" or "never". */
  hint?: string
  /** Shorter wording for the stacked layout; falls back to `label`. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/matrix-rating.json

Prompt

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 real scope="col" / scope="row" headers. Painting role="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 thead with display:none, but each radio is aria-labelledby a 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 maxHeight turns 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.prototype can never light up a column.

On This Page