
EDS 240: Lecture 7.1
Typography
Week 7 | February 18th, 2026
Good data visualization design considers:
This lesson will focus on the use of typefaces and fonts in a good data visualization.
Type and font choice influences audience perception and readability
Typeface vs. font
Typeface (aka font family): underlying visual design (e.g. Times New Roman, Helvetica, Roboto)

Font: an implementation of a typeface; they can come in different weights and styles (e.g. bold, italic)

You choose a typeface (e.g. Nunito)
You use a font (e.g. regular, italic, bold)
Typeface choices affect emotions and perceptions

“Typography is the art and technique of arranging type to make written language legible, readable and appealing when displayed.”
Similar to colors, typefaces / fonts influence the how viewers perceive information (check out this short TEDx talk).
Source: The Daily Egg
Want to dive deeper into the world of typography? Start with this quick read, Why care about typography? and explore other great articles by Google Fonts.
Context matters - choose typeface accordingly
Typefaces and fonts communicate beyond more than just the written text – they can evoke emotions and can be used to better connect your audience with your work.
Source: Typography for a better user experience, by Suvo Ray
Interested in font psychology? Check out this short video and this article to learn a bit more.
Consider the text on this visualizaton
What typography choices did the author(s) of this visualization make? With you learning partner(s), consider all the various pieces of text on this visualization:
02:00
Example from Which fonts to use for your charts and tables, by Lisa Charlotte Muth
When in doubt, use sans-serif fonts
Serif fonts have small decorative lines (aka “tails” or “feet”) that extend off characters while sans serif fonts don’t.

Example from Which fonts to use for your charts and tables, by Lisa Charlotte Muth
Use a typeface with lining figures for numerals
Different typefaces display numbers differently. Serif fonts tend to have “oldstyle figures”, which extend above and below the “line” – these can be difficult to read in a visualization.
Instead, look for options with lining figures, where numbers “line up”, i.e. they’re all the same height.
Example from Which fonts to use for your charts and tables, by Lisa Charlotte Muth
Use a monospaced typeface for numerals
Typefaces with tabular figures print every character with equal width – you may see these referred to as monospaced typefaces. These work well in tables, visualizations, or any scenario where figures should line up vertically (see how you can quickly identify how many figures a number has in the table on the right, below).

Examples from Which fonts to use for your charts and tables, by Lisa Charlotte Muth & Understanding numerals article by Google Fonts
Use a typeface with all the symbols you need
Confirm that all symbols (aka glyphs) that you need exist and that they look good for your chosen typeface.
Consider special characters for different languages, currency symbols, math symbols, reference marks, sub / superscript numbers, etc.
Example from Which fonts to use for your charts and tables, by Lisa Charlotte Muth
Use bold fonts for emphasis only
Most typefaces come with fonts for different weights (Google Fonts uses numbers for font weights – extra light (200), light (300), regular (400, default), medium (500), semi bold (600), bold (700), extra bold (800)).
Use bold text for titles or to emphasize a few words in annotations. Regular or medium weights are often easiest for longer text (descriptions, annotations, notes).
Example from Which fonts to use for your charts and tables, by Lisa Charlotte Muth
Avoid really thin fonts
Thin (light-weight fonts) fonts are hard to read. Only use them in a high-contrast color and in large sizes (e.g. for titles).
Example from Which fonts to use for your charts and tables, by Lisa Charlotte Muth
Ensure your font size is large enough
Make sure your font size is large enough, especially when presenting visualizations in a slide-based presentation (this oftentimes means increasing it larger than you would have it in print). In ggplot, adjust font sizes using theme().
Example from Which fonts to use for your charts and tables, by Lisa Charlotte Muth
Use high-contrast color for most text
Web Content Accessibility Guidelines (WCAG) recommends a minimum contrast ratio of 4.5:1 – use a color contrast checker to check your ratio. The Coolers color contrast checker will enhance poor color combinations to meet WCAG guidelines. For example, here is a color combo with a bad contrast ratio, and here is the updated color combo with an improved contrast ratio.
Example from Which fonts to use for your charts and tables, by Lisa Charlotte Muth
Use UPPERCASE text sparingly
Uppercase text is more difficult to read compared to sentence case – limit use to headlines or labels. Region labels on maps are commonly uppercase (e.g. see maps in these New York Times pieces, How to Think About Ukraine, in Maps and Charts and Closing the Back Door to Europe).
Example from Which fonts to use for your charts and tables, by Lisa Charlotte Muth
Typographic hierarchy
No one wants to read a wall of text. You can use font size, style, color, spacing, and typeface (or combinations of these) to create a hierarchy that guide your readers.
Recap: choosing the right typeface(s) & font(s)
Avoid using too many typefaces (stick to just 1-3)
Check out fontpair.co to explore aesthetically-pleasing font pairings!
There are lots of excellent resources out there for choosing the right typeface / font – check out the resources page on the course website for some recommendations.
Horror Movies, by Cristophe Nicault (source code)
01:00
HBCUs, by Ijeamaka Anyene (source code)
01:00
R-Ladies Chapter Events, by Nicola Rennie (source code) | Font inspired by R-Ladies Global
01:00
Let’s learn how to use different fonts in our ggplots!
The problem with system fonts
A system font is one that’s already assumed to be on the vast majority of users’ devices, with no need for a web font to be downloaded.
There are only three system fonts that are guaranteed to work everywhere: sans (the default), serif, or mono. Use the family argument to specify which font family you’d like to use for a particular text element, and use the face argument to specify font face (bold, italic, plain (default)):
library(palmerpenguins)
library(tidyverse)
ggplot(penguins, aes(x = bill_length_mm, y = bill_depth_mm)) +
geom_point() +
labs(title = "This title is serif font",
subtitle = "This subtitle is mono font",
x = "This axis label is sans font\n(default)",
y = "This axis is also sans font\n(default)") +
theme(
plot.title = element_text(family = "serif", size = 30),
plot.subtitle = element_text(family = "mono", size = 25),
axis.title = element_text(family = "sans", size = 22),
axis.text.x = element_text(family = "serif", face = "bold", size = 18),
axis.text.y = element_text(family = "mono", face = "italic", size = 18)
)
The problem with system fonts
A graphics device (GD) is something used to make a plot appear – every time you create a plot in R, it needs to be sent to a specific GD to be rendered. There are two main device types:
Unfortunately, text drawing is handled differently by each graphics device, which means that if we want a font to work everywhere, we need to configure all these different devices in different ways.
Run ?Devices in your console to see which devices are supported by your installation of R.
Read a bit more about graphics devices in Chapter 8 of Exploratory Data Analysis with R, by Roger Peng
R packages to the rescue!
Fortunately, there are a couple super handy packages that make working with fonts a little bit easier:

We’ll be using {showtext} for a couple reasons: it supports more file formats and more graphics devices, and it also avoids using external software ({extrafont} requires that you install some additional software and packages first). {showtext} makes is easy to import and use Google Fonts.
Albert Rapp and Nicola Rennie both have written great blog posts on using {showtext}. These materials draw from both their work!
Recall our dumbbell plot from the amounts / rankings lecture
Let’s improve this plot by adding text, modifying the theme, and using some new fonts!
Wrangle data
This code should look familiar (copied from the amounts / rankings lecture):
##~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
## setup ----
##~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
#..........................load packages.........................
library(tidyverse)
library(janitor)
#..........................import data...........................
iwa_data <- read_csv(here::here("course-materials", "data", "lecture", "combined_iwa-assessment-outputs-conus-2025_CONUS_200910-202009_long.csv"))
#..................create df of subregions names.................
# data only contain HUC codes; must manually join names if we want to include those in our viz (which we do! we'll mainly be looking at CA subregions)
# subregions (& others) identified in: https://water.usgs.gov/GIS/wbd_huc8.pdf
# there may be a downloadable dataset containing HUCs & names out there...but I couldn't find it
subregion_names <- tribble(
~subregion_HUC, ~subregion_name,
"1801", "Klamath-Northern California Coastal",
"1802", "Sacramento",
"1803", "Tulare-Buena Vista Lakes",
"1804", "San Joaquin",
"1805", "San Francisco Bay",
"1806", "Central California Coastal",
"1807", "Southern California Coastal",
"1808", "North Lahontan",
"1809", "Northern Mojave-Mono Lake",
"1810", "Southern Mojave-Salton Sea",
)
##~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
## wrangle data ----
##~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
#......create df with just CA water resource region (HUC 18).....
ca_region <- iwa_data |>
clean_names() |>
mutate(region_HUC = str_sub(string = huc12_id, start = 1, end = 2),
subregion_HUC = str_sub(string = huc12_id, start = 1, end = 4)) |>
filter(region_HUC == "18") |>
separate_wider_delim(cols = year_month,
delim = "-",
names = c("year", "month")) |>
mutate(year = as.numeric(year),
month = as.numeric(month)) |>
left_join(subregion_names) |>
select(year, month, huc12_id, region_HUC, subregion_HUC, subregion_name, availab_mm_mo, consum_mm_mo, sui_frac)
#.................create df of just SBC subbasin.................
sbc_subbasin_monthly <- ca_region |>
mutate(subbasin_HUC = str_sub(string = huc12_id, start = 1, end = 8)) |>
filter(subbasin_HUC == "18060013") |> #
group_by(month) |>
summarize(mean_avail = mean(availab_mm_mo, na.rm = TRUE),
mean_consum = mean(consum_mm_mo, na.rm = TRUE)) |>
mutate(month = month.abb[month],
month = factor(month, levels = rev(c(month.abb[10:12], month.abb[1:9]))))Code for our original plot
This code was copied from the amounts / rankings lecture. Resulting plot is rendered on the next slide.
##~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
## plot ----
##~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
ggplot(sbc_subbasin_monthly) +
geom_linerange(aes(y = month,
xmin = mean_consum, xmax = mean_avail)) +
geom_point(aes(x = mean_avail, y = month),
color = "#448F9C",
size = 5,
stroke = 2) +
geom_point(aes(x = mean_consum, y = month),
color = "#9C7344",
fill = "white",
shape = 21,
size = 5,
stroke = 2) +
labs(x = "Mean monthly water availability & consumption (mm)") +
theme(axis.title.y = element_blank())Create a named palette
Let’s create a named vector of colors to call from. In addition to our point colors, we’ll also include colors for our plot’s text:
The primary purpose of the {monochromeR} package is for creating monochrome color palettes, however, it also includes a helpful function for viewing our palette:
Apply new colors by name
ggplot(sbc_subbasin_monthly) +
geom_linerange(aes(y = month,
xmin = mean_consum, xmax = mean_avail)) +
geom_point(aes(x = mean_avail, y = month),
color = pal["avail"],
size = 5,
stroke = 2) +
geom_point(aes(x = mean_consum, y = month),
color = pal["consum"],
fill = "white",
shape = 21,
size = 5,
stroke = 2) +
labs(x = "Mean monthly water availability & consumption (mm)") +
theme(axis.title.y = element_blank())Add titles / caption & modify theme
Use relative font sizes within your theme! If you need to make your plot larger for a presentation, for example, you’ll just need to increase your base_size and all text scales proportionally. Resulting plot is rendered on next slide.
ggplot(sbc_subbasin_monthly) +
geom_linerange(aes(y = month,
xmin = mean_consum, xmax = mean_avail),
alpha = 0.6) +
geom_point(aes(x = mean_avail, y = month),
color = pal["avail"],
size = 5,
stroke = 2) +
geom_point(aes(x = mean_consum, y = month),
color = pal["consum"],
fill = "white",
shape = 21,
size = 5,
stroke = 2) +
labs(title = "Water availability vs. consumption across the Santa Barbara\nCoastal subbasin (2010-2020)",
subtitle = "Summer deficits exist, despite winter water surplus",
caption = "Data Source: USGS CONUS 2025",
x = "Mean monthly water availability & consumption (mm)") +
theme_light(base_size = 17) + # set relative text sizes based on your defined `base_size`
theme(
plot.title.position = "plot",
plot.title = element_text(face = "bold",
size = rel(0.98),
color = pal["dark_gray"]),
plot.subtitle = element_text(size = rel(0.9),
color = pal["light_gray"],
margin = margin(b = 8)), # you don't need to include arg names, so long as you list margin sizes in order (counterclockwise from top, e.g. `margin(0, 0, 8, 0)`)
axis.text = element_text(size = rel(0.7),
color = pal["light_gray"]),
axis.title.x = element_text(size = rel(0.75),
margin = margin(t = 15)),
axis.title.y = element_blank(),
plot.caption = element_text(face = "italic",
size = rel(0.5),
color = pal["light_gray"],
margin = margin(t = 15)),
panel.grid.major.y = element_blank(),
plot.margin = margin(t = 1, r = 1, b = 1, l = 1, "cm")
)Note: We can apply our font colors in the same way we applied our point colors, calling named values from pal

Pick a typeface(s) from Google Fonts
Browse typefaces and fonts at https://fonts.google.com/. It can be helpful to type your desired text into the Preview field (you may need to expand the sidebar by clicking the Filters button on the top left of the page) to get a better sense of how your font choice will look. You can also search typefaces by name.

Previewing available typefaces with our text sample

Available font styles in the Noto Sans typeface
We’ll use these three typefaces:
Important: Pick typefaces that have all the font styles (e.g. bold, italic) that you want to use. Sarala does not come in an italic style, meaning we won’t be able to render any text in the typeface Sarala and the font style italic.
Import Google Fonts
Import {showtext} at the top of your script, then use font_add_google() to specify the font family(ies) you want to import.
##~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
## setup ----
##~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
#..........................load packages.........................
library(tidyverse)
library(janitor)
library(showtext)
#......................import Google fonts.......................
# `name` is the name of the font as it appears in Google Fonts
# `family` is the user-specified id that you'll use to apply a font in your ggpplot
font_add_google(name = "Sarala", family = "sarala")
font_add_google(name = "Noto Sans", family = "noto-sans")
font_add_google(name = "Red Hat Mono", family = "red")
# ~ additional setup code omitted for brevity ~Enable {showtext} & apply Google Fonts
You’ll need to “turn on” {showtext} rendering ahead of running your plot code. I like to turn it off immediately after as well.
#..............enable {showtext} for newly opened GD.............
showtext_auto(enable = TRUE)
#...........................build plot...........................
ggplot(sbc_subbasin_monthly) +
geom_linerange(aes(y = month,
xmin = mean_consum, xmax = mean_avail),
alpha = 0.6) +
geom_point(aes(x = mean_avail, y = month),
color = pal["avail"],
size = 5,
stroke = 2) +
geom_point(aes(x = mean_consum, y = month),
color = pal["consum"],
fill = "white",
shape = 21,
size = 5,
stroke = 2) +
labs(title = "Water availability vs. consumption across the Santa Barbara\nCoastal subbasin (2010-2020)",
subtitle = "Summer deficits exist, despite winter water surplus",
caption = "Data Source: USGS CONUS 2025",
x = "Mean monthly water availability & consumption (mm)") +
theme_light(base_size = 17) + # set relative text sizes based on your defined `base_size`
theme(
plot.title.position = "plot",
plot.title = element_text(family = "sarala",
face = "bold",
size = rel(0.98),
color = pal["dark_gray"]),
plot.subtitle = element_text(family = "noto-sans",
size = rel(0.9),
color = pal["light_gray"],
margin = margin(b = 8)), # you don't need to include arg names, so long as you list margin sizes in order (counterclockwise from top, e.g. `margin(0, 0, 8, 0)`)
axis.text = element_text(family = "red",
size = rel(0.7),
color = pal["light_gray"]),
axis.title.x = element_text(family = "noto-sans",
size = rel(0.75),
margin = margin(t = 15)),
axis.title.y = element_blank(),
plot.caption = element_text(family = "noto-sans",
face = "italic",
size = rel(0.5)
color = pal["light_gray"],
margin = margin(t = 15)),
panel.grid.major.y = element_blank(),
plot.margin = margin(t = 1, r = 1, b = 1, l = 1, "cm")
)
#...............turn off {showtext} text rendering...............
showtext_auto(enable = FALSE)
Import Font Awesome fonts
Font Awesome is a library of icons, which can be imported and used similar to Google Fonts. You’ll need to download the font files first (see today’s pre-class prep instructions). We can then use showtext::font_add() to make them available for use in our ggplots:
##~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
## setup ----
##~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
#..........................load packages.........................
library(tidyverse)
library(showtext)
#......................import Google Fonts.......................
# `name` is the name of the font as it appears in Google Fonts
# `family` is the user-specified id that you'll use to apply a font in your ggplot
font_add_google(name = "Sarala", family = "sarala")
font_add_google(name = "Noto Sans", family = "noto-sans")
font_add_google(name = "Red Hat Mono", family = "red")
#....................import Font Awesome fonts...................
# we'll only be using an icon from the brands collection today; you'll need to import the other .otf files if you also plan to use icons from those collections
font_add(family = "fa-brands",
regular = here::here("fonts", "Font Awesome 7 Brands-Regular-400.otf"))
# ~ additional setup code omitted for brevity ~Reference icons by their Unicode
Let’s say I want to include my GitHub username along with the GitHub icon in the caption of my plot. Start by searching the Free icons on Font Awesome and click on the one you want to use (here, the github icon). Find the icon’s Unicode in the top right corner of the popup box:
Add an icon to our caption
To use this unicode in HTML, we need to stick a &#x ahead of it. We can make our code a bit easier to read by saving the unicode (as well as our username text) to variable names. We’ll then use the glue::glue() function to construct our full caption. Importantly, glue() will evaluate expressions enclosed by braces as R code.
Note that we (1) wrap our object names in {} to use the values that are saved to them, and (2), we use the HTML <span> tag to apply styles to text – here, we use the font-family property and supply it the value, fa-brands (which is the id (i.e. family) we created when loading the Font Awesome 7 Brands-Regular-400.otf file at the top of our script).
Apply our new caption
Resulting plot is rendered on the next slide.
#..............enable {showtext} for newly opened GD.............
showtext_auto(enable = TRUE)
#...........................build plot...........................
ggplot(sbc_subbasin_monthly) +
geom_linerange(aes(y = month,
xmin = mean_consum, xmax = mean_avail),
alpha = 0.6) +
geom_point(aes(x = mean_avail, y = month),
color = pal["avail"],
size = 5,
stroke = 2) +
geom_point(aes(x = mean_consum, y = month),
color = pal["consum"],
fill = "white",
shape = 21,
size = 5,
stroke = 2) +
labs(title = "Water availability vs. consumption across the Santa Barbara\nCoastal subbasin (2010-2020)",
subtitle = "Summer deficits exist, despite winter water surplus",
caption = caption,
x = "Mean monthly water availability & consumption (mm)") +
theme_light(base_size = 17) + # set relative text sizes based on your defined `base_size`
theme(
plot.title.position = "plot",
plot.title = element_text(family = "sarala",
face = "bold",
size = rel(0.98),
color = pal["dark_gray"]),
plot.subtitle = element_text(family = "noto-sans",
size = rel(0.9),
color = pal["light_gray"],
margin = margin(b = 8)), # you don't need to include arg names, so long as you list margin sizes in order (counterclockwise from top, e.g. `margin(0, 0, 8, 0))
axis.text = element_text(family = "red",
size = rel(0.7),
color = pal["light_gray"]),
axis.title.x = element_text(family = "noto-sans",
size = rel(0.75),
margin = margin(t = 15)),
axis.title.y = element_blank(),
plot.caption = element_text(family = "noto-sans",
face = "italic",
size = rel(0.5),
color = pal["light_gray"],
margin = margin(t = 15)),
panel.grid.major.y = element_blank(),
plot.margin = margin(t = 1, r = 1, b = 1, l = 1, "cm")
)
#...............turn off {showtext} text rendering...............
showtext_auto(enable = FALSE)
ggplot doesn’t (natively) know how to parse HTML

. . . but the {ggtext} package does! If we want to render ggplot text using HTML or Markdown syntax, we also need to use one of {ggtext}’s theme() elements, which will parse and render the applied styles.
There are a few options, all which replace {ggplot2}’s element_text() – be sure to check out the documentation as you’re deciding which to use. We’ll use element_markdown().
Update theme() element to render styling
#..............enable {showtext} for newly opened GD.............
showtext_auto(enable = TRUE)
#...........................build plot...........................
ggplot(sbc_subbasin_monthly) +
geom_linerange(aes(y = month,
xmin = mean_consum, xmax = mean_avail),
alpha = 0.6) +
geom_point(aes(x = mean_avail, y = month),
color = pal["avail"],
size = 5,
stroke = 2) +
geom_point(aes(x = mean_consum, y = month),
color = pal["consum"],
fill = "white",
shape = 21,
size = 5,
stroke = 2) +
labs(title = "Water availability vs. consumption across the Santa Barbara\nCoastal subbasin (2010-2020)",
subtitle = "Summer deficits exist, despite winter water surplus",
caption = caption,
x = "Mean monthly water availability & consumption (mm)") +
theme_light(base_size = 17) + # set relative text sizes based on your defined `base_size`
theme(
plot.title.position = "plot",
plot.title = element_text(family = "sarala",
face = "bold",
size = rel(0.98),
color = pal["dark_gray"]),
plot.subtitle = element_text(family = "noto-sans",
size = rel(0.9),
color = pal["light_gray"],
margin = margin(b = 8)), # you don't need to include arg names, so long as you list margin sizes in order (counterclockwise from top, e.g. `margin(0, 0, 8, 0))
axis.text = element_text(family = "red",
size = rel(0.7),
color = pal["light_gray"]),
axis.title.x = element_text(family = "noto-sans",
size = rel(0.75),
margin = margin(t = 15)),
axis.title.y = element_blank(),
plot.caption = ggtext::element_markdown(family = "noto-sans",
face = "italic",
size = rel(0.5),
color = pal["light_gray"],
halign = 1,
lineheight = 1.5,
margin = margin(t = 15)),
panel.grid.major.y = element_blank(),
plot.margin = margin(t = 1, r = 1, b = 1, l = 1, "cm")
)
#...............turn off {showtext} text rendering...............
showtext_auto(enable = FALSE)Note: Text rendered with {ggtext} doesn’t typically appear correctly in the plots pane (or when using the Zoom window; see this separate, but potentially related GitHub issue). For now, you’ll want to render your template .qmd file to see your plot instead.

We also need to tell readers what our colors mean!
Rather than a traditional legend, let’s color-code our title text (i.e. availability & consumption) to match the points in our plot. We can again use the {ggtext} package to apply simple Markdown and HTML rendering to our ggplot text.
We’ll need to both format our title using some HTML, and update theme() so that this text element is rendered using ggtext::element_markdown() (like our caption). Let’s construct our title using glue::glue():
Then update to element_markdown() and increase the lineheight to give the two lines a bit of breathing room:
Note: We combine both Markdown syntax (e.g. ** to bold text) and HTML syntax to apply in-line CSS styling (e.g. <span style=...>):

Before & after


Take a Break
05:00