Design once, use anywhere

Practical Quarto: Patterns, templates, and tooling @ posit::conf(2026)

Charlotte Wickham
&  
Mine Çetinkaya-Rundel

One look, every output

A theme gets you started

Quarto ships 25 Bootswatch themes:

default, cerulean, cosmo, cyborg, darkly, flatly, journal, litera, lumen

lux, materia, minty, morph, pulse, quartz, sandstone, simplex, sketchy

slate, solar, spacelab, superhero, united, vapor, yeti, zephyr

report.qmd
format:
  html:
    theme: flatly

But you don’t want a theme…

You want your theme

Branded website for Bureau of Regional Statistics, light mode. Branded website for Bureau of Regional Statistics, dark mode. Branded slide deck for Bureau of Regional Statistics.

The same look, in how many places?

  • theme: — one SCSS file for html, another for revealjs
  • include-in-header: — fonts, again
  • mainfont:, colorlinks: — pdf has its own names
  • your plots — theme_*() in R, theme() in Python
  • and again in the next project

Five descriptions of one brand, each drifting on its own schedule.

_brand.yml

The brand.yml logo: a paintbrush inside a circle with a purple to orange gradient, next to the words BRAND YML.

Unified branding with a simple YAML file


One file, written once, applied across Quarto formats — and beyond Quarto.

Two steps

  1. Describe the brand in one _brand.yml.

  2. Quarto applies it to almost every format.


No format-specific theming to write. No SCSS – unless you want to tweak bits of your document/website/slide deck brand doesn’t control via a YAML field.

Who reads _brand.yml?

flowchart LR
    by{brand.yml}
    by-->quarto[Quarto]
    quarto-->quarto-html[html]
    quarto-->quarto-typst[typst]
    quarto-html-->quarto-websites[Websites]
    quarto-html-->quarto-presentations[Presentations]
    quarto-html-->quarto-dashboards[Dashboards]
    quarto-html-->quarto-emails[Emails]

    by-->R
    R-->r-bslib["{bslib}"]
    r-bslib-->r-thematic["{thematic}"]
    r-bslib-->r-shiny["Shiny for R"]
    r-bslib-->r-pkgdown["pkgdown"]

    by-->Python
    Python-->py-brand_yml["brand_yml"]
    py-brand_yml-->py-shiny[Shiny for Python]
    py-brand_yml-->py-plots[seaborn, matplotlib, plotnine, ...]

Five keys

  • meta — who the brand belongs to: name, links
  • logo — the image files, at a few sizes
  • color — the palette, and the roles colors play
  • typography — fonts for base, headings, links, code
  • defaults — escape hatches for specific output formats

_brand.yml

_brand.yml
meta:
  name: Bureau of Regional Statistics
  link:
    home: https://example.gov/brs

logo:
  images:
    mark:
      path: brs-logo.svg
      alt: Bureau of Regional Statistics logo featuring a stylized bar chart
  small: mark

color:
  palette:
    forest: "#1b5e4f"
    ink: "#1f2933"
    slate: "#52606d"
    sand: "#f5f2ea"

  background: white
  foreground: ink
  primary: forest
  secondary: slate
  tertiary: sand

typography:
  fonts:
    - family: Atkinson Hyperlegible
      source: google
      weight: [400, 600]

  base:
    family: Atkinson Hyperlegible
    weight: 400

  headings:
    family: Atkinson Hyperlegible
    weight: 600

  link:
    color: primary
    decoration: underline
  • meta is documentation for humans only, Quarto doesn’t use it.

  • In logo, paths resolve relative to the brand file.

  • color works in two steps: name your colors in palette, then say what role each one plays, e.g., primary: forest, not primary: "#1b5e4f".

Naming, then roles

_brand.yml
color:
  palette:
    forest: "#1b5e4f"   # a name for a hex code
  primary: forest       # a job for a name
  • In SCSS these arrive as $brand-forest and $primary.

  • In Typst these arrive as brand-color.primary.

defaults: the escape hatch

For the thing brand.yml has no word for:

_brand.yml
defaults:
  bootstrap:
    defaults:
      link-decoration: underline
      navbar-fg: "#fff"
    rules: |
      h2 {
        border: none;
      }
  • html and dashboard only.

  • Also takes uses, functions, mixins.

Applying it

  1. Save _brand.yml in the root of your project.
  2. Render.

Quarto finds it, and applies it to every document in the project that supports it.


When it isn’t at the root: Use brand in a document or in _quarto.yml:

report.qmd
title: Regional Employment Indicators
format: html
brand: ../brand/brs.yml

When you want to turn it off: Use brand: false:

report.qmd
title: Regional Employment Indicators
format: html
brand: false

Using brand values in document

Use the brand shortcode, {{< brand color primary >}} anywhere a value goes — a div attribute, a background color, inline text:


slides.qmd
## Findings {background-color="{{< brand color primary >}}"}

Your turn 1: write a brand by hand

Exercise: 02-brand/01-first-brand

  1. Render report.qmd first, so you have something to compare against.

  2. Create _brand.yml in the same folder and name the agency’s colors:

color:
  palette:
    forest: "#1B5E4F"
  1. Render. What happened?
  1. Now give the colors jobs:
  foreground: ink
  primary: forest
  1. Render. Then add typography with Atkinson Hyperlegible and render again.

More details in exercise README.

Once done, discuss with your neighbors: The palette colors are the agency’s. The role names are Quarto’s. Which of the two would you expect to change more often?

Your turn 1: takeaways

  • No _quarto.yml, no theme:, no css:, no need to name _brand.yml in report.qmd, just place in the same directory.

  • Naming a color does nothing, assigning it does: palette names colors. The keys below it — foreground, background, primary, secondary — assign them.

  • background: white is a literal CSS color, not a palette name

  • source: google fetches the font at render time. base sets body text; headings sets every level of heading.

Light and dark

Two appearances, one document

Give theme a pair, and Quarto builds both:

_quarto.yml
format:
  html:
    theme:
      light: flatly
      dark: darkly
  • a toggle appears, top right
  • the one listed first is the default
  • respect-user-color-scheme: true defers to the reader’s OS or browser

One brand, two modes

  • Any color in color or typography takes a light/dark pair.

  • Colors in palette cannot take a pair — name the color once, choose per mode where the role is assigned.

_brand.yml
color:
  background:
    light: white
    dark: ink
  foreground:
    light: ink
    dark: sand

typography:
  headings:
    color:
      light: forest
      dark: sand

Two brands, two modes

When the modes differ by more than color, split the files and define use brand field in either the YAML of your document or in _quarto.yml:

report.qmd
brand:
  light: brs-light.yml
  dark: brs-dark.yml

Logos for both modes

For the project:

_brand.yml
logo:
  images:
    mark:
      path: brs-logo.svg
      alt: Bureau of Regional Statistics logo featuring a stylized bar chart
    mark-reversed:
      path: brs-logo-white.svg
      alt: Bureau of Regional Statistics logo featuring a stylized bar chart
  medium:
    light: mark
    dark: mark-reversed

Override for one document:

report.qmd
logo:
  light:
    path: large
    alt: Bureau of Regional Statistics logo featuring a stylized bar chart
  dark: brs-logo-white.svg

Formats that can’t toggle

Some formats produce only one appearance and offer no toggle between light/dark:

Revealjs:

slides.qmd
format:
  revealjs:
    brand-mode: dark

Typst:

report.qmd
format:
  typst:
    brand-mode: dark

Content for one mode only

  • light-content / .dark-content divs place the content in the same place, same size, so the layout doesn’t jump when the reader toggles.

  • {{< brand logo mark >}} already does this for you: it emits both, classed.

report.qmd

::: {.light-content}
![](brs-logo.svg)
:::

::: {.dark-content}
![](brs-logo-white.svg)
:::

Your turn 2: one brand, two modes

Exercise: 02-brand/02-dark-mode

  1. Give background, foreground and primary a value per mode:
  background:
    light: white
    dark: ink
  1. Add respect-user-color-scheme: true to report.qmd, render, and switch your system appearance back and forth with the page open.
  1. Look at the link to the methodology notes in dark mode. Fix it: add a lighter green to the palette and use it for the dark half of primary.

Stretch goal: Give secondary and tertiary mode values, then find something in the document that uses them, if any.

More details in exercise README.

Once done, discuss with your neighbors: The style guide gave you four colors and no dark mode. You invented mint. Is that a decision you are allowed to make, and who would you ask?

Your turn 2: takeaways

  • Getting colors and color contrast right, especially in light/dark mode, requires careful review and iteration.

  • A standalone page, unlike a website, has no toggle in the corner;respect-user-color-scheme is how single pages use the dark stylesheet.

What brand doesn’t reach

The plot didn’t get the memo

A brandedwebsite with an unbranded plot in it: barplot of sector and value (same values as table above) using the plotting library's default pink, purple, blue and green palette.

Theme helpers

The quarto packages turn a brand into a plot theme:

library(quarto)

theme_set(theme_brand_ggplot2("_brand.yml"))
from quarto import theme_brand_plotnine

theme = theme_brand_plotnine("_brand.yml")

Or read the brand yourself

library(brand.yml)

brand <- read_brand_yml("_brand.yml")
brand$color$primary
from brand_yml import Brand

brand = Brand.from_yaml("_brand.yml")
brand.color.primary

One source of truth, reachable from code.

Two modes need two plots

  • renderings says: this cell produces one output per mode.
report.qmd
```{r}
#| renderings: [light, dark]
par(bg = "#FFFFFF", fg = "#000000")
plot(1:10)

par(bg = "#000000", fg = "#FFFFFF", col.axis = "#FFFFFF")
plot(1:10)
```
  • The number of outputs must match the number of renderings.

renderings + caption or crossref

renderings and fig-/tbl- labels don’t mix

Solution: Wrap the cell instead

report.qmd
::: {#fig-employment}

```{r}
#| label: employment-plot
#| renderings: [light, dark]
```

Non-farm payroll employment, by quarter

:::

Caution The cell’s label must not start with fig- or tbl-, the divs may

Layering and formats

Brand is the bottom layer

Suppose you have the following and a _brand.yml file in the directory:

_quarto.yml
format:
  html:
    theme: [cosmo, tweaks.scss]

Quarto reads this as:

_quarto.yml
format:
  html:
    theme: [brand, cosmo, tweaks.scss]   # brand is lowest priority

Want the brand to win? Say where it goes:

_quarto.yml
format:
  html:
    theme: [cosmo, brand, tweaks.scss]

Same trick, light and dark

_quarto.yml
format:
  html:
    theme:
      light: [cosmo, brand]
      dark: [slate, brand]

Brand reaches four formats

html · dashboard · revealjs · typst

Key Where it lands
foreground, background body text and page, in all four
primary links, navbar (html), progress bar (revealjs)
typography base, headings, links, code
logo navbar, sidebar, slide corner, page corner
defaults: bootstrap html and dashboard only

Logos land in different places

Set small, medium, large — Quarto picks per location:

Format Location Prefers
html, dashboard top navbar small › medium › large
html side navigation medium › small › large
revealjs bottom right of slides medium › small › large
typst top left of the page medium › small › large
website, book browser tab favicon small

Your turn 3: takeaways

  • When both formats generate HTML, one of them need to output a filename that’s different than the name of the .qmd file, defined with output-file.

  • Not all formats use all inputs in _brand.yml, e.g., HTML documents don’t get a logo by default, but elements defined in _brand.yml can be called explicitly with the brand shortcode.

  • content-hidden when-format="revealjs" is conditional content – keeps the shortcode out of the deck, which already has the logo in the corner.

  • The brand shortcode emits two <img> tags, light-content and dark-content.

Sharing a brand

A brand is a file, so it can be a repo

  • one brand, several projects — a website, a report, a deck, an app
  • edited in one place, by whoever owns the brand
  • versioned, so a change is a commit and not a surprise

The catch: Quarto needs the assets inside your project. Logos and font files can’t be referenced across the filesystem or the network.

quarto use brand

Terminal
quarto use brand ./brand-repo         # a local directory
quarto use brand myorg/shared-brand   # a GitHub repository
quarto use brand https://example.com/brand.zip

Copies into _brand/, then makes it match the source: it asks before creating the directory, before overwriting a file, and before removing one the source no longer has.

Terminal
quarto use brand myorg/shared-brand --dry-run   # show me first
quarto use brand myorg/shared-brand --force     # no prompts, for CI

Or a brand extension

_extensions/
_extensions/
  myorg/
    brs-brand/
  • installs like any other Quarto extension
  • update with quarto update
  • distributed from a Git repository

Brand extensions →

Your turn 4: stop writing the brand

Exercise: 02-brand/04-shared-brand

  1. Delete the brand you wrote, and both SVGs.

  2. Preview the report without the brand to remind yourself what it looks like without it.

  3. Change the directory to the exercise folder, then take the agency’s brand — with --dry-run first, then without:

quarto use brand posit-conf-2026/practical-quarto-exercises/brs-brand --dry-run
  1. Look at what landed in _brand/, preview both formats.

  2. Switch your system appearance to dark, what happens?

Your turn 4: takeaways

  • If the brand isn’t what you want, do not fix that by editing _brand/_brand.yml. Instead, fix the source and run quarto use brand again.

  • quarto use brand takes a local directory, a GitHub org/repo, an org/repo/path within one, or a URL to a zip. It copies files into _brand/.

  • --dry-run shows what would happen, without changing anything.

  • --force skips the prompts, which is what you might want if not working interactively, e.g., in a CI workflow.

Brand on a website

One brand, every page

A _brand.yml in the project root applies to the whole site:

  • Each page gets the brand’s colors and typography.

  • Navbar gets primary and the logo, in small size.

  • Browser tab gets the favicon.

  • theme: brand is not required if no other theme is listed, but it is useful if the brand is built on a Bootswatch theme – same layering rule as before.

Dark mode comes built in

  • Websites get a light/dark toggle in the navbar automatically when the theme has two halves:
_quarto.yml
format:
  html:
    theme:
      light: brand
      dark: brand
  • With light/dark pairs in _brand.yml, the toggle flips the whole site, including navbar, links, code, and logo.

Your turn 5: brand the website

Exercise: 02-brand/05-website

  1. Change the directory to the exercise folder, bring the shared brand into the website project, then render the site:
quarto use brand posit-conf-2026/practical-quarto-exercises/brs-brand
  1. Set the navbar to the brand color:
website:
  navbar:
    background: primary
  1. Render again and find everywhere the brand shows up: navbar, links, headings, favicon.
  1. Give the site a light/dark toggle with theme: light: brand, dark: brand, and flip it.

  2. Does the navbar logo stay readable in both modes? Remember: don’t edit the copy in _brand/ — what would you ask the brand owner for instead?

Stretch goal: Add a page that doesn’t exist yet — how much branding work does the new page take?

Your turn 5: takeaways

  • In a website project, _brand.yml applies site-wide — pages, navbar, and favicon — with no per-page configuration.

  • Websites get a built-in light/dark toggle; with light/dark pairs in the brand, the whole site (including the navbar logo) follows it.

Wrap-up

Reaching brand values from code

Where How
SCSS $brand-forest, $primary
Typst brand-color.primary, brand-logo-images.mark.path
Markdown {{< brand color primary dark >}}, {{< brand logo mark >}}
R read_brand_yml("_brand.yml")
Python Brand.from_yaml("_brand.yml")
Lua filter require('modules/brand/brand'), brand.get_color('primary')

Learn more

Questions?