# A tibble: 6 × 5
site date vegetation_cover_pct species_richness soil_moisture_pct
<chr> <date> <dbl> <dbl> <dbl>
1 Site A 2023-04-01 51 13 28
2 Site A 2023-07-01 57 13 23
3 Site A 2023-10-01 55 14 26
4 Site A 2024-01-01 53 15 34
5 Site A 2024-04-01 60 15 28
6 Site A 2024-07-01 66 16 23
Exercise: Build a Dashboard
Learning Goal
Build a small, interactive Quarto dashboard from monitoring data: a row of headline numbers, a chart, and a table, all drawn from the same source. Along the way you will see how format: dashboard arranges a page into rows and columns.
This is what you are building toward:
The complete, ready-to-run version lives in the repository as dashboards/restoration-dashboard.qmd, so you can copy exact code from there or compare as you go.
The version above inherits this website’s custom fonts and colours. A fresh dashboard uses Quarto’s default styling, so expect the same layout with a plainer look.
The Data
You will use a small synthetic dataset, data/restoration-sites.csv. Each row is one survey of one site:
It has five columns:
site— the monitoring site (A to D)date— survey date (quarterly)vegetation_cover_pct— vegetation cover, in percentspecies_richness— number of species recordedsoil_moisture_pct— soil moisture, in percent
Turning a Document into a Dashboard
A dashboard is an ordinary .qmd file with one decisive setting: the output format in the YAML header is set to dashboard.
---
title: "My Dashboard"
format: dashboard
---Everything else, the Markdown, the code chunks, stays exactly as you know it. What changes is how Quarto arranges the content on the page, which is what the next section is about.
How a Dashboard Is Laid Out: Rows and Columns
This is the part that trips most people up at first, so it is worth getting clear before you build.
A dashboard fills the browser window and arranges your content into a grid of cards. Two heading levels control that grid:
##starts a new row###starts a new column inside the current row
By default a dashboard is row-oriented: rows stack from top to bottom, and cards placed in the same row sit side by side.
One row, two columns (charts side by side):
## Row
### Column
```r
chart_one
```
### Column
```r
chart_two
```chart_one and chart_two appear next to each other.
Two rows (charts stacked):
## Row
```r
chart_one
```
## Row
```r
chart_two
```Now chart_one is on top and chart_two below it.
Inside a single ## row you can leave out the ### headings: each code cell already becomes its own column. The ### headings just make the layout explicit (and let you set widths later).
If you would rather have your top-level ## headings create columns instead of rows, flip the orientation in the YAML:
format:
dashboard:
orientation: columns{r} with curly braces
The code cells on this page are written as ```r (no braces) purely so this website can display them without running them. When you copy them into your own dashboard, you need to add the curly braces, ```{r}, otherwise the code will not run and your dashboard will show the code as plain text instead of charts and numbers.
Build It Step by Step
Create a file my-dashboard.qmd in your project folder, and place the downloaded CSV in a subfolder called data next to it. The snippets below assume exactly this layout, so the path data/restoration-sites.csv is relative to your .qmd file; adjust it if you store the CSV somewhere else. And remember: add the curly braces around r in every code cell you copy.
1. Header and setup.
---
title: "Restoration Site Monitoring"
format: dashboard
execute:
echo: false
---
```r
#| label: setup
#| include: false
library(readr)
library(dplyr)
library(plotly)
library(DT)
sites <- read_csv("data/restoration-sites.csv") |>
mutate(date = as.Date(date))
latest <- sites |>
filter(date == max(date))
first <- sites |>
filter(date == min(date))
```echo: false hides the code, so the dashboard shows only results, and include: false keeps this setup cell from becoming a card of its own. The chunk loads the packages and the data, then prepares two subsets: latest keeps the most recent survey per site (used by the value boxes and the table), and first keeps the earliest one, so we can show how much cover has changed.
2. A row of headline numbers (value boxes).
A value box is a code cell with #| content: valuebox that returns a small list:
## Row
```r
#| content: valuebox
#| title: "Sites monitored"
list(value = n_distinct(sites$site), icon = "geo-alt", color = "primary")
```
```r
#| content: valuebox
#| title: "Surveys recorded"
list(value = nrow(sites), icon = "clipboard-data", color = "secondary")
```
```r
#| content: valuebox
#| title: "Cover change since 2023"
list(value = paste0("+", round(mean(latest$vegetation_cover_pct) - mean(first$vegetation_cover_pct)), "%"),
icon = "graph-up-arrow", color = "success")
```The value can be any small computation: n_distinct(sites$site) counts the unique sites, nrow(sites) counts the rows (one per survey), and the third box subtracts the mean cover of the first survey round from the mean of the latest one to get the change.
Render. Three coloured boxes appear side by side, three cards in one row. The icon names come from the Bootstrap Icons library, where you can browse all available icons and copy their names.
A card does not have to hold code. Write a short paragraph in its own row to add a description, exactly as the finished dashboard does at the top:
## Row {height="15%"}
Vegetation cover is recovering across all four restoration sites.3. Add the first chart: cover over time.
## Row
```r
#| title: "Vegetation cover over time"
plot_ly(sites, x = ~date, y = ~vegetation_cover_pct, color = ~site,
type = "scatter", mode = "lines+markers") |>
layout(xaxis = list(title = ""), yaxis = list(title = "Cover (%)"),
legend = list(title = list(text = "")))
```plot_ly() builds the chart from the sites data: x and y say which columns go on the axes, color = ~site draws one line per site, and type/mode make it a line chart with visible points (the ~ tells plotly to look the name up as a column). The layout() call afterwards just tidies up the axis titles and the legend.
Render and hover the lines to read values. That is plotly’s interactivity, and it needs no server.
4. Put a table beside it: the latest readings.
A dashboard card can hold a table just as easily as a chart. Give the row two explicit columns, the chart in one and a sortable DT table in the other:
## Row
### Column
```r
#| title: "Vegetation cover over time"
plot_ly(sites, x = ~date, y = ~vegetation_cover_pct, color = ~site,
type = "scatter", mode = "lines+markers") |>
layout(xaxis = list(title = ""), yaxis = list(title = "Cover (%)"),
legend = list(title = list(text = "")))
```
### Column
```r
#| title: "Latest readings by site"
latest |>
select(Site = site,
`Cover (%)` = vegetation_cover_pct,
`Richness` = species_richness,
`Soil moisture (%)` = soil_moisture_pct) |>
datatable(options = list(dom = "t"), rownames = FALSE)
```The table cell works in two steps: select() picks the columns to show and renames them into readable headers (backticks allow spaces like `Cover (%)`), then datatable() turns the result into an interactive table. The option dom = "t" shows only the table itself, without a search box or pagination, and rownames = FALSE drops the row numbers.
Render. The chart and the table now sit side by side under the value boxes. Click a column header in the table to sort it. That is your finished dashboard.
Stretch: a what-if scenario explorer (optional)
To give the dashboard the feel of an interactive tool, you can let readers switch between precomputed views with a tabset. We provide a second dataset, data/restoration-scenarios.csv, holding vegetation cover under three management scenarios (Baseline, Moderate, Intensive).
Download restoration-scenarios.csv
Here is what it produces. Switch the tabs above the chart to compare the scenarios:
Read it in your setup chunk and add a small helper function. The helper wraps the whole chart recipe once, so each tab later needs only a single line; filter() keeps just the rows of the chosen scenario:
scenarios <- read_csv("data/restoration-scenarios.csv") |>
mutate(date = as.Date(date))
plot_cover <- function(scenario_name) {
scenarios |>
filter(scenario == scenario_name) |>
plot_ly(x = ~date, y = ~vegetation_cover_pct, color = ~site,
type = "scatter", mode = "lines+markers") |>
layout(xaxis = list(title = ""),
yaxis = list(title = "Cover (%)", range = c(30, 95)),
legend = list(title = list(text = "")))
}Then turn one column into a tabset, with one tab per scenario:
### Column {.tabset}
```r
#| title: "Baseline (no action)"
plot_cover("Baseline")
```
```r
#| title: "Moderate restoration"
plot_cover("Moderate")
```
```r
#| title: "Intensive restoration"
plot_cover("Intensive")
```Because all three charts share a fixed y-axis (range = c(30, 95)), switching tabs shows the real difference rather than plotly rescaling each one. It feels like a what-if explorer, yet every view is precomputed, so the page is still fully static.
Adapt It to Your Own Data
The same structure works for any tidy dataset:
- swap
restoration-sites.csvfor your own CSV - change the column names in the value boxes, the
plot_ly()call, and the table - keep the layout (
##for rows,###for columns) as it is
Self-Check
- Does your dashboard render with
format: dashboard? - Do the three value boxes sit together in one row?
- Do the chart and the table sit side by side in a second row?
- Can you hover the chart, and sort the table by clicking a column header?