Quiz
Quiz checks understanding with one-answer and open-ended questions inside a lesson, then shows a score. Use it at the end of a lesson section.
Follows the live learner app (owner, 2026-10-14), restyled with Nuton tokens. Beta until design adds the live quiz to Figma and confirms the deviations listed under States.
Preview
Choose an option: it answers straight away. Use Next, Back and View results to go through the whole flow. Change the width to see the phone sizes.
Question 1 of 3
State: question 1, 0 answered, score 0
import { Quiz } from '@nuton/ui'
const questions = [
{
question: 'What best defines Human-Centered Design?',
options: ['A process focused on aesthetics', 'A design approach that prioritizes user needs', …],
correctAnswer: 1,
explanation: 'Human-Centered Design starts from people’s goals…',
},
…
]
<Quiz
questions={questions}
defaultState={savedState}
onStateChange={save}
/>Installation
Availability
@nuton/ui and @nuton/tokens are private workspace packages and aren't published yet (D4, D12). Inside the Nuton monorepo, add them as workspace dependencies.
pnpm add @nuton/ui @nuton/tokens @remixicon/reactnpm install @nuton/ui @nuton/tokens @remixicon/reactyarn add @nuton/ui @nuton/tokens @remixicon/reactimport '@nuton/tokens/tokens.css'
import '@nuton/ui/styles.css'Usage
import { Quiz, type QuizState } from '@nuton/ui'
const questions = [
{
question: 'What best defines Human-Centered Design?',
options: ['A process focused on aesthetics', 'A design approach that prioritizes user needs'],
correctAnswer: 1,
explanation: 'Human-Centered Design starts from people’s goals and behaviours.',
},
]
<Quiz
questions={questions}
defaultState={savedState} // restore the learner's progress
onStateChange={(state) => save(state)}
saveStatus={saveStatus} // 'idle' | 'saving' | 'saved' | 'failed'
/>- Use
Quizfor the whole flow. It handles answering, locking, feedback, Back and Next, and the results screen. - Clicking an option answers the question. There is no separate check step, so write options that are clearly different.
- Add an
explanationto every question. It turns a wrong answer into learning. - Save progress with
onStateChangeand show the request withsaveStatus. Restore it withdefaultState, or control it withstate. - Add
type: 'open'for a written answer. Give it amodelAnswer. Passevaluateto grade answers in your app or with AI; without it, learners compare with the model answer and mark themselves. - Use
QuizOptionorQuizResultson their own only for custom layouts.
Variants and sizes
Quiz has no visual variants. It fills its container and switches to the live phone sizes when the container is narrower than 480px (a container query, so it also works in side panels).
Lesson column
Question 1 of 3
<Quiz questions={questions} />Narrow container
Question 1 of 3
<div style={{ inlineSize: 375 }}>
<Quiz questions={questions} />
</div>| Part | Lesson column | Narrow (< 480px) |
|---|---|---|
| Card | Radius 20, 1px Stroke/Soft-200, Bg/White; padding 24 top, 28 sides, 24 bottom | Padding 20 top and bottom, 16 sides |
| Header | “Question n of N” Label/Small Sub-600; save status Label/X Small | Same |
| Progress | Progress, 3px, 12 above and 24 below | Same |
| Question | Title/H6 (Fraunces 20/28), 24 below | Fraunces 17/24, 16 below |
| Options | Gap 12; padding 14/16, gap 14, radius 12; circle 26; Paragraph/Medium | Gap 10; padding 12, gap 12; circle 22; Paragraph/Small |
| Feedback | Icon + Label/Large title, 12 below; explanation padding 20, radius 12, Bg/Weak-50 | Same |
| Footer | Border-top, 16 above; Back (ghost) and Next (filled pill), Button sm 36px | Same |
| Results | Padding 40/28; Title/H1 percent; 200px bar; Retake Button sm lighter | Padding 32/16 |
States and interactions
Quiz states
Question 1 of 3
Explanation
<Quiz questions={questions} defaultState={{ currentIndex: 0, answers: { 0: 1 }, score: 1, done: false }} />Question 2 of 3
Explanation
<Quiz questions={questions} defaultState={{ currentIndex: 1, answers: { 0: 1, 1: 0 }, score: 1, done: false }} />Question 1 of 1
Not saved — retrying on your next answer
<Quiz questions={questions} saveStatus="failed" />saveStatus | Text | Colour |
|---|---|---|
idle | Nothing | — |
saving | Saving… | Text/Sub-600 |
saved | Saved | Text/Sub-600 |
failed | Not saved — retrying on your next answer | red-700 |
Results tiers
80%
4 of 5 correct
60%
3 of 5 correct
20%
1 of 5 correct
Open-ended questions
Figma Quiz 292:13104 has an “Open ended” variant (3009:42182): the question, then a Text Area with “Type your answer here…”. The live quiz has no open questions yet, so marking, loading and error states are code-only. Check answer locks the answer (read-only, still focusable), then it is marked in one of two ways.
Question 1 of 1
Two or three sentences is enough.
<Quiz questions={[{ type: 'open', question, modelAnswer, explanation, guidance, minLength: 20, maxLength: 400 }]} />Question 1 of 1
Two or three sentences is enough.
const evaluate = async (answer, question, { signal }) => {
const res = await fetch('/api/grade', { method: 'POST', body: JSON.stringify({ answer, questionId }), signal })
if (!res.ok) throw new Error('Grading failed') // shows the error and Try again
return res.json() // { result: 'correct' | 'partial' | 'incorrect', feedback?: string }
}
<Quiz questions={questions} evaluate={evaluate} />Question 1 of 1
Two or three sentences is enough.
<Quiz questions={questions} evaluate={evaluate} /> // evaluate rejects onceQuestion 1 of 1
Model answer
Explanation
Compare your answer with the model answer. How did you do?
Question 1 of 1
Partly right
Model answer
Explanation
| Step | With evaluate | Without (self-assessment) |
|---|---|---|
| Writing | Textarea named by the question, placeholder, optional guidance and counter. Empty or shorter than minLength: an error under the field, focus stays in it. | |
| Check answer | Answer locks; the button shows a spinner and “Checking your answer…”; announced politely. | Answer locks; model answer and explanation appear; focus moves to the locked answer. |
| Marked | Verdict (Correct! / Partly right / Wrong!), feedback, model answer, explanation. | “I got it” → You got it (1). “Not quite” → Not quite (0). |
| Error | Red-700 message and Try again in place of the button (focus stays on it). The answer stays locked. | — |
Scoring
Multiple choice scores 1 or 0. Open answers score correct 1, partial 0.5 and incorrect 0. Change this with scoring, e.g. scoring={{ partial: 0.25 }}. The results screen shows the total, e.g. “2.5 of 3 correct”. quizScore(questions, answers, scoring) gives the same number outside the component.
Option states
Answering a question
- Next is disabled until the question is answered.
- Clicking an option (or Enter or Space on it) answers. “Correct!” or “Wrong! The correct answer is B.” is announced politely.
- All options lock. The right option and the learner's wrong pick get an icon and a caption; the others are dimmed.
- The feedback title and explanation appear, and the progress bar moves on.
- Back returns to answered questions, which stay locked. The score counts each question once. Unsent drafts of open answers are kept while the quiz is open.
- On the last question the button says View results. Retake starts again from question 1.
State tokens
Set on .nu-quiz-option: --nu-quiz-option-bg, -stroke, -fg, -marker-stroke, -marker-bg, -marker-fg, -caption, -radius, -marker-size and -focus-shadow. On .nu-quiz: --nu-quiz-radius, -pad-top, -pad-inline, -pad-bottom, -question-size and -options-gap.
Where code differs from the live app
- Grey #a4a4a4 text (“Question n of N”, save status, Back, option letters, the results message and “%”) is 2.49:1 on white. Code uses Text/Sub-600.
- Other options are dimmed with opacity .4 on live. Code uses Sub-600 text and a soft ring, so they stay readable.
- “🎉 Correct!” and “❌ Wrong!” use emoji. Code uses a check or cross icon in green-700 or red-700, so screen readers don't read “party popper”.
- The live progress track (#f7f7f7) is almost invisible on white. Code uses the Progress track (Bg/Soft-200).
- Raw sizes map to the nearest Nuton style: option text 15/22 → Paragraph/Medium 16/28; header 13 → Label/Small 14; save status 11 → Label/X Small 12; feedback title 17 → Label/Large 18; score 15 → Label/Medium 16; message 13 → Paragraph/Small 14; radius 14 → 12. Phone question size 17/24 is kept as a component token.
- Next, View results and Retake are Nuton Buttons (sm, 36px, Label/Small): disabled uses the Button disabled colours, Retake is the lighter style (Bg/Soft-100). Live copy “View Results” and “Retake Quiz” is sentence case.
- Motion uses Nuton durations: 200ms for the question (live 180ms), 300ms fade for the feedback (live animates height over 250ms). The results bar doesn't animate from 0. All motion is off with reduced motion.
- Code adds: a polite announcement with the right answer, screen-reader text on options (“Your answer, correct”), focus management, a focus ring and a disabled option.
Composition examples
In a lesson, with saved progress
The live lesson puts a “QUIZ · 4” label and a hatched rule above the card. That label is page markup, not part of Quiz; here it is hidden from screen readers because the quiz region already has a name. Each change is “saved” with a short delay to show the save status.
API reference
Quiz props
| Prop | Type | Default | Description |
|---|---|---|---|
questionsrequired | QuizQuestionData[] | — | Multiple choice: { question, options, correctAnswer, explanation?, id? }. Open: { type: 'open', question, modelAnswer?, explanation?, placeholder?, guidance?, minLength?, maxLength?, rows?, id? }. Content fields accept rich content. |
state | QuizState | — | Controlled state: { currentIndex, answers, score, done }, the live savedState shape. answers maps question index to an option index, or to { text, result?, feedback? } for open questions. |
defaultState | QuizState | initialQuizState | Initial state when uncontrolled, e.g. saved progress. |
onStateChange | (state: QuizState) => void | — | Called after every answer, Back, Next, View results and Retake. |
onAnswer | (detail: { questionIndex, optionIndex, correct }) => void | — | Called once per multiple-choice question, when the learner answers. |
evaluate | (answer, question, { questionIndex, signal }) => Promise<{ result, feedback? }> | — | Grades open answers ('correct' | 'partial' | 'incorrect'). Shows a loading state, then the verdict; a rejected promise shows an error with Try again. The signal aborts on Retake and unmount. Without it, learners self-assess. |
onOpenAnswer | (detail: { questionIndex, text, result, markedBy: 'evaluate' | 'self' }) => void | — | Called when an open answer is marked. |
scoring | Partial<{ correct, partial, incorrect }> | { correct: 1, partial: 0.5, incorrect: 0 } | Points per open-answer result. |
onComplete | (state: QuizState) => void | — | Called when the learner opens the results screen. |
onRetake | () => void | — | Called when the learner presses Retake. The state resets to question 1 either way. |
saveStatus | 'idle' | 'saving' | 'saved' | 'failed' | 'idle' | Save status in the header, announced politely. |
mascot | boolean | true | Nuri beside the verdict instead of the icon: happy when right, sad when wrong (encouraging for a partly right open answer), with the accent repeating. Also on the score card. false keeps the icons and a text-only card. |
labels | Partial<QuizLabels> | — | Overrides for every string: name, position(n, total), progressBar, progressValue, saving, saved, saveFailed, correct, incorrect, correctAnswerIs(letter), explanation, back, next, viewResults, letter(index), the option captions, the results texts, and for open questions answerLabel, answerPlaceholder, submit, grading, gradeError, retry, answerRequired, answerTooShort(min), characterCount(count, max), partial, modelAnswer, selfAssessPrompt, selfCorrect, selfIncorrect, selfMarkedCorrect, selfMarkedIncorrect, answerRevealed. |
aria-label / aria-labelledby | string | 'Quiz' | Names the quiz region. Name each quiz when a page has more than one. |
…section props | HTMLAttributes<HTMLElement> | — | className and other attributes go to the section. The ref points at it. |
QuizOption props
| Prop | Type | Default | Description |
|---|---|---|---|
letterrequired | string | — | Letter in the circle. Always part of the name, also when an icon replaces it. |
childrenrequired | ReactNode | — | Option content. |
result | 'correct' | 'incorrect' | — | Marks the option after answering with colour, an icon and a caption. |
chosen | boolean | false | Whether this is the learner's answer. Picks the caption. |
locked | boolean | false | After answering: aria-disabled, clicks ignored, still focusable. Without result it looks dimmed. |
labels | Partial<QuizOptionLabels> | — | correctAnswer, yourAnswer, yourAnswerCorrect, yourAnswerIncorrect. |
…button props | ButtonHTMLAttributes | — | onClick, disabled, tabIndex and others go to the button. The ref points at it. |
QuizResults props
| Prop | Type | Default | Description |
|---|---|---|---|
scorerequired | number | — | Correct answers. |
totalrequired | number | — | Number of questions. |
onRetake | () => void | — | Shows the Retake button. |
mascot | boolean | true | Nuri above the score: celebrating at 80% and up, proud at 50% and up, encouraging below. false leaves the card text only. |
labels | Partial<QuizResultsLabels> | — | resultsTitle, score(score, total), messageHigh, messageMedium, messageLow, retake. |
…div props | HTMLAttributes<HTMLDivElement> | — | Passed to the results group. The ref points at it. |
Accessibility
Keyboard
| Key | Behaviour |
|---|---|
| Tab | Moves through the options, then Back and Next. After answering only the chosen option stays in the tab order |
| Enter / Space | Answers with the focused option, or presses Check answer, I got it, Not quite, Back, Next, View results or Retake |
| Tab | In an open answer, Tab and Shift+Tab leave the textarea (Tab is not captured); Enter adds a new line |
Screen readers
The quiz is a region named “Quiz” (or your aria-label). Options are buttons in a group named by the question; each name starts with its letter (“B, Mitochondria”). After answering, “Correct!” or “Wrong! The correct answer is B.” is announced politely, and option names end with “Correct answer”, “Your answer, correct” or “Your answer, incorrect”. Locked options are aria-disabled. The save status is a polite status. The progress bar is named “Quiz progress”. Open answers: the textarea is named by the question, is required, and is described by the guidance, counter and any error. “Checking your answer…”, the verdict with feedback, errors and “Answer locked…” are announced politely; the open verdict stays readable later (it is not hidden).
Focus
After answering, focus stays on the chosen option, because options lock with aria-disabled rather than disabled. Back, Next and Retake move focus to the new question; View results moves it to the results group. Options show a primary border and halo. Open answers: an empty Check keeps focus in the textarea; while grading, focus stays on the busy button (and on Try again after an error); when the button goes away, focus moves to the locked, read-only answer.
Contrast and colour
All text is at least Text/Sub-600 (live #a4a4a4 fails at 2.49:1); dimmed options use Sub-600, not opacity. Correct and wrong use green-800 and red-700 text, and an icon plus a caption or screen-reader text, so nothing relies on colour alone (WCAG 1.4.1). Borders are at least 3:1. Forced colours keep borders, icons and focus.
Touch and pointer
The whole option is the target, at least 50px tall. Back and Next are 36px tall (WCAG 2.5.8). Motion is off with reduced motion.
You must: give each option distinct text, set the right correctAnswer index, and name each quiz when a page has more than one.
Live app mapping
Live (learning-quiz) | Code |
|---|---|
questions[].correct_answer | questions[].correctAnswer |
savedState · onStateChange | defaultState or state · onStateChange (same shape) |
saveStatus | saveStatus |
| Option button, results screen | QuizOption, QuizResults |
No open questions in learning-quiz (lesson practice uses a textarea + “Compare your thinking”; school activities grade short and long text on the server) | type: 'open' with evaluate or self-assessment |
| “QUIZ · 4” section label | Page markup (not part of Quiz) |
Guidelines and related components
Use Quiz for
- Checking understanding at the end of a lesson section
- Two to six options with one clear right answer
- Short written answers with a model answer
Don't use Quiz for
- Settings or preferences (use Radio)
- Recall practice with no marking (use Flashcard)
- Questions with several right answers (not supported)
Do and don't
Question 1 of 1
Explanation
Question 1 of 1