The learnr and gradethis packages in R are great. With them, instructors can use Shiny to create interactive web pages with checking and feedback. While moving teaching online during the pandemic, I rewrote the teaching materials for most of my data analytics courses into learnr tutorials, included the links in course materials, and started students there. In these tutorials, students could work at their own pace, trying out the R code they were learning and seeing how everything worked in a protective sandbox. Small, bite-sized pieces let them get a taste of things, and encouraging feedback made clear which parts needed more chewing.
The best part was that students even went out of their way to tell me that they enjoyed using these tutorials as a way to learn. That almost never happens!
The worst part was that it wasn’t cheap. Shiny apps and web pages like those made with learnr don’t just use HTML and CSS and JavaScript. They also use R, requiring a dedicated computer somewhere to run the code. There are multiple hosting options to choose from, but the free tiers all have compromises. With only a little regret, I dipped into my research funds to pay for something less compromised.
Times have changed. My research funds don’t exist, and the WebR project does, making it viable to run R code in a web browser—without a dedicated server. It’s a good time to adapt old tutorials to new technologies.1
1 This kind of change is already in the air. Andrew Heiss used the quarto-webr extension to create standalone versions of RStudio’s classic R Primers and interactive lessons in his Data Visualization course. Ted Laderas used the quarto-live extension in rewriting the R Bootcamp course. There are undoubtedly others who are also using or reworking learnr tutorials to take advantage of WebR’s advantages.
Unfortunately, some functionality like learnr’s progressive reveal gets lost in such a change. Progressive reveal typically limits what the learner sees in a tutorial, letting them focus on their current step without worrying about what’s next. But not all of Shiny’s magic is compatible with the mundane world.
1 CSS Stage Magic
Into the void steps CSS, with a bag of tricks big enough to give us something good! In CSS, we can make things disappear or turn invisible. The biggest difficulty is in figuring out how to point our wands.2
2 CSS selectors determine which part of a web page is being targeted by a certain style incantation. And finding the right selector can feel more like a mystic art than a science. The rvest documentation has a good introduction to web scraping including details on selectors, and the Library Carpentry and Scraping Bee both have explanations on choosing selectors. In the end, only practice makes proficient.
1.1 Revealing progressively
Because web pages made using quarto-live use the <section> tag to divide at each header, we can use CSS to find any section containing a <div> child with data-check="true" and lacking a <div> child with class="exercise-grade alert-success". We have to be a little specific to to target sections that contain these exercises as parents rather than as grandparents. Once we’ve narrowed our target to the right sections, we can adjust our aim to point to all the siblings that come after these sections:
style.css
/* hide later sections until exercises are complete */section:has(div[data-check="true"]):not(:has(div div div div div.exercise-grade.alert-success))~* {display:none!important;}/* reveal later sections when exercises are complete */section:has(div div div div div.exercise-grade.alert-success)~* {display:block;}
In place of these hidden sections, it’s probably helpful to indicate that something more is on the way. We can use CSS again to add an indicator after every section that needs to be completed before moving on. We want it to look nice, too, so we’ll add a little extra styling. Although we’re being a little sloppy here with our targeting, absolute positioning lets us stack message bubbles on top of each other to show only the the top one.
style.css
/* message pill to explain hidden sections */section:has(div[data-check="true"]):not(:has(div.exercise-grade.alert-success))::after {content:"(Complete to continue.)";color:#664d03;background-color:#fff3cd;border-color:#ffecb5;}/* general pill styling */section:has(div[data-check="true"]):not(:has(div.exercise-grade.alert-success))::after {display:inline-block;position:absolute;left:0;right:0;margin-inline:auto;border-radius:0.25rem;padding:0.5em;width:fit-content;}
In addition to the specific message and color of this message, I’m also defining a general “pill” shape for other messages, like advancing to the next section. Here’s how it looks so far:
1.2 Toggling sections
The CSS definitions defined above are almost good enough, but they aren’t perfect. If one section includes two exercises, for instance, then completing one exercise is enough to reveal subsequent sections. The current CSS specification—and the way exercises are currently presented in quarto-live—doesn’t make it possible to target with any more precision. So I fashioned a workaround.
Markdown (and thus Quarto) makes it easy to add HTML form elements in a web page. As we build our tutorials with progressive reveal, we can use form elements to introduce hard breaks before any overeager section. Something like the following is easy to read in a Quarto document and easy to target with CSS:
- [ ] Continue to the next section
In Quarto, this Markdown typically renders into the following HTML, with a task-list CSS class:
Toggles like these are easy to target in CSS, which we can use to style them as message pills that disappear when checked. On top of that, we can check their status to hide or show subsequent sections. Here are the updated parts of our CSS:
style.css
/* hide later sections until exercises are complete or box is checked*/section:has(div[data-check="true"]):not(:has(div div div div div.exercise-grade.alert-success))~*,section:has(ul.task-list input[type="checkbox"]:not(:checked))~* {display:none!important;}/* general pill styling */section:has(div[data-check="true"]):not(:has(div.exercise-grade.alert-success))::after,ul.task-list:has(input[type="checkbox"]:not(:checked)) {display:inline-block;position:absolute;left:0;right:0;margin-inline:auto;border-radius:0.25rem;padding:0.5em;width:fit-content;}/* action pill for next sections*/ul.task-list:has(input[type="checkbox"]:not(:checked)) {color:#084298;background-color:#cfe2ff;border-color:#b6d4fe;cursor:pointer;}/* (hover) */ul.task-list:has(input[type="checkbox"]:not(:checked)):hover, ul.task-list:has(input[type="checkbox"]:not(:checked)) label:hover {color:#055160;background-color:#cff4fc;border-color:#b6effb;cursor:pointer;}/* remove action pill on click */ul.task-list:has(input[type="checkbox"]:checked), ul.task-list input[type="checkbox"] {display:none;}
This works well! The resulting interaction looks like this:
1.3 Setting the table (of contents)
With all of this misdirection, we’ve neglected to do anything with Quarto’s table of contents. If it’s enabled in a document’s YAML header, then it’ll flounder helplessly on the edge, showing all of a tutorial’s sections but unable to link to those that remain disappeared. With CSS, we can hide the contents and reveal them at the end, after another check box is checked. This checkmark will need a little extra Markdown to differentiate it from the checkmarks that divide sections, but that compromise is worth it:
::: showtoc
- [ ] Show table of contents
:::
Adding the showtoc class to a surrounding <div> helps our CSS know right where to find this toggle. We only need to add the following definitions:
style.css
/* hide toc until end */#quarto-content:has(.showtoc input[type="checkbox"]:not(:checked)) nav#TOC {visibility:hidden;}/* show toc at end */#quarto-content:has(.showtoc input[type="checkbox"]::checked) nav#TOC {visibility:inherit;}
Here’s how it looks on the page:
1.4 Choosing progress
Things are looking pretty good now. But maybe we don’t want every exercise to limit a learner’s progression. Luckily, Quarto makes it easy to add a CSS class wait to certain code chunks, which we can use to further target our stage magic. In a Quarto code chunk, just add class: wait to the code chunk used for checking an exercise, as shown here:
To target only these excercises, we can update our CSS definitions. Any time we see div[data-check="true"], which targets every code-checking <div>, we can add the wait class by replacing it with this: div.wait[data-check="true"]. (I’m not going to include all of that code here, but I’ll share a final version at the end.)
2 Quiz Wizardry
The webexercises package is one of many options to replicate learnr’s quizzes, checking comprehension in plain language. Adding “webex.css” and “webex.js” to the project directly will make things available.
3 JavaScript Sorcery
Anyone who works through our tutorial should feel proud of the work they’ve done. But they should also be able to review it! After all of the effort we’ve gone through to hide things, wouldn’t it be helpful to undo all of our work and offer a version with everything visible?
The power of JavaScript makes it easy to add a URL trick to toggle display of our hidden elements. Add the following in a file sitting beside your tutorials:
With this clever trick, adding “?displayall” to the end of your tutorial’s URL will introduce the displayall ID to your page’s <body> tag; in every other circumstance, it will assign the ID of progressive.
With this final change, we can update our CSS so that it only hides elements when the page’s <body> tag has this progressive ID. Our CSS file is finally complete.
Show it all!
style.css
/* hide later sections until exercises are complete */body#progressive section:has(div.wait[data-check="true"]):not(:has(div div div div div.exercise-grade.alert-success))~*,body#progressive section:has(section div.wait[data-check="true"]):not(:has(section div div div div div.exercise-grade.alert-success))~*,body#progressive section:has(ul.task-list input[type="checkbox"]:not(:checked))~* {display:none!important;}/* reveal later sections when exercises are complete */section:has(div div div div div.exercise-grade.alert-success)~* {display:block;}/* general pill styling */body#progressive section:has(div.wait[data-check="true"]):not(:has(div.exercise-grade.alert-success))::after, body#progressive ul.task-list:has(input[type="checkbox"]:not(:checked)) {display:inline-block;position:absolute;left:0;right:0;margin-inline:auto;border-radius:0.25rem;padding:0.5em;width:fit-content;}/* message pill to explain hidden sections */body#progressive section:has(div.wait[data-check="true"]):not(:has(div.exercise-grade.alert-success))::after {content:"(Complete to continue.)";color:#664d03;background-color:#fff3cd;border-color:#ffecb5;}/* remove message after completed sections */section:has(div.exercise-grade.alert-success)::after {content:"";}/* action pill for next sections and toc */body#progressive ul.task-list:has(input[type="checkbox"]:not(:checked)) {color:#084298;background-color:#cfe2ff;border-color:#b6d4fe;cursor:pointer;}/* (hover) */body#progressive ul.task-list:has(input[type="checkbox"]:not(:checked)):hover, ul.task-list:has(input[type="checkbox"]:not(:checked)) label:hover {color:#055160;background-color:#cff4fc;border-color:#b6effb;cursor:pointer;}/* remove action pill on click or displayall */ul.task-list:has(input[type="checkbox"]:checked), ul.task-list input[type="checkbox"], body#progressive ul.task-list:has(input[type="checkbox"]:not(:checked)) {display:none;}/* hide toc until end */body#progressive#quarto-content:has(.showtoc input[type="checkbox"]:not(:checked)) nav#TOC {visibility:hidden;}/* show toc at end */#quarto-content:has(.showtoc input[type="checkbox"]::checked) nav#TOC {visibility:inherit;}
And here’s how it looks to make this change:
4 YAML Shenanigans
The last step involves preparing a Quarto document to use these changes. As the quarto-live documentation explains, it’s necessary first to install the extension in the project directory by typing the following into the terminal:
Terminal
quarto add r-wasm/quarto-live
Next, we set up the YAML header to use the right format, engine, format, style, and other things. To make it easier to upload each tutorial as a standalone HTML file, I’m also using Quarto’s option to embed-resources:
After the YAML, we have to remember to include the necessary include codes to use quarto-live and gradethis, making sure each is on its own line:
{{< include ./_extensions/r-wasm/live/_knitr.qmd >}}{{< include ./_extensions/r-wasm/live/_gradethis.qmd >}}
5 Charming Conclusions
The final product isn’t Shiny, but it’s far from dull. Try it out live here, and explore the repository on GitHub to see the files in their near-native habitat.3
3 A code snippet also helps simplify the process of preparing exercises.
Citation
BibTeX citation:
@misc{clawson2025,
author = {Clawson, James},
title = {Enchanting in-Browser Tutorials},
date = {2025-02-18},
url = {https://jmclawson.net/posts/quarto-live/},
langid = {en}
}