10  Nanoplots

Tables present numbers; charts reveal patterns. But what if a table could do both? Nanoplots (tiny, embedded visualizations) bridge this divide by placing miniature graphics directly within table cells. These small-scale data representations can show trends, distributions, and comparisons at a glance, augmenting numeric values with visual context that aids rapid comprehension.

The term “sparkline” (coined by Edward Tufte) describes “data-intense, design-simple, word-sized graphics” that can be embedded inline with text. Nanoplots in gt extend this concept to tabular contexts, allowing each row to carry its own miniature visualization derived from the row’s data. They are deliberately simple (there’s limited space, after all) but that simplicity becomes a virtue. A nanoplot doesn’t replace detailed analysis; it provides an immediate visual summary that guides the reader’s attention and facilitates comparison across rows.

This chapter explores the cols_nanoplot() function and its companion nanoplot_options() helper. We’ll examine the three available plot types (line plots, bar plots, and box plots) and discover how to customize their appearance, handle missing data, add reference elements, and supply data in various formats. By the chapter’s end, you’ll be equipped to enhance your tables with these compact but powerful visualizations.

10.1 The cols_nanoplot() function

The cols_nanoplot() function creates a new column containing nanoplots, using data from one or more existing columns. The function collects numeric values across specified columns for each row, generates a plot from those values, and places the resulting visualization in a new column.

Function Signature

cols_nanoplot(
  data,
  columns,
  rows = everything(),
  plot_type = c("line", "bar", "boxplot"),
  plot_height = "2em",
  missing_vals = c("gap", "marker", "zero", "remove"),
  autoscale = FALSE,
  autohide = TRUE,
  columns_x_vals = NULL,
  reference_line = NULL,
  reference_area = NULL,
  expand_x = NULL,
  expand_y = NULL,
  new_col_name = NULL,
  new_col_label = NULL,
  before = NULL,
  after = NULL,
  options = NULL
)

Let’s start with a simple example using the illness dataset, which contains daily medical test measurements:

illness |>
  dplyr::slice_head(n = 8) |>
  gt(rowname_col = "test") |>
  tab_header(title = "Daily Medical Test Results") |>
  cols_hide(columns = c(starts_with("norm"), units)) |>
  cols_nanoplot(
    columns = starts_with("day"),
    new_col_name = "trend",
    new_col_label = "7-Day Trend"
  )
Daily Medical Test Results
7-Day Trend
Viral load
12.0K25012.0K4.20K1.60K830760520250
WBC
30.34.265.264.269.9210.524.830.319.0
Neutrophils
27.24.724.874.727.9218.222.127.216.6
RBC
5.982.685.725.984.234.834.122.683.32
Hb
15375153135126115758795
PLT
74.125.667.038.627.426.274.136.225.6
ALT
12.8K51212.8K12.6K6.43K4.26K1.62K673512
AST
23.7K78223.7K21.4K14.7K8.69K2.19K1.14K782

This table shows medical test measurements with a line plot summarizing the seven daily values. The columns = starts_with("day") argument collects data from all columns beginning with “day”, concatenating them left-to-right to form each row’s data series. The source columns are automatically hidden (due to autohide = TRUE by default), keeping the table clean.

The nanoplot provides immediate visual context: readers can instantly see whether values trended upward, downward, or remained stable, without mentally parsing seven separate numbers.

Two arguments control how the new nanoplot column is identified and presented. The new_col_name = "trend" argument sets the internal column name and this is what you’ll use to reference this column in subsequent gt operations like cols_width(), cols_align(), cols_move(), or tab_style(). Think of it as the column’s programmatic identifier. Meanwhile, new_col_label = "7-Day Trend" sets what readers actually see in the column header. This label can include spaces, special characters, or even markdown formatting (via md()), making it reader-friendly while keeping the internal name concise and code-friendly.

Both arguments are optional. If you omit new_col_name, gt generates a default name (typically “nanoplots”). If you omit new_col_label, the column name itself becomes the label. However, providing explicit values is good practice: it makes your code clearer and ensures column headers communicate effectively with your audience. You’ll often want a short, simple internal name for coding convenience paired with a more descriptive, formatted label for presentation clarity.

10.1.1 Data input: columns vs. value streams

Nanoplots accept data in two flexible formats, allowing you to work with whatever structure your data already has.

The first format spreads values across multiple columns, as we just saw with the illness dataset. Each column contributes one value to the nanoplot, collected in left-to-right order:

# Multi-column format: each column is one value
dplyr::tibble(
  product = c("Widget", "Gadget", "Gizmo"),
  q1_sales = c(120, 85, 210),
  q2_sales = c(135, 92, 198),
  q3_sales = c(142, 88, 225),
  q4_sales = c(156, 95, 245)
) |>
  gt(rowname_col = "product") |>
  tab_header(title = "Quarterly Sales") |>
  cols_nanoplot(
    columns = starts_with("q"),
    new_col_name = "trend",
    new_col_label = "Quarterly Trend"
  ) |>
  cols_width(trend ~ px(100))
Quarterly Sales
Quarterly Trend
Widget
156120120135142156
Gadget
958585928895
Gizmo
245198210198225245

The second format packs all values for a row into a single column as a delimited string. This is particularly useful when different rows have varying numbers of data points, or when your data arrives in this format from external sources:

# Value stream format: comma-separated values in one column
dplyr::tibble(
  product = c("Widget", "Gadget", "Gizmo"),
  sales_stream = c("120, 135, 142, 156", "85, 92, 88, 95", "210, 198, 225, 245")
) |>
  gt(rowname_col = "product") |>
  tab_header(title = "Quarterly Sales") |>
  cols_nanoplot(
    columns = sales_stream,
    new_col_name = "trend",
    new_col_label = "Quarterly Trend"
  ) |>
  cols_width(trend ~ px(100))
Quarterly Sales
Quarterly Trend
Widget
156120120135142156
Gadget
958585928895
Gizmo
245198210198225245

Both formats produce identical nanoplots. The multi-column approach works well when your table already has the data structured that way and you want to show those columns alongside the nanoplot. The value stream approach is good for when you need flexibility in the number of data points per row or want to keep your table structure simple. Commas, spaces, or semicolons can all serve as delimiters in value streams.

10.2 Line plots

Line plots are the default nanoplot type and is best for showing trends over ordered sequences. They consist of three visual layers that can be independently controlled: data points (the actual values), a connecting line, and a filled area beneath the line.

10.2.1 Basic line plots

Line plots are the natural choice when your data represents a continuous progression or ordered sequence. Time series, sequential measurements, cumulative values, and any data where the order matters and you want to emphasize change from one value to the next: all of these benefit from line plot representation. The connecting line creates a visual path that guides the eye through the progression, making it easy to spot upward trends, downward trends, plateaus, or sudden changes.

In the context of nanoplots, line plots serve as compact trend indicators that answer questions like “Is this going up or down?” and “How volatile is this pattern?” at a glance. Because they’re the default plot type, you don’t need to specify plot_type = "line". Simply calling cols_nanoplot() produces a line plot. This makes them the quickest option when you need a basic visual summary without customization.

The towny dataset contains population data for municipalities across multiple census years. By selecting columns that start with “population”, we collect a time series for each municipality that spans several decades. The resulting line plots provide an immediate visual history of population growth:

towny |>
  dplyr::select(name, starts_with("population")) |>
  dplyr::slice_max(population_2021, n = 8) |>
  gt() |>
  fmt_integer(columns = starts_with("population")) |>
  cols_nanoplot(
    columns = starts_with("population"),
    new_col_name = "pop_trend",
    new_col_label = "Population Trend"
  ) |>
  cols_hide(columns = matches("199|200|201")) |>
  cols_width(pop_trend ~ px(120))
name Population Trend
Toronto
2.79M2.39M2.39M2.48M2.50M2.62M2.73M2.79M
Ottawa
1.02M721K721K774K812K883K934K1.02M
Mississauga
722K544K544K613K669K713K722K718K
Brampton
656K268K268K325K434K524K594K656K
Hamilton
569K468K468K490K505K520K537K569K
London
422K326K326K336K352K366K384K422K
Markham
339K173K173K209K262K302K329K339K
Vaughan
323K133K133K182K239K288K306K323K

Each municipality’s population history spanning decades condenses into a compact visualization. The line plot reveals growth trajectories that would require careful numeric comparison to discern from the raw numbers alone. You can immediately see which municipalities experienced steady growth (smooth upward slopes), which grew explosively in recent decades (sharp upward curves), and which remained relatively stable (nearly flat lines).

The visual comparison across rows is particularly valuable here. Without the nanoplots, determining which municipality grew fastest would require mentally calculating growth rates from the numeric columns. The line plots make this comparison instant as steeper slopes indicate faster growth. Similarly, you can spot patterns like early rapid growth followed by stabilization, or slow initial growth accelerating in later years, patterns that might be missed when scanning columns of numbers.

By hiding the intermediate year columns with cols_hide(columns = matches("199|200|201")), we keep the table clean while still showing the most recent population figure (2021) alongside the visual trend. This combination of current value and historical trend provides both the “what is it now?” and “how did we get here?” perspectives in a single glance.

10.2.2 Customizing line plot appearance

The nanoplot_options() helper function provides extensive control over visual properties. Let’s create line plots with customized styling:

towny |>
  dplyr::select(name, starts_with("density")) |>
  dplyr::slice_max(density_2021, n = 6) |>
  gt() |>
  fmt_number(columns = starts_with("density"), decimals = 0) |>
  cols_nanoplot(
    columns = starts_with("density"),
    new_col_name = "density_trend",
    new_col_label = "Density Over Time",
    options = nanoplot_options(
      data_point_radius = 6,
      data_point_stroke_color = "darkblue",
      data_point_stroke_width = 2,
      data_point_fill_color = "lightblue",
      data_line_stroke_color = "steelblue",
      data_line_stroke_width = 3,
      data_area_fill_color = "lightsteelblue"
    )
  ) |>
  cols_hide(columns = matches("199|200|201")) |>
  cols_width(density_trend ~ px(140))
name Density Over Time
Toronto
4.43K3.78K3.78K3.93K3.97K4.14K4.33K4.43K
Brampton
2.47K1.01K1.01K1.22K1.63K1.97K2.23K2.47K
Mississauga
2.46K1.86K1.86K2.09K2.28K2.44K2.46K2.45K
Newmarket
2.28K1.48K1.48K1.71K1.93K2.08K2.19K2.28K
Richmond Hill
2.00K1.01K1.01K1.31K1.61K1.84K1.93K2.00K
Orangeville
1.99K1.42K1.42K1.67K1.78K1.85K1.91K1.99K

The blue color scheme creates visual cohesion. Larger data points with contrasting stroke and fill colors improve visibility, while the wider line and lighter area fill create depth.

10.2.3 Showing only specific layers

Sometimes you want to emphasize particular aspects of the data. Here’s a line-only plot without points or area:

sp500 |>
  dplyr::filter(date >= "2015-01-01" & date <= "2015-03-31") |>
  dplyr::select(date, close) |>
  dplyr::mutate(month = format(date, "%Y-%m")) |>
  dplyr::summarize(
    prices = paste(close, collapse = ","),
    .by = month
  ) |>
  gt(rowname_col = "month") |>
  tab_header(title = "S&P 500 Daily Closing Prices", subtitle = "Q1 2015") |>
  cols_nanoplot(
    columns = prices,
    new_col_name = "price_chart",
    new_col_label = "Daily Prices",
    options = nanoplot_options(
      show_data_points = FALSE,
      show_data_area = FALSE,
      data_line_stroke_color = "#2E7D32",
      data_line_stroke_width = 2,
      data_line_type = "straight"
    )
  ) |>
  cols_width(price_chart ~ px(200))
S&P 500 Daily Closing Prices
Q1 2015
Daily Prices
2015-03
2.12K2.04K2.07K2.09K2.06K2.06K2.06K2.09K2.10K2.11K2.09K2.10K2.07K2.08K2.05K2.07K2.04K2.04K2.08K2.07K2.10K2.10K2.11K2.12K
2015-02
2.12K2.02K2.10K2.11K2.11K2.12K2.11K2.11K2.10K2.10K2.10K2.10K2.09K2.07K2.07K2.05K2.06K2.06K2.04K2.05K2.02K
2015-01
2.06K1.99K1.99K2.02K2.00K2.03K2.06K2.05K2.06K2.03K2.02K2.02K1.99K2.01K2.02K2.03K2.04K2.06K2.03K2.00K2.02K2.06K

With many data points, hiding individual markers reduces visual clutter. The data_line_type = "straight" option uses straight line segments instead of curves, which can be clearer for volatile data.

Alternatively, show only points for a scatter-like appearance:

exibble |>
  dplyr::select(row, group, num, currency) |>
  gt(rowname_col = "row", groupname_col = "group") |>
  cols_nanoplot(
    columns = c(num, currency),
    new_col_name = "values",
    new_col_label = "Values",
    options = nanoplot_options(
      show_data_line = FALSE,
      show_data_area = FALSE,
      data_point_radius = 8,
      data_point_fill_color = "coral",
      data_point_stroke_color = "darkred",
      data_point_stroke_width = 2
    )
  )
Values
grp_a
row_1
50.00.110.1150.0
row_2
18.02.222.2218.0
row_3
33.31.3933.31.39
row_4
65.1K44444465.1K
grp_b
row_5
5.55K1.33K5.55K1.33K
row_6
15.910.6NA13.3
row_7
932K622K777KNA
row_8
8.88M0.448.88M0.44

With only two data points per row, a connecting line adds little value. Points alone clearly show the two values and their relative magnitudes.

10.2.4 Line plots with x-axis values

By default, nanoplots space data points evenly along the x-axis. For line plots, you can supply explicit x values using columns_x_vals:

# Create sample data with irregular time intervals
dplyr::tibble(
  category = c("Product A", "Product B", "Product C"),
  times = c("1,3,4,8,12", "2,5,6,9,15", "1,2,7,10,14"),
  values = c("10,15,13,20,25", "8,12,15,11,18", "5,8,12,15,20")
) |>
  gt(rowname_col = "category") |>
  tab_header(title = "Sales at Irregular Intervals") |>
  cols_nanoplot(
    columns = values,
    columns_x_vals = times,
    new_col_name = "trend",
    new_col_label = "Sales Trend",
    expand_x = c(0, 16)
  ) |>
  cols_width(trend ~ px(150))
Sales at Irregular Intervals
Sales Trend
Product A
25101015132025
Product B
188812151118
Product C
20558121520

The x values position points according to their actual timing rather than equally spacing them. The expand_x argument ensures all plots share the same x-axis range for valid comparison.

10.3 Bar plots

Bar plots are good when showing categorical comparisons and clearly distinguishing positive from negative values. Unlike line plots, bar plots always use equal spacing and ignore any x-axis values.

10.3.1 Basic bar plots

Bar plots work best when you need to compare discrete categories or sequential values where the magnitude of each individual item matters. Each bar represents a single data point, and its height encodes the value. When multiple values appear in a single nanoplot, the bars stand side by side, making it easy to compare magnitudes both within a row (across bars) and between rows (comparing corresponding bars).

In the context of tables, bar nanoplots are particularly effective for showing compositions, breakdowns, or multi-part measurements. For instance, if each row represents a different entity and the columns contain related metrics, a bar nanoplot can show all those metrics together in a compact visual form. This allows readers to quickly grasp not just which values are largest or smallest, but how the pattern of relative magnitudes differs from row to row.

pizzaplace |>
  dplyr::count(type, date) |>
  tidyr::pivot_wider(names_from = type, values_from = n) |>
  dplyr::slice_head(n = 7) |>
  gt(rowname_col = "date") |>
  tab_header(title = "Daily Pizza Sales by Type") |>
  fmt_date(columns = stub(), date_style = "MMMd") |>
  cols_nanoplot(
    columns = c(chicken, classic, supreme, veggie),
    plot_type = "bar",
    new_col_name = "by_type",
    new_col_label = "Sales Distribution"
  ) |>
  cols_width(by_type ~ px(100))
Daily Pizza Sales by Type
Sales Distribution
Jan 1
46036463941
Jan 2
57032574531
Jan 3
47042473237
Jan 4
28028262824
Jan 5
37031372829
Jan 6
48031483137
Jan 7
45028453332

Each bar represents one pizza type’s sales for that day. The relative heights reveal the sales mix, showing which types dominated each day’s orders. By comparing across rows, you can see whether certain days had notably different sales patterns. For example, a day where the classic pizza bar is much taller than the others indicates strong preference for that type, while more uniform bar heights suggest balanced sales across types.

The bars are positioned in the same order as the columns specified in the columns argument, creating a consistent visual structure. This consistency allows your eye to track a specific category across multiple rows. If the veggie pizza is always the fourth bar, you can quickly scan down the column to assess veggie pizza sales across all days without having to reorient yourself for each row.

Bar plots also make zero values and missing values visually obvious. A missing bar (or a bar with zero height) stands out immediately, drawing attention to gaps in the data. This is different from line plots, where missing values might create subtle gaps that could be overlooked.

10.3.2 Customizing bar colors

When bar plots display multiple categories, assigning distinct colors to each bar position creates immediate visual differentiation. Instead of relying on position alone, color allows readers to identify specific categories at a glance. This technique is particularly effective when the categories have inherent associations (like product types, regions, or departments) that benefit from consistent color coding.

The data_bar_fill_color option in nanoplot_options() accepts a vector of colors, with each color corresponding to a bar position in the order specified by the columns argument. The first color applies to the first column’s bar, the second color to the second column’s bar, and so on. This positional consistency means that across all rows, the same category always appears in the same color, creating a visual legend that persists throughout the table.

When using multiple colors, providing a legend is essential. Without one, readers must deduce the color-to-category mapping by examining the source columns (if visible) or through trial and error. A clear legend, whether in a footnote, source note, or table caption, eliminates ambiguity and ensures readers can interpret the colored bars correctly from the first glance.

pizzaplace |>
  dplyr::count(type, date) |>
  tidyr::pivot_wider(names_from = type, values_from = n) |>
  dplyr::slice_head(n = 7) |>
  gt(rowname_col = "date") |>
  tab_header(title = "Daily Pizza Sales by Type") |>
  fmt_date(columns = stub(), date_style = "MMMd") |>
  fmt_integer(columns = c(chicken, classic, supreme, veggie)) |>
  cols_align(align = "center", columns = everything()) |>
  cols_nanoplot(
    columns = c(chicken, classic, supreme, veggie),
    plot_type = "bar",
    autohide = FALSE,
    new_col_name = "by_type",
    new_col_label = "Sales by Type",
    options = nanoplot_options(
      data_bar_stroke_color = "transparent",
      data_bar_fill_color = c("#D35400", "#F4D03F", "#8E44AD", "#27AE60")
    )
  ) |>
  cols_width(everything() ~ px(100)) |>
  tab_source_note(
    source_note = md(paste0(
      "<span style=\"color:#D35400;\">&#9632;</span> Chicken &nbsp;&nbsp; ",
      "<span style=\"color:#F4D03F;\">&#9632;</span> Classic &nbsp;&nbsp; ",
      "<span style=\"color:#8E44AD;\">&#9632;</span> Supreme &nbsp;&nbsp; ",
      "<span style=\"color:#27AE60;\">&#9632;</span> Veggie"
    ))
  )
Daily Pizza Sales by Type
chicken classic supreme veggie Sales by Type
Jan 1 36 46 39 41
46036463941
Jan 2 32 57 45 31
57032574531
Jan 3 42 47 32 37
47042473237
Jan 4 28 26 28 24
28028262824
Jan 5 31 37 28 29
37031372829
Jan 6 31 48 31 37
48031483137
Jan 7 28 45 33 32
45028453332
Chicken    Classic    Supreme    Veggie

With autohide = FALSE, the source columns remain visible, allowing readers to see both exact numbers and the visual comparison. The colored bars create an instant visual signature for each pizza type. The source note uses HTML to create colored squares (■ is the Unicode character for a square) that match the bar colors, providing an unambiguous legend without requiring readers to cross-reference column positions.

The even column widths (achieved with cols_width(everything() ~ px(100))) and centered alignment create a balanced, symmetric layout. This uniformity emphasizes the visual comparison by removing layout-based distractions. When all elements are equally spaced and aligned, differences in bar heights become the dominant visual feature.

Color choice matters. The colors used here have strong contrast and distinct hues (orange, yellow, purple, green), making them easy to distinguish even for readers with some forms of color vision deficiency. Avoid using colors that differ only in saturation or lightness, as these can be difficult to differentiate. Test your color palette to ensure sufficient contrast between adjacent bars.

10.3.3 Bar plots with positive and negative values

When data contains both positive and negative values, bar plots automatically apply different visual styling to distinguish them. This is particularly useful for displaying changes, differences, or variance metrics where direction matters as much as magnitude. Positive values might represent growth, gains, or increases, while negative values indicate declines, losses, or decreases.

By default, gt renders positive and negative bars with distinct fill colors, making the directional information immediately visible without requiring readers to examine numeric values or axis labels. You can customize these colors using the data_bar_fill_color and data_bar_negative_fill_color options in nanoplot_options(), allowing you to align the color scheme with your data’s semantics. For instance, green for positive changes and red for negative changes, or any other color pairing that suits your context:

# Create data with positive and negative changes
dplyr::tibble(
  metric = c("Revenue", "Costs", "Margin", "Volume"),
  q1_change = c(12.5, -3.2, 8.1, -1.5),
  q2_change = c(-2.1, 5.8, -4.3, 6.2),
  q3_change = c(7.8, -1.9, 3.2, -2.8),
  q4_change = c(4.2, 2.1, -1.5, 8.9)
) |>
  gt(rowname_col = "metric") |>
  tab_header(title = "Quarterly Percent Changes") |>
  cols_nanoplot(
    columns = ends_with("change"),
    plot_type = "bar",
    new_col_name = "quarterly",
    new_col_label = "Q1–Q4 Changes",
    options = nanoplot_options(
      data_bar_fill_color = "#2ECC71",
      data_bar_stroke_color = "#1E8449",
      data_bar_negative_fill_color = "#E74C3C",
      data_bar_negative_stroke_color = "#922B21"
    )
  ) |>
  cols_width(quarterly ~ px(120))
Quarterly Percent Changes
Q1–Q4 Changes
Revenue
12.5−2.1012.5−2.107.804.20
Costs
5.80−3.20−3.205.80−1.902.10
Margin
8.10−4.308.10−4.303.20−1.50
Volume
8.90−2.80−1.506.20−2.808.90

Positive changes appear in green while negative changes display in red, making it immediately apparent which quarters saw gains versus losses for each metric.

10.3.4 Horizontal reference lines in bar plots

Reference lines add analytical context to bar plots by marking specific values of interest. They can highlight thresholds, targets, or statistical measures like means and medians. When comparing bars across different positions or rows, a reference line provides a common benchmark that makes it easier to assess whether individual values exceed, fall short of, or align with a particular standard.

You can specify reference lines using keywords (like "mean" or "median") to compute values from the data itself, or supply fixed numeric values when you have predetermined thresholds or targets in mind. The reference line appears as a horizontal line across the plot, typically in a contrasting color to ensure visibility against the bars:

countrypops |>
  dplyr::filter(
    country_name %in% c("India", "China", "Nigeria", "Brazil", "Japan"),
    year >= 1960,
    year %% 10 == 0
  ) |>
  dplyr::select(country_name, year, population) |>
  dplyr::mutate(pop_millions = population / 1e6, .keep = "unused") |>
  tidyr::pivot_wider(names_from = year, values_from = pop_millions) |>
  gt(rowname_col = "country_name") |>
  tab_header(title = "Population Trends (Millions)", subtitle = "1960–2020") |>
  cols_nanoplot(
    columns = where(is.numeric),
    plot_type = "bar",
    reference_line = "mean",
    new_col_name = "pop_bars",
    new_col_label = "Annual Population"
  ) |>
  cols_width(pop_bars ~ px(140))
Population Trends (Millions)
1960–2020
Annual Population
Brazil
145209072.495.4121149174194209
China
1.09K1.41K06678189811.14K1.26K1.34K1.41K
India
8911.40K04365466878651.06K1.24K1.40K
Japan
117128093.2103117123127128126
Nigeria
111214045.155.973.897.1126167214

The reference line shows each country’s mean population across the years, helping identify whether recent years are above or below the historical average. This horizontal line provides a quick visual benchmark: bars extending above the line represent years with above-average population, while those falling below indicate below-average years. The reference line is interactive. Positioning your mouse pointer to the right of the reference line reveals the computed mean value in a tooltip, formatted in the same way as the individual bar values. This allows you to see both the visual pattern and the precise threshold value that defines the comparison.

10.4 Box plots

Box plots summarize distributions by showing median, quartiles, and outliers. They’re ideal when each row contains many values and you want to convey distributional characteristics rather than individual data points.

10.4.1 Basic box plots

pizzaplace |>
  dplyr::filter(date <= "2015-01-14") |>
  dplyr::mutate(time_numeric = as.numeric(hms::as_hms(time))) |>
  dplyr::summarize(
    times = paste(time_numeric, collapse = ","),
    n_orders = dplyr::n(),
    .by = date
  ) |>
  gt() |>
  tab_header(title = "Pizza Order Timing", subtitle = "First Two Weeks of 2015") |>
  fmt_date(columns = date, date_style = "yMMMEd") |>
  cols_nanoplot(
    columns = times,
    plot_type = "boxplot",
    new_col_name = "timing",
    new_col_label = "Order Time Distribution"
  ) |>
  cols_width(timing ~ px(150)) |>
  cols_align(columns = timing, align = "center")
Pizza Order Timing
First Two Weeks of 2015
date n_orders Order Time Distribution
Thu, Jan 1, 2015 162
41.9K48.8K57.2K66.7K79.9K
Fri, Jan 2, 2015 165
41.9K48.9K64.4K69.8K81.2K
Sat, Jan 3, 2015 158
41.6K53.0K61.3K70.6K82.2K
Sun, Jan 4, 2015 106
41.4K50.2K60.1K72.7K80.5K
Mon, Jan 5, 2015 125
41.0K51.7K60.6K67.6K79.2K
Tue, Jan 6, 2015 147
41.9K47.0K54.2K64.8K80.8K
Wed, Jan 7, 2015 138
42.3K47.1K52.3K65.3K82.0K
Thu, Jan 8, 2015 173
40.6K45.3K51.6K64.4K80.8K
Fri, Jan 9, 2015 127
40.6K48.8K58.5K68.3K81.2K
Sat, Jan 10, 2015 146
43.9K49.7K66.8K72.8K82.1K
Sun, Jan 11, 2015 116
42.1K49.1K59.6K65.6K77.4K
Mon, Jan 12, 2015 119
41.9K50.9K58.9K66.2K77.7K
Tue, Jan 13, 2015 120
41.5K49.2K58.5K66.7K78.0K
Wed, Jan 14, 2015 150
41.3K46.5K61.4K67.1K80.2K

Each box plot summarizes that day’s order timing distribution. The box spans the interquartile range (Q1 to Q3), the line inside marks the median, and whiskers extend to data within 1.5× IQR. Points beyond the whiskers are outliers.

10.4.2 Customizing box plot appearance

# Generate sample distribution data
set.seed(23)

dplyr::tibble(
  group = LETTERS[1:5],
  values = purrr::map_chr(1:5, ~ paste(round(rnorm(30, mean = .x * 10, sd = 5), 1),     collapse = ","))
) |>
  gt(rowname_col = "group") |>
  tab_header(title = "Distribution Comparison") |>
  cols_nanoplot(
    columns = values,
    plot_type = "boxplot",
    autoscale = TRUE,
    new_col_name = "dist",
    new_col_label = "Distribution"
  ) |>
  cols_width(dist ~ px(180))
Distribution Comparison
Distribution
A
3.907.4010.514.519.0
B
10.016.120.524.626.6
C
19.727.129.632.639.9
D
30.538.541.844.448.5
E
43.848.851.353.760.9

The autoscale = TRUE option ensures all box plots share the same scale, making cross-row comparisons valid. Without this, each box plot would scale independently to its own data range.

10.4.3 Formatting box plot hover values

Box plots can display custom-formatted values on hover:

pizzaplace |>
  dplyr::filter(date <= "2015-01-07") |>
  dplyr::mutate(time_numeric = as.numeric(hms::as_hms(time))) |>
  dplyr::summarize(
    times = paste(time_numeric, collapse = ","),
    .by = date
  ) |>
  gt() |>
  tab_header(title = "Order Time Distributions") |>
  fmt_date(columns = date, date_style = "yMd") |>
  cols_nanoplot(
    columns = times,
    plot_type = "boxplot",
    new_col_name = "timing",
    new_col_label = "When Orders Came In",
    options = nanoplot_options(
      y_val_fmt_fn = function(x) format(hms::as_hms(x), "%H:%M")
    )
  ) |>
  cols_width(timing ~ px(160))
Order Time Distributions
date When Orders Came In
1/1/2015
11:38:3613:34:0715:53:1818:31:25.522:12:13
1/2/2015
11:38:5113:34:4917:54:0419:23:0222:32:49
1/3/2015
11:34:1014:42:42.7517:01:3819:36:0022:50:29
1/4/2015
11:30:4813:56:35.7516:41:4820:11:09.7522:22:13
1/5/2015
11:23:3514:22:1916:49:3818:46:5421:59:46
1/6/2015
11:39:0613:03:5315:03:1217:59:4822:26:59
1/7/2015
11:45:1813:04:5614:31:1018:07:4622:46:13

The y_val_fmt_fn argument accepts a function that transforms numeric values for display. Here, seconds-since-midnight values convert back to readable times when users hover over the plot.

10.5 Reference lines and reference areas

Reference elements provide context by marking specific values or ranges within nanoplots.

10.5.1 Reference lines

A reference line is a horizontal line marking a particular value. It can be a fixed number or computed from the data:

illness |>
  dplyr::slice_head(n = 6) |>
  gt(rowname_col = "test") |>
  cols_hide(columns = c(starts_with("norm"), units)) |>
  cols_nanoplot(
    columns = starts_with("day"),
    reference_line = "median",
    new_col_name = "trend",
    new_col_label = "Trend (median reference)"
  ) |>
  cols_width(trend ~ px(120))
Trend (median reference)
Viral load
83012.0K25012.0K4.20K1.60K830760520250
WBC
10.530.34.265.264.269.9210.524.830.319.0
Neutrophils
16.627.24.724.874.727.9218.222.127.216.6
RBC
4.235.982.685.725.984.234.834.122.683.32
Hb
11515375153135126115758795
PLT
36.274.125.667.038.627.426.274.136.225.6

The reference line shows each test’s median value across the seven days. Values above or below this baseline become immediately apparent.

Available keywords for computed reference lines:

  • "mean": arithmetic mean of the data
  • "median": median value
  • "min": minimum value
  • "max": maximum value
  • "q1": first quartile (25th percentile)
  • "q3": third quartile (75th percentile)
  • "first": first data value
  • "last": last data value

Or supply a fixed numeric value:

towny |>
  dplyr::select(name, starts_with("population")) |>
  dplyr::slice_max(population_2021, n = 5) |>
  gt() |>
  fmt_integer(columns = starts_with("population")) |>
  cols_nanoplot(
    columns = starts_with("population"),
    reference_line = 500000,
    new_col_name = "trend",
    new_col_label = "Population History"
  ) |>
  cols_hide(columns = matches("199|200|201")) |>
  cols_width(trend ~ px(130))
name Population History
Toronto
500K2.79M500K2.39M2.48M2.50M2.62M2.73M2.79M
Ottawa
500K1.02M500K721K774K812K883K934K1.02M
Mississauga
500K722K500K544K613K669K713K722K718K
Brampton
500K656K268K268K325K434K524K594K656K
Hamilton
500K569K468K468K490K505K520K537K569K

The fixed 500,000 reference line provides a common benchmark across all municipalities.

10.5.2 Reference areas

Reference areas shade a horizontal band, marking a range of values:

illness |>
  dplyr::slice_head(n = 6) |>
  gt(rowname_col = "test") |>
  cols_hide(columns = c(starts_with("norm"), units)) |>
  cols_nanoplot(
    columns = starts_with("day"),
    reference_area = c("q1", "q3"),
    new_col_name = "trend",
    new_col_label = "Trend (IQR shaded)"
  ) |>
  cols_width(trend ~ px(130))
Trend (IQR shaded)
Viral load
12.0K25012.0K4.20K1.60K830760520250
WBC
30.34.265.264.269.9210.524.830.319.0
Neutrophils
27.24.724.874.727.9218.222.127.216.6
RBC
5.982.685.725.984.234.834.122.683.32
Hb
15375153135126115758795
PLT
74.125.667.038.627.426.274.136.225.6

The shaded area marks the interquartile range. Values within this band represent “typical” observations while those outside are relatively extreme.

You can combine keywords and fixed values:

towny |>
  dplyr::select(name, starts_with("density")) |>
  dplyr::slice_max(density_2021, n = 5) |>
  gt() |>
  fmt_number(columns = starts_with("density"), decimals = 0) |>
  cols_nanoplot(
    columns = starts_with("density"),
    reference_line = "median",
    reference_area = c("min", "max"),
    new_col_name = "trend",
    new_col_label = "Density Trend",
    options = nanoplot_options(
      reference_line_color = "darkred",
      reference_area_fill_color = "lightyellow"
    )
  ) |>
  cols_hide(columns = matches("199|200|201")) |>
  cols_width(trend ~ px(130))
name Density Trend
Toronto
4.06K4.43K3.78K3.78K3.93K3.97K4.14K4.33K4.43K
Brampton
1.80K2.47K1.01K1.01K1.22K1.63K1.97K2.23K2.47K
Mississauga
2.36K2.46K1.86K1.86K2.09K2.28K2.44K2.46K2.45K
Newmarket
2.00K2.28K1.48K1.48K1.71K1.93K2.08K2.19K2.28K
Richmond Hill
1.73K2.00K1.01K1.01K1.31K1.61K1.84K1.93K2.00K

The yellow area spans the full data range while the red line marks the median, showing both the overall scale and central tendency.

10.6 Handling missing values

Real data often contains missing values. The missing_vals argument controls how nanoplots handle NAs:

10.6.1 Gap strategy (default)

dplyr::tibble(
  item = c("A", "B", "C"),
  v1 = c(10, 8, NA),
  v2 = c(15, NA, 12),
  v3 = c(NA, 14, 15),
  v4 = c(20, 16, 18),
  v5 = c(18, 12, NA)
) |>
  gt(rowname_col = "item") |>
  tab_header(title = "Missing Values: Gap Strategy") |>
  cols_nanoplot(
    columns = starts_with("v"),
    missing_vals = "gap",
    new_col_name = "trend",
    new_col_label = "Values"
  )
Missing Values: Gap Strategy
Values
A
20101015NA2018
B
1688NA141612
C
1812NA121518NA

Gaps appear where data is missing. Lines discontinue and resume, clearly indicating where observations are absent.

10.6.2 Marker strategy

The marker strategy works like the gap strategy but adds special visual markers at the locations of missing values. This draws even more attention to the fact that data is absent:

dplyr::tibble(
  item = c("A", "B", "C"),
  v1 = c(10, 8, NA),
  v2 = c(15, NA, 12),
  v3 = c(NA, 14, 15),
  v4 = c(20, 16, 18),
  v5 = c(18, 12, NA)
) |>
  gt(rowname_col = "item") |>
  tab_header(title = "Missing Values: Marker Strategy") |>
  cols_nanoplot(
    columns = starts_with("v"),
    missing_vals = "marker",
    new_col_name = "trend",
    new_col_label = "Values"
  )
Missing Values: Marker Strategy
Values
A
20101015NA2018
B
1688NA141612
C
1812NA121518NA

Like gaps, but with prominent markers at missing data locations. This makes missingness even more visible.

10.6.3 Zero strategy

The zero strategy treats missing values as zeros. This approach is appropriate when absence of data genuinely means zero (for instance, missing order counts likely mean no orders occurred):

dplyr::tibble(
  item = c("A", "B", "C"),
  v1 = c(10, 8, NA),
  v2 = c(15, NA, 12),
  v3 = c(NA, 14, 15),
  v4 = c(20, 16, 18),
  v5 = c(18, 12, NA)
) |>
  gt(rowname_col = "item") |>
  tab_header(title = "Missing Values: Zero Strategy") |>
  cols_nanoplot(
    columns = starts_with("v"),
    missing_vals = "zero",
    new_col_name = "trend",
    new_col_label = "Values"
  )
Missing Values: Zero Strategy
Values
A
200101502018
B
16080141612
C
18001215180

Missing values are replaced with zeros. Use this when zeros are meaningful substitutes (e.g., missing sales might truly mean zero sales).

10.6.4 Remove strategy

The remove strategy excludes missing values entirely from the plot, connecting the remaining points directly. This can be useful when you want to focus only on observed values without calling attention to gaps:

dplyr::tibble(
  item = c("A", "B", "C"),
  v1 = c(10, 8, NA),
  v2 = c(15, NA, 12),
  v3 = c(NA, 14, 15),
  v4 = c(20, 16, 18),
  v5 = c(18, 12, NA)
) |>
  gt(rowname_col = "item") |>
  tab_header(title = "Missing Values: Remove Strategy") |>
  cols_nanoplot(
    columns = starts_with("v"),
    missing_vals = "remove",
    new_col_name = "trend",
    new_col_label = "Values"
  )
Missing Values: Remove Strategy
Values
A
201010152018
B
1688141612
C
1812121518

Missing values are simply removed, and remaining values connect directly. The plots may have different numbers of points, but no gaps appear.

10.7 Data input formats

Nanoplots accept data in two main formats: values spread across columns, or value streams in a single column.

10.7.1 Multi-column format

The examples above primarily used multi-column format, where each column contains one value per row:

exibble |>
  dplyr::select(row, num, currency) |>
  gt(rowname_col = "row") |>
  cols_nanoplot(
    columns = c(num, currency),
    new_col_name = "values",
    new_col_label = "Num & Currency"
  )
Num & Currency
row_1
50.00.110.1150.0
row_2
18.02.222.2218.0
row_3
33.31.3933.31.39
row_4
65.1K44444465.1K
row_5
5.55K1.33K5.55K1.33K
row_6
15.910.6NA13.3
row_7
932K622K777KNA
row_8
8.88M0.448.88M0.44

Values are collected left-to-right in the order columns are specified.

10.7.2 Value stream format

While spreading values across multiple columns works well when your data is already structured that way, there are situations where packing values into a single delimited string offers significant advantages. This “value stream” format is useful when different rows contain varying numbers of data points, when data arrives from external sources in this format, or when you want to keep your table structure simple without creating many intermediate columns that serve only as nanoplot inputs.

Value streams are particularly useful when working with time series data of irregular length, aggregated measurements, or any scenario where the number of observations varies by row. Instead of dealing with missing values in unused columns or complex data reshaping, you can store each row’s complete data series as a comma-separated (or space-separated, or semicolon-separated) string. gt parses these strings automatically, making it seamless to work with data in this format.

Value streams pack multiple values into a single column as delimited strings:

dplyr::tibble(
  product = c("Widget", "Gadget", "Gizmo"),
  weekly_sales = c(
    "120, 135, 142, 128, 156, 149, 163",
    "85, 92, 88, 95, 101, 98, 105",
    "210, 198, 225, 232, 218, 245, 238"
  )
) |>
  gt(rowname_col = "product") |>
  tab_header(title = "Weekly Sales Trends") |>
  cols_nanoplot(
    columns = weekly_sales,
    new_col_name = "trend",
    new_col_label = "7-Day Trend"
  ) |>
  cols_width(trend ~ px(120))
Weekly Sales Trends
7-Day Trend
Widget
163120120135142128156149163
Gadget
105858592889510198105
Gizmo
245198210198225232218245238

Commas, spaces, or semicolons can separate values. Value streams are useful when different rows have varying numbers of observations, since each row’s string can contain however many values exist for that row.

10.7.3 Datetime value streams

Value streams can also contain ISO 8601 datetimes, which are automatically converted to numeric values:

dplyr::tibble(
  event = c("Launch", "Update", "Sale"),
  timestamps = c(
    "2024-01-15 09:00:00, 2024-01-15 14:30:00, 2024-01-15 18:45:00",
    "2024-02-20 08:15:00, 2024-02-20 11:00:00, 2024-02-20 16:30:00, 2024-02-20 20:00:00",
    "2024-03-10 10:00:00, 2024-03-10 12:00:00, 2024-03-10 15:00:00"
  ),
  activity = c("50, 120, 85", "30, 75, 95, 60", "200, 350, 280")
) |>
  gt(rowname_col = "event") |>
  tab_header(title = "Activity Over Time") |>
  cols_nanoplot(
    columns = activity,
    columns_x_vals = timestamps,
    new_col_name = "timeline",
    new_col_label = "Activity Pattern"
  ) |>
  cols_width(timeline ~ px(140))
Activity Over Time
Activity Pattern
Launch
120505012085
Update
953030759560
Sale
350200200350280

The datetime strings become x-axis positions, accurately representing the timing of observations.

10.8 Autoscaling and axis control

By default, nanoplots scale independently: each plot adjusts its axis range to fit its own data. While this maximizes detail within each plot, it can mislead when comparing across rows. The autoscale option addresses this by forcing all nanoplots to share a common scale, and the expand_x and expand_y arguments allow you to set explicit axis ranges for even greater control.

10.8.1 Autoscaling across rows

By default, each nanoplot scales independently to its own data range. This maximizes visual variation within each plot but makes cross-row comparison difficult:

towny |>
  dplyr::select(name, starts_with("population")) |>
  dplyr::filter(population_2021 > 100000) |>
  dplyr::slice_head(n = 5) |>
  gt() |>
  fmt_integer(columns = starts_with("population")) |>
  cols_nanoplot(
    columns = starts_with("population"),
    autoscale = FALSE,  
    new_col_name = "trend",
    new_col_label = "Trend (independent scales)"
  ) |>
  cols_hide(columns = matches("199|200|201")) |>
  cols_width(trend ~ px(120))
name Trend (independent scales)
Ajax
127K64.4K64.4K73.8K90.2K110K120K127K
Barrie
148K79.2K79.2K104K128K136K141K148K
Brampton
656K268K268K325K434K524K594K656K
Brantford
105K84.8K84.8K86.4K90.2K93.6K98.6K105K
Burlington
187K137K137K151K164K176K183K187K

Each municipality’s plot fills its available space regardless of absolute population differences.

With autoscale = TRUE, all plots share the same y-axis scale:

towny |>
  dplyr::select(name, starts_with("population")) |>
  dplyr::filter(population_2021 > 100000) |>
  dplyr::slice_head(n = 5) |>
  gt() |>
  fmt_integer(columns = starts_with("population")) |>
  cols_nanoplot(
    columns = starts_with("population"),
    autoscale = TRUE,  
    new_col_name = "trend",
    new_col_label = "Trend (common scale)"
  ) |>
  cols_hide(columns = matches("199|200|201")) |>
  cols_width(trend ~ px(120))
name Trend (common scale)
Ajax
656K64.4K64.4K73.8K90.2K110K120K127K
Barrie
656K64.4K79.2K104K128K136K141K148K
Brampton
656K64.4K268K325K434K524K594K656K
Brantford
656K64.4K84.8K86.4K90.2K93.6K98.6K105K
Burlington
656K64.4K137K151K164K176K183K187K

Now the visual heights accurately represent population magnitudes relative to other rows. Toronto’s massive population dominates while smaller cities appear proportionally smaller.

10.8.2 Expanding axis ranges

The expand_x and expand_y arguments extend plot boundaries beyond the data range:

dplyr::tibble(
  scenario = c("Optimistic", "Baseline", "Pessimistic"),
  projections = c("105, 112, 120, 130", "100, 102, 105, 108", "100, 95, 88, 80")
) |>
  gt(rowname_col = "scenario") |>
  tab_header(title = "Revenue Projections (Indexed to 100)") |>
  cols_nanoplot(
    columns = projections,
    expand_y = c(70, 140),
    reference_line = 100,
    new_col_name = "projection",
    new_col_label = "4-Year Outlook"
  ) |>
  cols_width(projection ~ px(120))
Revenue Projections (Indexed to 100)
4-Year Outlook
Optimistic
10014070105112120130
Baseline
10014070100102105108
Pessimistic
10014070100958880

The fixed y-axis range (70140) and reference line at 100 provide consistent context across all scenarios, making it easy to see which projections exceed or fall below the baseline.

10.9 Column positioning and labeling

When cols_nanoplot() creates a new column, you control where it appears and what it’s called. The before and after arguments position the column relative to existing columns, while new_col_name and new_col_label set its internal name and display label.

10.9.1 Positioning the nanoplot column

The before and after arguments control where the new nanoplot column appears:

exibble |>
  dplyr::select(row, char, num, currency) |>
  gt(rowname_col = "row") |>
  cols_nanoplot(
    columns = c(num, currency),
    new_col_name = "values_plot",
    new_col_label = "Visual",
    after = "char"  
  )
char Visual
row_1 apricot
50.00.110.1150.0
row_2 banana
18.02.222.2218.0
row_3 coconut
33.31.3933.31.39
row_4 durian
65.1K44444465.1K
row_5 NA
5.55K1.33K5.55K1.33K
row_6 fig
15.910.6NA13.3
row_7 grapefruit
932K622K777KNA
row_8 honeydew
8.88M0.448.88M0.44

The nanoplot column appears immediately after char, placing related information together.

10.9.2 Custom column names and labels

The new_col_name and new_col_label arguments work together to give your nanoplot column both a practical internal identifier and a polished display label:

illness |>
  dplyr::slice_head(n = 5) |>
  gt(rowname_col = "test") |>
  cols_hide(columns = c(starts_with("norm"), units)) |>
  cols_nanoplot(
    columns = starts_with("day"),
    new_col_name = "weekly_progression",
    new_col_label = md("*7-Day Progression*")
  )
7-Day Progression
Viral load
12.0K25012.0K4.20K1.60K830760520250
WBC
30.34.265.264.269.9210.524.830.319.0
Neutrophils
27.24.724.874.727.9218.222.127.216.6
RBC
5.982.685.725.984.234.834.122.683.32
Hb
15375153135126115758795

The new_col_name sets the internal column name (useful for subsequent operations), while new_col_label sets the display label. Labels can include markdown formatting via md().

10.10 The nanoplot_options() helper

The nanoplot_options() function provides granular control over every visual aspect of nanoplots. Let’s explore the major option categories:

Function Signature

nanoplot_options(
  # Data point options
  data_point_radius = NULL,
  data_point_stroke_color = NULL,
  data_point_stroke_width = NULL,
  data_point_fill_color = NULL,
  
  # Data line options
  data_line_type = NULL,
  data_line_stroke_color = NULL,
  data_line_stroke_width = NULL,
  
  # Data area options
  data_area_fill_color = NULL,
  
  # Bar options (positive values)
  data_bar_stroke_color = NULL,
  data_bar_stroke_width = NULL,
  data_bar_fill_color = NULL,
  
  # Bar options (negative values)
  data_bar_negative_stroke_color = NULL,
  data_bar_negative_stroke_width = NULL,
  data_bar_negative_fill_color = NULL,
  
  # Reference elements
  reference_line_color = NULL,
  reference_area_fill_color = NULL,
  
  # Interactive guides
  vertical_guide_stroke_color = NULL,
  vertical_guide_stroke_width = NULL,
  
  # Layer visibility
  show_data_points = NULL,
  show_data_line = NULL,
  show_data_area = NULL,
  show_reference_line = NULL,
  show_reference_area = NULL,
  show_vertical_guides = NULL,
  show_y_axis_guide = NULL,
  
  # Value formatting
  interactive_data_values = NULL,
  y_val_fmt_fn = NULL,
  y_axis_fmt_fn = NULL,
  y_ref_line_fmt_fn = NULL,
  currency = NULL
)

10.10.1 Per-point styling

Some options accept vectors to style individual data points differently:

dplyr::tibble(
    quarter = c("Q1", "Q2", "Q3", "Q4"),
    revenue = c("100, 110, 105, 120")
) |>
    gt(rowname_col = "quarter") |>
    cols_nanoplot(
        columns = revenue,
        new_col_name = "trend",
        options = nanoplot_options(
            data_point_fill_color = c("gray", "gray", "gray", "gold"),
            data_point_radius = c(5, 5, 5, 10),
            data_point_stroke_color = "black",
            data_line_stroke_color = "gray"
        )
    )
trend
Q1
120100100110105120
Q2
120100100110105120
Q3
120100100110105120
Q4
120100100110105120

The final point (Q4) is highlighted with a larger golden marker (look at the final values in the data_point_fill_color and data_point_radius arguments), drawing attention to the most recent value.

10.10.2 Reusable option sets

When creating tables with multiple nanoplot columns, you often want them to share a consistent visual style. Rather than duplicating the same nanoplot_options() specification for each column, you can define an option set once and reuse it. This approach offers several benefits: it reduces code repetition, ensures perfect visual consistency across columns, and makes style updates trivial (change the definition once rather than hunting through multiple cols_nanoplot() calls).

Reusable option sets are particularly valuable when building themed tables or when working with organizational style guidelines. You might define several standard option sets ("minimal", "detailed", "dashboard", etc.) and apply them consistently across different tables and reports. This creates visual coherence across your work while keeping the implementation clean and maintainable.

Create option sets once and apply them across multiple nanoplot columns:

minimal_style <- nanoplot_options(
  show_data_area = FALSE,
  show_data_points = FALSE,
  data_line_stroke_width = px(2),
  data_line_stroke_color = "#333333",
  data_line_type = "straight"
)

towny |>
  dplyr::select(name, starts_with("population"), starts_with("density")) |>
  dplyr::slice_max(population_2021, n = 4) |>
  gt() |>
  cols_nanoplot(
    columns = starts_with("population"),
    new_col_name = "pop_trend",
    new_col_label = "Population",
    options = minimal_style
  ) |>
  cols_nanoplot(
    columns = starts_with("density"),
    new_col_name = "dens_trend",
    new_col_label = "Density",
    options = minimal_style
  ) |>
  cols_hide(columns = -c(name, pop_trend, dens_trend))
name Population Density
Toronto
2.79M2.39M2.39M2.48M2.50M2.62M2.73M2.79M
4.43K3.78K3.78K3.93K3.97K4.14K4.33K4.43K
Ottawa
1.02M721K721K774K812K883K934K1.02M
365259259278291317335365
Mississauga
722K544K544K613K669K713K722K718K
2.46K1.86K1.86K2.09K2.28K2.44K2.46K2.45K
Brampton
656K268K268K325K434K524K594K656K
2.47K1.01K1.01K1.22K1.63K1.97K2.23K2.47K

Both nanoplot columns share the same minimal styling, creating visual consistency across the table.

10.10.3 Currency formatting

When nanoplots display financial data, proper currency formatting in tooltips and hover displays makes the values immediately understandable. Rather than seeing raw numbers like “1350” or “12000”, users see properly formatted currency values like "$1,350" or "$12,000". This formatting applies to the interactive elements of the nanoplot: when users hover over data points, reference lines, or other interactive features, the displayed values respect the currency specification.

The currency option in nanoplot_options() accepts standard three-letter currency codes ("USD", "EUR", "GBP", "JPY", etc.) and automatically applies appropriate formatting rules for that currency, including the correct symbol, decimal places, and thousands separators. This ensures that financial nanoplots maintain the same level of polish and professionalism as the rest of your formatted table columns.

For financial data, specify a currency code:

dplyr::tibble(
  product = c("Basic", "Pro", "Enterprise"),
  monthly_revenue = c(
    "1200, 1350, 1280, 1420, 1510",
    "4500, 4800, 5100, 4950, 5300",
    "12000, 13500, 14200, 15800, 16500"
  )
) |>
  gt(rowname_col = "product") |>
  tab_header(title = "Monthly Revenue Trends") |>
  cols_nanoplot(
    columns = monthly_revenue,
    reference_line = "mean",
    new_col_name = "trend",
    new_col_label = "5-Month Trend",
    options = nanoplot_options(currency = "USD")
  ) |>
  cols_width(trend ~ px(140))
Monthly Revenue Trends
5-Month Trend
Basic
$1.35K$1.51K$1.20K$1.20K$1.35K$1.28K$1.42K$1.51K
Pro
$4.93K$5.30K$4.50K$4.50K$4.80K$5.10K$4.95K$5.30K
Enterprise
$14.4K$16.5K$12.0K$12.0K$13.5K$14.2K$15.8K$16.5K

Hovering over data points displays values formatted as currency (e.g., "$1,350" instead of "1350").

10.11 Practical examples

Nanoplots shine when they combine multiple techniques like autoscaling for valid comparisons, reference elements for context, custom styling for clarity, and meticulous positioning for narrative flow. These examples demonstrate complete workflows that bring together the concepts covered in this chapter.

10.11.1 Sparkline summary table

This example combines monthly summary statistics with daily price trend sparklines, providing both high-level metrics and visual context:

sp500 |>
  dplyr::filter(date >= "2015-01-01" & date <= "2015-12-31") |>
  dplyr::mutate(month = format(date, "%B")) |>
  dplyr::mutate(month = factor(month, levels = month.name)) |>
  dplyr::summarize(
    open = first(open),
    close = last(close),
    high = max(high),
    low = min(low),
    prices = paste(close, collapse = ","),
    .by = month
  ) |>
  dplyr::arrange(month) |>
  gt(rowname_col = "month") |>
  tab_header(
    title = "S&P 500 Monthly Summary",
    subtitle = "2015 Calendar Year"
  ) |>
  fmt_currency(columns = c(open, close, high, low), decimals = 0) |>
  cols_nanoplot(
    columns = prices,
    new_col_name = "daily_trend",
    new_col_label = "Daily Closes",
    options = nanoplot_options(
      show_data_points = FALSE,
      show_data_area = FALSE,
      data_line_stroke_color = "steelblue",
      data_line_stroke_width = 1.5
    )
  ) |>
  cols_width(daily_trend ~ px(100)) |>
  cols_move(columns = daily_trend, after = open)
S&P 500 Monthly Summary
2015 Calendar Year
open Daily Closes close high low
January $2,019
2.47K1.65K2.06K
$2,058 $2,072 $1,988
February $2,111
2.43K1.62K2.02K
$2,021 $2,120 $1,981
March $2,084
2.54K1.69K2.12K
$2,117 $2,118 $2,040
April $2,106
2.47K1.65K2.06K
$2,060 $2,126 $2,048
May $2,121
2.53K1.69K2.11K
$2,108 $2,135 $2,068
June $2,061
2.53K1.69K2.11K
$2,112 $2,130 $2,056
July $2,112
2.49K1.66K2.08K
$2,077 $2,133 $2,044
August $1,987
2.52K1.68K2.10K
$2,098 $2,113 $1,867
September $1,887
2.30K1.53K1.91K
$1,914 $2,021 $1,872
October $2,090
2.31K1.54K1.92K
$1,924 $2,094 $1,894
November $2,091
2.52K1.68K2.10K
$2,104 $2,116 $2,019
December $2,061
2.52K1.68K2.10K
$2,103 $2,104 $1,993

This financial summary table combines key statistics with a visual representation of daily price movements. The sparkline provides trend context that complements the summary figures.

10.11.2 Distribution comparison table

Box plot nanoplots are great at comparing distributions across groups, revealing differences in central tendency, spread, and outliers:

set.seed(23)

dplyr::tibble(
  treatment = c("Control", "Drug A", "Drug B", "Drug C"),
  responses = purrr::map_chr(
    c(50, 55, 48, 62),
    ~ paste(round(rnorm(25, mean = .x, sd = 10), 1), collapse = ",")
  )
) |>
  gt(rowname_col = "treatment") |>
  tab_header(title = "Treatment Response Distributions") |>
  cols_nanoplot(
    columns = responses,
    plot_type = "boxplot",
    autoscale = TRUE,
    new_col_name = "distribution",
    new_col_label = "Response Distribution"
  ) |>
  cols_width(distribution ~ px(200))
Treatment Response Distributions
Response Distribution
Control
37.844.851.959.167.9
Drug A
38.748.455.060.168.3
Drug B
28.042.448.055.566.3
Drug C
41.557.766.669.581.8

Box plots reveal distributional differences between treatments (not just central tendency but spread and outliers). The shared scale (via autoscale) enables valid visual comparison.

10.11.3 Multi-metric dashboard row

This dashboard-style table demonstrates how multiple nanoplot columns with different plot types and color schemes can work together to tell a comprehensive story:

# Simulated performance metrics
dplyr::tibble(
    server = c("prod-1", "prod-2", "prod-3"),
    cpu = c("45,52,48,61,55,49,53", "78,82,79,85,81,77,80", "32,35,38,34,36,33,37"),
    memory = c("62,65,64,68,66,63,67", "71,74,72,76,73,70,75", "55,58,56,60,57,54,59"),
    requests = c(
        "1200,1350,1280,1420,1380,1250,1310",
        "890,920,905,940,915,885,910",
        "1580,1620,1595,1650,1610,1570,1605"
    )
) |>
    gt(rowname_col = "server") |>
    tab_header(title = "Server Performance (Last 7 Hours)") |>
    cols_nanoplot(
        columns = cpu,
        autoscale = TRUE,
        new_col_name = "cpu_plot",
        new_col_label = "CPU %",
        options = nanoplot_options(
            show_data_area = FALSE,
            data_line_stroke_color = "#E74C3C",
            data_point_fill_color = "#E74C3C"
        )
    ) |>
    cols_nanoplot(
        columns = memory,
        autoscale = TRUE,
        new_col_name = "mem_plot",
        new_col_label = "Memory %",
        options = nanoplot_options(
            show_data_area = FALSE,
            data_line_stroke_color = "#3498DB",
            data_point_fill_color = "#3498DB"
        )
    ) |>
    cols_nanoplot(
        columns = requests,
        plot_type = "bar",
        autoscale = TRUE,
        new_col_name = "req_plot",
        new_col_label = "Requests/hr",
        options = nanoplot_options(
            data_bar_fill_color = "#2ECC71",
            data_bar_stroke_color = "#27AE60"
        )
    ) |>
    cols_width(
        c(cpu_plot, mem_plot) ~ px(100),
        req_plot ~ px(80)
    )
Server Performance (Last 7 Hours)
CPU % Memory % Requests/hr
prod-1
853245524861554953
765462656468666367
1.65K01.20K1.35K1.28K1.42K1.38K1.25K1.31K
prod-2
853278827985817780
765471747276737075
1.65K0890920905940915885910
prod-3
853232353834363337
765455585660575459
1.65K01.58K1.62K1.60K1.65K1.61K1.57K1.60K

This dashboard-style table uses color-coded nanoplots to show multiple metrics per server. Line plots suit the percentage metrics while bar plots work well for discrete request counts.

Nanoplots transform tables from static data presentations into dynamic visual summaries. By embedding trend lines, distributions, and comparisons directly within table rows, they enable readers to grasp patterns at a glance while retaining access to precise numeric values. The extensive customization options ensure that nanoplots can be tailored to match any design aesthetic or analytical purpose.

10.12 Summary

This chapter has explored nanoplots: compact visualizations that embed directly within table cells, bridging the gap between tabular precision and visual pattern recognition.

The key capabilities we’ve covered:

  • plot types: line plots show trends over time or sequence, bar plots display discrete comparisons, and box plots summarize distributions. Each type serves different analytical purposes.
  • the cols_nanoplot() function creates a new column of visualizations from numeric data in existing columns. It handles data collection, plot generation, and column placement automatically.
  • data formats: nanoplots accept data as separate columns, comma-separated strings within cells, or explicit x-y value pairs. This flexibility accommodates various data structures.
  • reference elements: reference lines and reference areas add context to plots, showing targets, thresholds, or acceptable ranges that help readers interpret the visualizations.
  • customization: the nanoplot_options() helper provides extensive control over colors, sizes, strokes, and display elements. You can match nanoplots to your table’s overall design aesthetic.
  • missing data handling: the missing_vals argument controls how gaps appear in plots (as breaks, markers, zeros, or removed points).
  • autoscaling: when comparing across rows, autoscale = TRUE ensures all plots share the same axis ranges, making visual comparisons meaningful.

Nanoplots work best when they complement rather than replace numeric values. A trend line next to quarterly figures helps readers see the trajectory. A distribution box plot alongside summary statistics reveals shape. The combination of numbers and graphics creates tables that inform at multiple levels of detail.

The next chapter introduces table groups, which let you work with multiple related tables as a cohesive unit. You’ll learn to bundle tables together, apply common styling, and output them as coordinated sets.