Documents v0.9.0 · previews run the real runtime · experimental features are marked exp

Interactive quiz

Three questions with three options each. The first answer counts. After answering, the quiz shows whether it was right, marks the correct option, shows an explanation and enables Next. At the end it shows the score and a message, and "Try again" starts a fresh session. Question data and the answer key are static HTML (class="correct").

Live previewreal runtime
View source

How it works

  • focus:lines 56, 57, 58, 59Moves focus to the matched element when its value goes from false to true because of a user action. In 0.9 this declaration is the focus request: focus: when(--open).
  • attr:lines 14, 15, 16, 20…Derives one allowlisted HTML attribute of the matched element, such as disabled or aria-expanded, from a reactive value.
  • :when()lines 26, 27, 28, 29…Matches elements for which the condition is true, with states resolved from that element.
  • :nth-var()lines 8, 21, 25, 40…Matches the sibling whose one-based index equals the integer value of a custom property or state, read on the parent.

Several scalar states working together (current card, three answers, score) without arrays or objects. The first answer counts because answered options are natively disabled (0.5.3), derived from the quiz’s state.

Left to native HTML and CSS

  • :has(), ::before text and counters.
  • display: none for the other cards.
  • Native buttons, so Enter and Space work.

A fully native quiz is possible with radio buttons, :has(:checked) and CSS counters (counters can even count checked correct answers). It cannot lock the first answer, though, and paging needs radio-button tricks.

What would normally need JavaScript

Locking the first answer, the score, paging with a single Next button, and restarting.

Limitations

  • Rules repeat per question. CSS has no parameterized rules, and each question needs its own state name, because “Try again” must reset all answers from one place. No action can reset states owned by descendants.
  • First answer counts (0.5.3). The options of an answered question are disabled in the same task as the answer, so a second click is never dispatched (tested: three clicks in one task, real double click). The 0.5.1 conditional on: rules are gone.
  • Generated marker. The verdict, score and messages are real DOM text since 0.5.1. The ”✓ (correct answer)” marker is still generated content with alternative text.
  • Single choice only. No aggregation for multiple-answer questions.
  • Reset that cannot be expressed per option. The answers live on the quiz (--a1…--a3) because “Try again” can only reset states on its own ancestors. With attr:, options derive aria-pressed and disabled from those quiz states, so restart clears them too (tested). What still cannot be expressed: a reset of states owned by the options themselves, and returning focus to the first question.
  • Focus (0.6.0, focus:). Answering disables the focused option, and focus moves to Next. Next and “Try again” move focus to the new card’s heading (tabindex="-1"). There is no focus trap: Tab and Shift+Tab work normally.
  • Next is not disabled on purpose. A disabled Next would drop focus after every question, so Next stays focusable and shows a nudge (“Choose an answer first”) instead.

Accessibility

  • Buttons and visible focus.
  • Verdicts are DOM text in polite live regions (announcements not verified with screen readers).
  • One persistent Next button; after an answer focus moves to it, and after Next to the new question’s heading (0.6.0, tested).
  • Answered options are disabled and the chosen one has aria-pressed="true" (0.5.3). aria-pressed marks the choice, not a toggle; a radio group would be the stricter pattern.
  • Announcements of the new question and the verdict live region are not verified with a screen reader.

Implementation

2 files · 70 lines
style.cssreactive
/* ---- state: all scalar, owned by the quiz ----------------------------------------------------   --q: current card (1–3 questions, 4 = result). --aN: the option chosen for question N   (its position, 0 = not answered yet). --score: correct first answers. */.quiz { --q: state(1); --score: state(0); --a1: state(0); --a2: state(0); --a3: state(0); --nudge: state(false); }/* One card at a time. Hidden cards are display: none (not focusable, not announced). */.quiz > .cards > .card { display: none; }.quiz > .cards > .card:nth-var(--q) { display: block; }/* ---- answering: the first answer counts ------------------------------------------------------   attr: (0.5.3): once a question is answered, its options are natively disabled, so a second   click (even in the same task) is never dispatched and cannot score again. The chosen option   reports aria-pressed="true", the others "false". */.q1 .opt { on: click(set(--a1, sibling-index()), set(--nudge, false)); attr: disabled when(--a1 > 0); }.q2 .opt { on: click(set(--a2, sibling-index()), set(--nudge, false)); attr: disabled when(--a2 > 0); }.q3 .opt { on: click(set(--a3, sibling-index()), set(--nudge, false)); attr: disabled when(--a3 > 0); }.q1 .opt.correct { on: click(set(--a1, sibling-index()), increment(--score), set(--nudge, false)); }.q2 .opt.correct { on: click(set(--a2, sibling-index()), increment(--score), set(--nudge, false)); }.q3 .opt.correct { on: click(set(--a3, sibling-index()), increment(--score), set(--nudge, false)); }.question .opt { attr: aria-pressed "false"; }.q1 .opt:nth-var(--a1), .q2 .opt:nth-var(--a2), .q3 .opt:nth-var(--a3) { attr: aria-pressed "true"; }.question .opt:disabled { cursor: default; color: inherit; }/* ---- feedback: the chosen option, the correct one, the verdict and the explanation ---------- */.q1 .opt:nth-var(--a1), .q2 .opt:nth-var(--a2), .q3 .opt:nth-var(--a3) { border-color: #b42318; background: #fef3f2; }.quiz:when(--a1 > 0) .q1 .opt.correct,.quiz:when(--a2 > 0) .q2 .opt.correct,.quiz:when(--a3 > 0) .q3 .opt.correct { border-color: #166534; background: #dcfce7; }.quiz:when(--a1 > 0) .q1 .opt.correct::after,.quiz:when(--a2 > 0) .q2 .opt.correct::after,.quiz:when(--a3 > 0) .q3 .opt.correct::after { content: " ✓" / " (correct answer)"; }.question .explain, .question .verdict { display: none; }.quiz:when(--a1 > 0) .q1 :is(.explain, .verdict),.quiz:when(--a2 > 0) .q2 :is(.explain, .verdict),.quiz:when(--a3 > 0) .q3 :is(.explain, .verdict) { display: block; }.question .verdict { font-weight: 600; color: #b42318; }/* Real DOM text (text:, 0.5.1): the verdict is chosen by the cascade. */.question .verdict { text: "Not quite."; }/* The chosen option is the correct one: native :has() over the :nth-var() marker. */.q1:has(.opt.correct:nth-var(--a1)) .verdict,.q2:has(.opt.correct:nth-var(--a2)) .verdict,.q3:has(.opt.correct:nth-var(--a3)) .verdict { color: #166534; text: "Correct!"; }/* ---- navigation: one persistent Next button (keeps keyboard focus) ------------------------- */.quiz > .nav > .next { on: click(set(--nudge, true)); }.quiz:when((--q == 1 and --a1 > 0) or (--q == 2 and --a2 > 0) or (--q == 3 and --a3 > 0)) > .nav > .next {  on: click(increment(--q), set(--nudge, false));}.quiz:when(--q == 4) > .nav { display: none; }.quiz > .nav > .nudge { display: none; margin: 0; color: #b42318; }.quiz:when(--nudge) > .nav > .nudge { display: block; }/* ---- focus (focus:, 0.6.0) ---------------------------------------------------------------------   Answering disables the focused option, so focus moves to Next (the next step); Next and   "Try again" move it to the new card's heading (tabindex="-1" in the HTML). */.quiz > .nav > .next { focus: false; }.quiz:when((--q == 1 and --a1 > 0) or (--q == 2 and --a2 > 0) or (--q == 3 and --a3 > 0)) > .nav > .next { focus: true; }.quiz > .cards > .card > h2 { focus: false; }.quiz > .cards > .card:nth-var(--q) > h2 { focus: true; }.quiz > .cards > .card > h2:focus:not(:focus-visible) { outline: none; }/* ---- score and result: real DOM text (text:, 0.5.1) --------------------------------------- */.quiz > .scoreline { color: #555; margin: 0 0 8px; }.quiz .sc { text: var(--score); }.quiz .qn { text: var(--q); }.quiz:when(--q == 4) > .scoreline > .progress-text { display: none; }.result .final { font-size: 22px; font-weight: 700; }.result .praise { text: "Review the explanations and try again."; }.quiz:when(--score == 3) .result .praise { text: "Perfect score!"; }.quiz > .cards > .result > .restart { on: click(set(--q, 1), set(--score, 0), set(--a1, 0), set(--a2, 0), set(--a3, 0), set(--nudge, false)); }
state()Function · hover any red tokenMakes each matching element the owner of an independent state with an initial value.Reference →

Source: the library's examples/interactive-quiz/. style.css from its first section comment; the layout above it runs but is not shown. Written in the 0.8 syntax (on:, bind:, text:, attr:), which 0.9.0 still runs; see Migrating from 0.8 for the 0.9 forms.

Related

Score history

Two independent scoreboards. Each shows the current score, the previous score, the last change (signed), the direction (up or down) and a short visual bump or dip on every change. +1, −1 and Reset.

0.8 syntaxincrement()decrement()set()previous()

Accessible tabs

Two independent tab sets. Each tab reports a real aria-selected, inactive panels are hidden, one tab is disabled in the HTML, and the "Billing" tab is disabled until a checkbox in the first panel is checked.

0.8 syntax:nth-var()prevent()key()focus:

Accessible toggle button

A pressed-state toggle button and a disclosure button. Their aria-pressed and aria-expanded attributes are real DOM attributes, kept in sync with Reactive CSS state.

0.8 syntaxstate()bind()on:click()