A learnr-style quiz question, rendered as a small self-contained, client-side interactive widget — no Shiny or R server required. Supports single-choice ("radio"), multiple-choice ("checkbox"), and free-text questions, graded entirely in the reader's browser.
Usage
question(
text,
...,
type = c("auto", "single", "multiple", "text", "reflection", "reflection_editable"),
correct = "Correct!",
incorrect = "Incorrect.",
allow_retry = FALSE,
random_answer_order = FALSE,
submit_button = "Submit Answer",
try_again_button = "Try Again",
edit_button = "Edit Answer",
id = NULL,
allow_image = FALSE,
allow_text = TRUE,
show_text = TRUE,
validate = c("none", "integer")
)Arguments
- text
Question prompt.
- ...
One or more
answer()objects. Optional for"reflection"/"reflection_editable"questions (seetype); required, with at least one markedcorrect, for every other type.- type
Question type.
"auto"(the default) picks"single"when exactly one answer is markedcorrect, and"multiple"otherwise. Other types:"text"– a free-text question, graded by comparing (trimmed, whitespace-collapsed, case-insensitive) the reader's response against theanswer()text(s)."reflection"– an ungraded free-response question. After submitting, the reader sees the model answer (thecorrectanswer()text(s)) and their own response is locked. If noanswer()is markedcorrect– including passing none at all, e.g. for a genuinely open-ended prompt like "how many minutes did this take?" with no right answer to demonstrate – nothing is revealed; the reader's response is still saved and locked exactly the same."reflection_editable"– like"reflection", but a submitted response can be reopened. Submit locks it behind an "Edit" button; Edit reopens it, with a note that the change isn't saved until the next Submit. The saved answer is always the last submitted one. Meant for answers with nothing to copy, such as a minutes-spent count: an ordinary"reflection"stays locked for good, so a reader can't reopen it and paste in the model answer they were just shown.
- correct
Message shown when the reader answers correctly. Unused for
"reflection"/"reflection_editable"questions.- incorrect
Message shown when the reader answers incorrectly. Unused for
"reflection"/"reflection_editable"questions.- allow_retry
Allow the reader to try again after an incorrect answer? Defaults to
FALSE. Unused for"reflection"/"reflection_editable"questions.- random_answer_order
Shuffle answer order each time the page loads? Defaults to
FALSE. Only applies to"single"/"multiple"questions.Button labels.
Button label shown while a submitted
"reflection_editable"question, or a"reflection"question withallow_image = TRUE, is locked; clicking it reopens the answer for editing (only the image, for the latter). Ignored otherwise.- id
Stable identifier for this question: it keys the reader's saved answer (see "Progress persistence" below) and is the
idthe question appears under in adownload_answers_button()submission. Defaults to the label of the{r}chunk thequestion()call sits in — which, followingtutorial.helpers'section-header-Nchunk-naming convention, is already a unique, readable identifier (## Quiz questions->quiz-questions-1,quiz-questions-2, ...). Falls back to a slug oftextwhen there is no usable chunk label, e.g. when printing aquestion()at the console. Passidexplicitly to pin it regardless of where the call sits.- allow_image
For
"reflection"/"reflection_editable"questions, let the reader paste an image (e.g. a screenshot) from their clipboard as their answer – not a file upload, just Ctrl+V/Cmd+V into the question. Defaults toFALSE. Ignored for other question types. Accepts PNG, JPEG, GIF, WebP, or BMP, up to 20MB, since the clipboard format varies by platform. Before it is stored, the image is scaled to at most 1600 pixels wide and re-encoded as WebP (JPEG in browsers that can't write WebP, such as Safari), dropping quality or size further if needed to stay under about 450KB. That keeps screenshots from filling the browser's storage, which is about 5MB for a whole site. If a save fails anyway, the page says so and the answer stays unsubmitted. A submitted"reflection"question withallow_image = TRUEgets anedit_button("Edit Answer"), which reopens only the image box so the reader can swap in a different screenshot; the question counts as unsubmitted until the new image is submitted. The text box stays locked.- allow_text
With
allow_image = TRUE, also offer a text box for a typed answer? Defaults toTRUE. Set it toFALSEfor a screenshot-only question: no text box is shown, the reader pastes into the image box, and Submit is refused until an image is pasted. Ignored whenallow_imageisFALSE.- show_text
Show
textas the question's prompt inside the widget? Defaults toTRUE. Set it toFALSEwhen the prompt is written as ordinary text on the page, just above the question, and should not be repeated inside the box.textis still required: it is kept (and read by screen readers, via visually hidden text) and is what the reader is reminded of if they try to download their answers without submitting this question.- validate
Client-side format check applied before the reader can submit a
"text","reflection", or"reflection_editable"answer."none"(the default) accepts anything."integer"requires the typed response to be a whole number (optionally signed, e.g.-3) – useful for a question like "how many minutes did this take?" where any honest number is fine, but free-form prose is not; see the"reflection"example below. Ignored (forced to"none") for"single"/"multiple"questions.
Value
A learnr2_question object. Printed as an interactive HTML
widget in a rendered Quarto document; at the console it opens a browser
preview in an interactive session and prints the HTML source otherwise.
Progress persistence
Once a reader submits an answer, it is saved in the browser's
localStorage (keyed by page URL and id) and restored on the next
visit. Because the default id is the enclosing {r} chunk's label,
renaming that chunk (or moving the question into a different one) resets
any saved answers for it — but editing only the question's wording
does not. Pass id explicitly to pin it.
localStorage is written straight to disk, not held only in memory, so
this survives closing and reopening the browser, and restarting the
computer – confirmed with automated tests
(tests/js/persistence.spec.js) that fully quit and relaunch a real
browser against the same profile, for both a file:// tutorial and one
served over HTTP. Two things it does not survive, by browser design
rather than anything learnr2 controls: private/incognito windows (their
storage is wiped when the window closes) and the exact page URL changing
– a tutorial opened from a different server, port or path starts fresh.
That is why run_tutorial() always serves on one fixed port and gives
every tutorial its own stable path, /<package>/<name>/: the address,
and so the saved answers, are the same on every launch, and no two
tutorials share them.
Every page also gets a "Start Over" button (nothing to opt into – it's
added by the same JavaScript that renders question()/student_info()).
It sits at the bottom of Quarto's TOC sidebar when the page shows one,
and otherwise – a tutorial rendered with toc: false, or any tutorial
on a screen too narrow for Quarto to show the sidebar – at the top of
the tutorial, directly under the title, before the first section.
Clicking it, after a confirmation prompt, clears every question()/
student_info() answer and every {webr} exercise's persisted code
(persist: true) for this page on this device, then reloads – a clean
slate, without needing to know that both live under different
localStorage key prefixes. It deliberately leaves alone the random
per-device id download_answers_button() embeds in a submission's
metadata, since that identifies this browser across every tutorial and
visit, not this one tutorial's progress.
Examples
question(
"What is 6 times 7?",
answer("42", correct = TRUE),
answer("36"),
answer("48"),
allow_retry = TRUE
)
#> <div class="learnr2-question" data-learnr2-question="eyJpZCI6IndoYXQtaXMtNi10aW1lcy03IiwidGV4dCI6IldoYXQgaXMgNiB0aW1lcyA3PyIs InR5cGUiOiJzaW5nbGUiLCJhbnN3ZXJzIjpbeyJ0ZXh0IjoiNDIiLCJjb3JyZWN0Ijp0cnVl LCJtZXNzYWdlIjpudWxsfSx7InRleHQiOiIzNiIsImNvcnJlY3QiOmZhbHNlLCJtZXNzYWdl IjpudWxsfSx7InRleHQiOiI0OCIsImNvcnJlY3QiOmZhbHNlLCJtZXNzYWdlIjpudWxsfV0s ImNvcnJlY3RNZXNzYWdlIjoiQ29ycmVjdCEiLCJpbmNvcnJlY3RNZXNzYWdlIjoiSW5jb3Jy ZWN0LiIsImFsbG93UmV0cnkiOnRydWUsInJhbmRvbUFuc3dlck9yZGVyIjpmYWxzZSwic3Vi bWl0TGFiZWwiOiJTdWJtaXQgQW5zd2VyIiwidHJ5QWdhaW5MYWJlbCI6IlRyeSBBZ2FpbiIs ImVkaXRMYWJlbCI6IkVkaXQgQW5zd2VyIiwiYWxsb3dJbWFnZSI6ZmFsc2UsImFsbG93VGV4 dCI6dHJ1ZSwic2hvd1RleHQiOnRydWUsInZhbGlkYXRlIjoibm9uZSJ9">
#> <noscript>This quiz question requires JavaScript.</noscript>
#> </div>
# No answer() at all -- a genuinely open-ended prompt with nothing to
# reveal after the reader submits.
question(
"How many minutes, approximately, did this take?",
type = "reflection_editable",
validate = "integer"
)
#> <div class="learnr2-question" data-learnr2-question="eyJpZCI6Imhvdy1tYW55LW1pbnV0ZXMtYXBwcm94aW1hdGVseS1kaWQtdGhpcy10YWtlIiwi dGV4dCI6IkhvdyBtYW55IG1pbnV0ZXMsIGFwcHJveGltYXRlbHksIGRpZCB0aGlzIHRha2U/ IiwidHlwZSI6InJlZmxlY3Rpb25fZWRpdGFibGUiLCJhbnN3ZXJzIjpbXSwiY29ycmVjdE1l c3NhZ2UiOiJDb3JyZWN0ISIsImluY29ycmVjdE1lc3NhZ2UiOiJJbmNvcnJlY3QuIiwiYWxs b3dSZXRyeSI6ZmFsc2UsInJhbmRvbUFuc3dlck9yZGVyIjpmYWxzZSwic3VibWl0TGFiZWwi OiJTdWJtaXQgQW5zd2VyIiwidHJ5QWdhaW5MYWJlbCI6IlRyeSBBZ2FpbiIsImVkaXRMYWJl bCI6IkVkaXQgQW5zd2VyIiwiYWxsb3dJbWFnZSI6ZmFsc2UsImFsbG93VGV4dCI6dHJ1ZSwi c2hvd1RleHQiOnRydWUsInZhbGlkYXRlIjoiaW50ZWdlciJ9">
#> <noscript>This quiz question requires JavaScript.</noscript>
#> </div>