Likert Matrix
A survey grid: rate statements on one agree–disagree scale, stacked on phones and as a true grid on wider cards.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/likert-matrix.json'use client'
import { useId, useState } from 'react'
import './likert-matrix.css'
const S5 = ['Strongly disagree', 'Disagree', 'Neutral', 'Agree', 'Strongly agree']
const S7 = ['Strongly disagree', 'Disagree', 'Somewhat disagree', 'Neutral', 'Somewhat agree', 'Agree', 'Strongly agree']
export interface LikertMatrixProps {
items: string[]
scale?: string[]
title?: string
onSubmit?: (a: number[]) => void
fill?: 'dark' | 'light'
className?: string
}
/** Agree–disagree grid that adapts to container width. */
export function LikertMatrix({ items, scale = S5, title = 'Quick survey', onSubmit, fill = 'dark', className }: LikertMatrixProps) {
const [a, setA] = useState<(number | null)[]>(() => items.map(() => null))
const [sent, setSent] = useState(false)
const uid = useId()
const n = a.filter((x) => x !== null).length
const set = (i: number, v: number) => setA((x) => x.map((y, k) => (k === i ? v : y)))
if (sent) return <div className={['sd-lik', className].filter(Boolean).join(' ')} data-fill={fill}><p className="sd-lik__thanks" role="status">Thanks! {n} answers sent.<br /><button type="button" className="sd-lik__go" onClick={() => setSent(false)}>Edit answers</button></p></div>
return (
<div className={['sd-lik', className].filter(Boolean).join(' ')} data-fill={fill} style={{ ['--n' as string]: scale.length }}>
<div className="sd-lik__h"><b>{title}</b><span aria-live="polite">{n}/{items.length}</span></div>
<div className="sd-lik__prog" aria-hidden><i style={{ width: `${(n / items.length) * 100}%` }} /></div>
{items.map((q, i) => (
<div key={i} className="sd-lik__row" data-done={a[i] !== null || undefined}>
<p className="sd-lik__q" id={`${uid}-${i}`}>{q}</p>
<div>
<div className="sd-lik__sc" role="radiogroup" aria-labelledby={`${uid}-${i}`}
onKeyDown={(e) => { const d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0; if (d) { e.preventDefault(); const v = Math.max(0, Math.min(scale.length - 1, (a[i] ?? Math.floor(scale.length / 2)) + d)); set(i, v); (e.currentTarget.children[v] as HTMLElement).focus() } }}>
{scale.map((s, v) => <button key={v} type="button" role="radio" aria-checked={a[i] === v} aria-label={s} title={s} tabIndex={a[i] === v || (a[i] === null && v === 0) ? 0 : -1} className="sd-lik__r" onClick={() => set(i, v)}>{v + 1}</button>)}
</div>
<div className="sd-lik__ends" aria-hidden><span>{scale[0]}</span><span>{scale[scale.length - 1]}</span></div>
</div>
</div>
))}
<button type="button" className="sd-lik__go" disabled={n < items.length} onClick={() => { setSent(true); onSubmit?.(a as number[]) }}>{n < items.length ? `Answer ${items.length - n} more` : 'Submit'}</button>
</div>
)
}.sd-lik { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-lik[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-lik :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-lik :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-lik .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-lik { width: min(100%, 340px); padding: 16px; border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); container-type: inline-size; }
.sd-lik__h { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 4px; }
.sd-lik__h b { font-size: 15px; }
.sd-lik__h span { font-size: 12px; color: var(--mute); font-variant-numeric: tabular-nums; }
.sd-lik__prog { height: 3px; margin-bottom: 14px; border-radius: 99px; background: var(--soft); overflow: hidden; }
.sd-lik__prog i { display: block; height: 100%; background: var(--fg); transition: width 300ms; }
.sd-lik__row { padding: 10px 0; border-bottom: 1px solid var(--line); }
.sd-lik__row:last-of-type { border-bottom: 0; }
.sd-lik__q { margin: 0 0 8px; font-size: 13.5px; line-height: 1.4; }
.sd-lik__row[data-done] .sd-lik__q { color: var(--mute); }
.sd-lik__sc { display: grid; grid-template-columns: repeat(var(--n), 1fr); gap: 4px; }
.sd-lik__r { height: 34px; border-radius: 9px; box-shadow: inset 0 0 0 1px var(--line); font-size: 12.5px; font-weight: 600; font-variant-numeric: tabular-nums; color: var(--mute) !important; transition: background-color 120ms, transform 140ms cubic-bezier(0.3, 1.6, 0.4, 1); }
.sd-lik__r[aria-checked='true'] { background: var(--strong) !important; color: var(--on) !important; box-shadow: none; transform: scale(1.05); }
.sd-lik__ends { display: flex; justify-content: space-between; margin-top: 4px; font-size: 11px; color: var(--mute); }
.sd-lik__go { width: 100%; height: 42px; margin-top: 12px; border-radius: 12px; background: var(--strong) !important; color: var(--on) !important; font-weight: 600; font-size: 14px; }
.sd-lik__go:disabled { opacity: 0.35; }
.sd-lik__thanks { padding: 40px 10px; text-align: center; font-size: 14px; }
@container (min-width: 420px) {
.sd-lik__row { display: grid; grid-template-columns: 1fr calc(var(--n) * 40px); align-items: center; gap: 12px; }
.sd-lik__q { margin: 0; }
.sd-lik__ends { display: none; }
}
@media (prefers-reduced-motion: reduce) { .sd-lik *, .sd-lik *::before, .sd-lik *::after { animation: none !important; transition: none !important; } }Overview
On narrow widths each statement shows its own five-point scale; wider containers use a classic grid with column headers. The layout switches by container width.
Answered rows tick off in the progress line; Submit unlocks when every row is answered.
When to use
- Product research, course feedback, employee surveys.
When not to use
- A single question: use a rating scale.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/likert-matrix.json$
npx shadcn@latest add https://sundaeui.dev/r/likert-matrix.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/likert-matrix.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/likert-matrix.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy likert-matrix.tsx and likert-matrix.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { LikertMatrix } from '@/components/sundae/likert-matrix'
<LikertMatrix />Props
| Prop | Type | Default |
|---|---|---|
items*Statements. | string[] | — |
scaleScale labels. | string[] | — |
onSubmitSubmit callback. | (answers: number[]) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Each row is a radiogroup labelled by its statement; the scale labels are on every radio.
- Progress is announced.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-lik; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- ← / → within a row
- Change answer
FAQ
- Is Likert Matrix free?
- Yes. Likert Matrix is free for personal and commercial projects, with full source on this page.
- How do I install Likert Matrix?
- Run npx shadcn@latest add https://sundaeui.dev/r/likert-matrix.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Likert Matrix support keyboard and screen readers?
- Each row is a radiogroup labelled by its statement; the scale labels are on every radio. Progress is announced.
- Does Likert Matrix respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →Rating Scale
Forms · RefinedWas This Helpful
Forms · RefinedPoll Builder
Forms · RefinedOTP Field
Forms · Refined- Pro
Drop Zone
Forms · Refined - Pro
Onboarding Flow
Forms · Refined