This vignette is the mechanics reference for learnr2 tutorials. It covers how a classic learnr or tutorial.helpers tutorial turns into a learnr2 one, and the rules every learnr2 tutorial follows whether it was translated or written fresh. It assumes you have read Tutorials in the Age of AI, which covers what a tutorial is for and how it should teach. This one covers how the file is put together so that it renders and runs.
The single most important difference is that a learnr2 tutorial needs nothing installed to view. There is no Shiny app and no R server. A tutorial renders once to a static HTML page, and its code cells run in the reader’s browser via WebR. Most of the rules below follow from that.
Classic tutorials live at inst/tutorials/<name>/tutorial.Rmd in their package. Translate one into inst/tutorials/<name>/<name>.qmd, either in learnr2 itself for a bundled tutorial or in a separate content package that depends on learnr2. create_tutorial() also writes <name>.qmd. A file named tutorial.qmd works too, because available_tutorials() and run_tutorial() take the first .qmd in the directory, but prefer <name>.qmd.
Keep the source’s images/ and data/ directories next to the .qmd, exactly as they were. Relative paths resolve against the .qmd’s own directory.
| learnr or tutorial.helpers source | learnr2 equivalent |
|---|---|
output: learnr::tutorial, runtime: shiny_prerendered |
format: live-html and engine: knitr |
library(learnr) or library(tutorial.helpers) in a setup chunk |
delete it, and call functions as learnr2::fn() |
knitr::opts_chunk$set(echo = FALSE) in setup |
#| echo: false on every widget chunk, never a document-wide setting |
the info_section.Rmd child document |
a chunk calling learnr2::student_info() |
the download_answers.Rmd child document |
a minutes question() chunk and a learnr2::download_answers_button() chunk |
an exercise = TRUE chunk |
a {webr} cell with label, exercise and persist: true |
an ex1-setup chunk |
a {webr} cell with setup: true and the same exercise, or the setup inlined |
ex1-hint, ex1-hint-1, … chunks |
.hint fenced divs |
an ex1-solution chunk, ungraded |
a .solution fenced div |
an ex1-solution chunk, graded |
a {webr} cell with solution: true |
an ex1-check chunk with gradethis |
a {webr} cell with check: true, plus the _gradethis.qmd include |
question("...", answer("a", correct = TRUE)) |
learnr2::question("...", learnr2::answer("a", correct = TRUE)) |
question_text(...), with or without a model answer |
learnr2::question(..., type = "reflection") |
question_numeric(...) (the minutes question) |
learnr2::question(..., type = "reflection_editable", validate = "integer") |
a bare ### divider |
keep it, since it becomes a Continue stop with no heading |
allow_skip: yes in the YAML |
learnr2::tutorial_options(allow_skip = TRUE) |
knitr::include_graphics("images/x.png") |
 |
| prose sending the reader to RStudio or a local console to configure it | rewrite around the page, or drop it (see below) |
The sections that follow expand on the rows that aren’t obvious.
Replace the learnr header:
---
title: Some Title
output:
learnr::tutorial:
progressive: yes
allow_skip: yes
runtime: shiny_prerendered
---with:
---
title: "Some Title"
subtitle: "A short description, if the source had one"
format: live-html
engine: knitr
toc: true
---Drop tutorial: id:, output:, runtime:, progressive: and allow_skip: entirely. learnr2 reveals sections progressively on its own. If the source had allow_skip: yes and you want to keep that behaviour, see Sections and the Continue button below. Update title: and subtitle: so neither still describes content you have changed, such as “Tutorials in RStudio” for a tutorial that no longer needs RStudio.
Immediately after the header, include the quarto-live runtime:
{{< include _extensions/r-wasm/live/_knitr.qmd >}}Add {{< include _extensions/r-wasm/live/_gradethis.qmd >}} as well if any exercise is graded.
Delete the learnr setup chunk, with its library() calls and knitr::opts_chunk$set(). learnr2 functions are called with the learnr2:: prefix instead.
The two tutorial.helpers child documents become plain chunks. At the top, right after any introductory prose:
```{r}
#| label: student-information-1
#| echo: false
learnr2::student_info()
```At the bottom:
```{r}
#| label: your-answers-1
#| echo: false
learnr2::question(
"How many minutes, approximately, did it take you to complete this
tutorial? For example, an hour and a half would be 90 minutes.",
type = "reflection_editable",
validate = "integer"
)
```
```{r}
#| label: your-answers-2
#| echo: false
learnr2::download_answers_button(filename_prefix = "<name>")
```learnr2 has no numeric question type. validate = "integer" blocks Submit until the response is a whole number, without grading it against any one value, since any honest count is acceptable. There is no answer() because there is no correct number of minutes. This is the one question that stays reflection_editable, because there is no right answer to copy. After Submit it locks behind an Edit button, and Edit reopens it until the next Submit, the same cycle as student_info().
Every chunk needs a unique #| label: on its own line inside the chunk, never the old inline {r some-label} form. That applies to {r} chunks and to every {webr} cell, including plain demonstration cells.
For a question, the label is load-bearing. With no explicit id =, a question’s id is its chunk’s label, and that id is the key its saved answer is stored under and the name it appears under in the downloaded file. Renaming a question’s chunk resets that question for every student.
Follow the tutorial.helpers convention. Take the enclosing ## heading, lowercase it, collapse spaces and punctuation to single dashes, drop any leading number such as the 6. in ## 6. Quiz questions, and append a dash and a sequence number that restarts at 1 in each section. ## Quiz questions gives quiz-questions-1, quiz-questions-2, and so on. Count every labelled chunk in the section in document order, {r} and {webr} alike. One variant: the “our answer” chunk that follows an exercise’s question takes the question’s label plus -answer, as in introduction-3 and introduction-3-answer, so exercise numbers and label numbers stay in step.
The learnr setup chunk you deleted almost always set echo = FALSE for the whole document. Without it, every {r} chunk shows its own source above its output, so the reader sees the R call that builds each question instead of just the question.
Add #| echo: false to each chunk that renders a widget: student_info(), question(), quiz(), download_answers_button() and tutorial_options(). Don’t use a document-wide execute: echo: false in the YAML instead. {webr} cells are a pass-through engine that re-emits its own source as output by design, and a global override isn’t verified safe for them.
A learnr exercise chunk:
```{r exercise-1, exercise = TRUE}
```becomes a {webr} cell with three options:
```{webr}
#| label: functions-and-packages-2
#| exercise: ex_load_tidyverse
#| persist: true
______
glimpse(mtcars)
```label follows the convention above.exercise is any stable id. The bundled tutorial uses snake case, like ex_total, to keep it visibly distinct from the dashed label.persist: true saves the reader’s typed code in their browser so it survives a reload and appears in the downloaded answers. Without it, their code is saved nowhere.Keep whatever starter code the source had, whether empty, a leading comment, or tutorial.helpers’ ____ blanks. Drop chunk options that mean nothing in the browser: exercise.lines, exercise.timelimit, exercise.cap, exercise.df_print, exercise.blanks, message and warning.
A learnr ex1-setup chunk has two translations. Either use a {webr} cell with setup: true and the same exercise id, which runs invisibly before the reader’s code:
```{webr}
#| label: analysis-3
#| setup: true
#| exercise: ex_summarise
library(dplyr)
flights <- nycflights13::flights
```or put the setup lines at the top of the exercise cell itself, which is simplest for a line or two and keeps the cell self-contained.
Whichever you choose, remember that {webr} cells do not share state. Each exercise runs in its own environment, separate from every other exercise and from any earlier demonstration cell, and a reload wipes all of it. Anything an exercise needs must come from its own setup cell or its own body.
A hint chunk becomes a fenced div tied to the exercise id:
::: { .hint exercise="ex_load_tidyverse" }
Some hint text, or an inline code sample.
:::Numbered hints (ex1-hint-1, ex1-hint-2, …) become several consecutive .hint divs with the same exercise, which quarto-live reveals one at a time. A model answer becomes a .solution div, and its inner ```r block is required:
::: { .solution exercise="ex_load_tidyverse" }
```r
library(tidyverse)
glimpse(mtcars)
```
:::A graded exercise is the exception. If the exercise also has a check: true cell, its solution must be a {webr} cell with solution: true, not a .solution div:
```{webr}
#| label: analysis-4
#| exercise: ex_load_tidyverse
#| solution: true
library(tidyverse)
glimpse(mtcars)
```Only the cell form produces the hidden copy of the code that the grader compares against. With the div, submission fails with “No solution code was found.” The cell still shows the reader a “Show solution” button.
Translate a check chunk that uses gradethis, such as gradethis::grade_this_code(), into a {webr} cell with check: true and the exercise’s id. Add the _gradethis.qmd include after _knitr.qmd, and give the exercise a solution: true cell as just described. If the source check is trivial or absent, leave it out. An exercise doesn’t need grading to be useful.
learnr questions become learnr2::question() calls in a {r} chunk, with learnr2::answer() for each answer and learnr2::quiz() to group them. The chunk runs once, when the tutorial is rendered, not once per reader. These arguments carry over unchanged: allow_retry, random_answer_order for choice questions, correct and incorrect.
answer(..., correct = TRUE) options, keeps the default type = "auto". It becomes single or multiple choice by how many answers are correct. Use type = "text" for a typed exact-match answer.question_text(), the tutorial.helpers workhorse for both “explain in your own words” and CP/CR evidence questions, becomes type = "reflection". Its correct answer’s text, if it has one, is revealed as a model answer after the reader submits. It is never graded against their wording. Drop the options with no equivalent: try_again_button, rows, options, trim and placeholder.question_numeric() becomes type = "reflection_editable" with validate = "integer" and no answer(), as in the minutes question.answer_fn checkers, regular expressions and partial credit have no equivalent. Reduce them to plain answer() matches, or to a "reflection" question if the point was open-ended.Use "reflection", not "reflection_editable", for the old allow_retry = TRUE, try_again_button = "Edit Answer" idiom. learnr2 locks every submitted answer by default, and Tutorials in the Age of AI explains why: we show our answer right after the reader’s, and an editable box next to it invites copying.
A question() always carries its prompt in text. That string is shown in the question box, saved with the answer, and printed beside it in the downloaded file. Where tutorial.helpers put the prompt in prose above a question_text(NULL, ...) chunk, move the question into text and keep any longer set-up prose above the chunk.
Choice and "text" questions, and quiz(), suit mechanics tutorials and feature tours. A normal data-science tutorial teaches concepts through exercises and knowledge drops rather than quizzes, so expect most translated questions to be "reflection".
learnr2::question(type = "reflection", allow_image = TRUE) lets the reader paste a screenshot as their answer. Use it in four cases, and convert the source’s question whenever one applies:
show_file(), which proves the student has code, not that it drew the right picture. Ask for a screenshot of the plot instead, as in the plotting template in Tutorials in the Age of AI. This is very common in misc.tutorials. Keep show_file() for exercises whose result is text, such as a printed tibble or summary statistics..gitignore, commit and push”, ask for a screenshot of the repo’s page on GitHub showing the .gitignore file. Pick the screen so a skipped or botched step shows: if the screenshot would look the same without one of the steps, that step needs its own check. When translating, use this for a run of steps the source left unchecked. Don’t merge questions the source already asks separately, since that pacing was the author’s choice.When you convert a question, rewrite the prose around it to match. An instruction that says “copy” or “CP/CR” should now say what to screenshot. Any sentence explaining that pasted text may look messy goes, since it no longer applies. Keep the source’s own answer, if it had one, as the answer shown after Continue: a screenshot of the expected page, or our answer chunk drawing the expected plot.
The first screenshot question a student meets should say how to take a screenshot and how to paste it: Cmd + Shift + 4 on a Mac, Win + Shift + S on Windows, then click in the answer box and press Ctrl + V (Cmd + V on a Mac). Later ones can just ask for the screenshot.
Ask for one specific screenshot, never a hedged choice. “Paste a screenshot of your GitHub dashboard” works. “Paste a screenshot showing you’re signed in, for example your profile page or the account menu” leaves the reader guessing which one counts. If it isn’t obvious why that screen proves the step, say so.
If the prompt reads better as ordinary text on the page, write it there and pass show_text = FALSE. Still repeat the wording in text, which is kept in the saved data and read to screen-reader users.
Replace knitr::include_graphics() chunks with plain Markdown, , optionally with {width=90%}. There is no chunk to label or hide.
learnr2 reveals a tutorial one section at a time. Every ## and ### heading becomes a stop behind a Continue button, and you add nothing to opt in. A Continue button always sits at the end of something to read, never directly under a heading: clicking it reveals the next heading together with the text that follows it, which may or may not include the next exercise. In particular, a ## heading followed immediately by a bare ###, the way tutorial.helpers opens every topic, shows the heading and that first block together. Some details for translations:
### divider. A line holding only ### becomes a Continue stop with no visible heading and a button that just says “Continue”. tutorial.helpers puts two inside every exercise, one before the author’s answer and one before the knowledge drop, and both pauses survive the translation.### Hints and ### Solutions subsections that only wrap a .hint or .solution div are not separate stops. They appear with the exercise they belong to.learnr2::tutorial_options(allow_skip = TRUE) restores learnr’s allow_skip: yes, where clicking an entry unlocks everything up to it.student_info() form above it has been submitted. learnr2::tutorial_options(require_submission = FALSE) turns that off, which suits reference material.toc: false is supported. Without a sidebar, the Start Over button moves to the top of the tutorial, under the title.Call tutorial_options() at most once, in its own chunk with #| echo: false.
The webr: block in the YAML header is the only place WebR learns what to install in the reader’s browser. List every package that any {webr} cell uses, whether exercise, setup, check or demonstration. An exercise that calls library(dplyr) or nycflights13::flights needs both packages listed:
webr:
packages:
- dplyr
- nycflights13A {r} chunk is different. It runs once, when the tutorial is rendered on your machine or in CI, never in the reader’s browser. That gives it two legitimate jobs: rendering a learnr2 widget, and showing our answer, an #| echo: true chunk that runs real code so the reader can compare it with their own. Every package such a chunk uses must be installed wherever the tutorial is rendered, which for a content package means listing it under Suggests. Such chunks must not reach for the network. Never use a {r} chunk to set up objects for a {webr} cell, since the browser never sees what a {r} chunk did.
A learnr2 tutorial page needs no local R, no RStudio, and no R package beyond those listed for WebR. A translation can get every mechanical rule right and still break this, by leaving prose that sends the reader to “the R Console” to run something like tutorial.helpers::set_rstudio_settings() or rstudioapi::readRStudioPreference(). A reader who opened the tutorial from a link, with nothing installed, hits a wall there.
When translating, look for and rewrite or remove anything that assumes:
{webr} cell on the page.webr: list being available on the page.If the source tutorial’s whole point was configuring a local R installation, it has no learnr2 translation. Drop it rather than reproduce commands the reader can’t run.
This is a rule about the page, not about the student. A normal AI-era tutorial has the student work in their own repository and Quarto document, run show_file() in their own R Terminal, and paste the result into a "reflection" question. That is the evidence model in Tutorials in the Age of AI, and the page itself still needs nothing installed. Note that learnr2::show_file() is for that kind of local session. It isn’t available inside a {webr} cell, so changing tutorial.helpers::show_file() to learnr2::show_file() doesn’t fix prose that tells a reader to run it on the page.
To show an R chunk literally, as this vignette does, wrap it in a four-backtick block marked {verbatim}.
Inline code is harder. Writing `r x` in a sentence runs it, and wrapping it in an extra pair of backticks doesn’t help, because Quarto still evaluates it. Spell the backticks as HTML entities inside a <code> tag instead:
<code>`r x`</code>
That renders as the literal syntax, because there is no backtick in the source for Quarto to match.
Syntax that looks right can still be wrong in ways only the rendered page shows, such as R source printed above a question. After translating, run the static checks and a real render:
learnr2::check_tutorial("inst/tutorials/<name>") # labels, echo, persist, boilerplate, packages
learnr2::render_tutorials("inst/tutorials/<name>") # a real Quarto rendercheck_tutorial() catches a missing label, echo: false or persist: true, a graded exercise with a .solution div, a {webr} package missing from the webr: list, and missing boilerplate. Then open the rendered HTML in a browser and click through it.
run_tutorial() reads the installed copy of a package, not your working files. While editing a content package, load it with devtools::load_all() first, and run_tutorial() and available_tutorials() will read its tutorials from the source tree. Don’t commit render artifacts such as _extensions/, *.html, *_files/ and .quarto/ alongside the .qmd, because run_tutorial() adds the extension and renders on demand.
A content package should test every tutorial from tests/testthat/test-tutorials.R:
tutorials <- learnr2::available_tutorials(package = "<your package>", type = "quarto")
learnr2::check_tutorial(tutorials$path)
learnr2::render_tutorials(tutorials$path)Guard that test with testthat::skip_on_cran() and testthat::skip_if(is.null(quarto::quarto_path())), since it needs the Quarto command line tool.
A single learnr2 .qmd can live in a package whose other tutorials are classic learnr .Rmd files. learnr2’s own functions scan every tutorial directory and prefer .qmd, so learnr2::run_tutorial("<name>", package = "<host package>") finds it, renders it and serves it. R CMD check on the host package treats the file as data and ignores it.
Classic tooling, though, only ever looks for .Rmd:
learnr::available_tutorials() doesn’t list a directory holding only a .qmd, so learnr::run_tutorial() can’t launch it.tutorial.helpers::return_tutorial_paths() keeps only .Rmd files, so a host package whose tests render tutorials through it neither renders nor fails on the .qmd. It simply goes untested.So, when you do this, also add learnr2 to the host’s Suggests (and to Remotes if it isn’t on CRAN), add inst/tutorials/*/_extensions/ and .quarto to .Rbuildignore and .gitignore, and give the .qmd its own test, as shown in the previous section.