9  Tables

Counts, summaries, publication tables, and export

Tables are part of a visualization workflow. A plot is often the fastest way to show a pattern, but a table is often the clearest way to show exact values, counts, summaries, and compact descriptions of a dataset.

Table or plot? Use a plot when the reader needs to grasp a shape — a trend, a distribution, a cluster. Use a table when the reader needs to look up a specific number, compare exact values across categories, or read a ranked list. The two formats are complements, not competitors.

This chapter starts with quick exploratory tables and then moves toward tables that belong in reports, slides, papers, and appendices.

9.1 Which tool for which table

R has several strong table packages. A quick orientation before diving in:

  • Base R table() — quick Console frequency counts; no dependencies.
  • janitor::tabyl() — counts, percentages, totals, and crosstabs in tidy pipelines.
  • tinytable — compact publication-style display; works across HTML, PDF, and Word.
  • modelsummary — descriptive summaries and formatted crosstabs.
  • gt — highly designed HTML tables with fine-grained cell control.
  • flextable — Word output as the primary target.
  • DT — interactive search and pagination in HTML output.

You do not need all of them. The choice depends on what kind of table you need: quick exploration, descriptive summary, publication display, or interactive browsing.

library(tidyverse)
library(scales)
library(janitor)
library(tinytable)
library(modelsummary)
library(corrr)

9.2 A first frequency table

Base R has a built-in function called table(). It counts how often each value appears.

table(mtcars$cyl)

 4  6  8 
11  7 14 

This table counts the number of cars in mtcars with 4, 6, and 8 cylinders.

A two-way table counts combinations of two variables.

table(mtcars$cyl, mtcars$gear)
   
     3  4  5
  4  1  8  2
  6  2  4  1
  8 12  0  2

The output is useful, but the labels are not very descriptive. We can improve the row and column labels before printing the table.

cylinders_gear <- table(mtcars$cyl, mtcars$gear)

dimnames(cylinders_gear) <- list(
  "Cylinders" = paste(rownames(cylinders_gear), "cyl"),
  "Gears" = paste(colnames(cylinders_gear), "gears")
)

cylinders_gear
         Gears
Cylinders 3 gears 4 gears 5 gears
    4 cyl       1       8       2
    6 cyl       2       4       1
    8 cyl      12       0       2

Base R tables are dependable and fast. They are especially useful when you are exploring data in the Console. For tables that will appear in a report, other packages give us cleaner formatting and easier percentages.

9.3 Cleaner crosstabs with janitor

The janitor package has a function called tabyl(). It behaves like a modern version of table(): it counts values, keeps the output as a data frame, and works well in tidyverse pipelines.

We will use a local copy of the Gapminder data.

The function glimpse() comes from dplyr, which is loaded as part of the tidyverse.

gapminder <- readr::read_csv("Data/gapminder/gapminder.csv")

glimpse(gapminder)
Rows: 1,704
Columns: 6
$ country   <chr> "Afghanistan", "Afghanistan", "Afghanistan", "Afghanistan", …
$ continent <chr> "Asia", "Asia", "Asia", "Asia", "Asia", "Asia", "Asia", "Asi…
$ year      <dbl> 1952, 1957, 1962, 1967, 1972, 1977, 1982, 1987, 1992, 1997, …
$ lifeExp   <dbl> 28.801, 30.332, 31.997, 34.020, 36.088, 38.438, 39.854, 40.8…
$ pop       <dbl> 8425333, 9240934, 10267083, 11537966, 13079460, 14880372, 12…
$ gdpPercap <dbl> 779.4453, 820.8530, 853.1007, 836.1971, 739.9811, 786.1134, …

A one-variable tabyl() gives counts and proportions.

gapminder |>
  tabyl(continent)

A two-variable tabyl() gives a crosstab.

gapminder |>
  tabyl(continent, year)

This table counts country-year observations by continent and year. A different kind of crosstab uses a category we create ourselves. Here we classify countries by whether life expectancy in 2007 was at least 70 years.

gapminder_2007 <- gapminder |>
  filter(year == 2007) |>
  mutate(
    life_expectancy_group = if_else(lifeExp >= 70, "70 years or more", "Under 70 years")
  )

gapminder_2007 |>
  tabyl(continent, life_expectancy_group)

The adorn_*() functions add totals, percentages, and count labels.

gapminder_2007 |>
  tabyl(continent, life_expectancy_group) |>
  adorn_totals(c("row", "col")) |>
  adorn_percentages("row") |>
  adorn_pct_formatting(digits = 1) |>
  adorn_ns()

Use row percentages when the row categories are your comparison groups. In this case, the question is: within each continent, what share of countries had life expectancy of at least 70 years?

Column percentages answer a different question.

gapminder_2007 |>
  tabyl(continent, life_expectancy_group) |>
  adorn_totals(c("row", "col")) |>
  adorn_percentages("col") |>
  adorn_pct_formatting(digits = 1) |>
  adorn_ns()

Here the question is: within each life expectancy group, what share of countries came from each continent?

tabyl() can extend to a third variable. The result is a list of two-way tables, one for each value of the third variable — useful for showing how a crosstab changes across time or groups.

gapminder |>
  filter(year %in% c(1952, 2007)) |>
  mutate(
    life_expectancy_group = if_else(lifeExp >= 70, "70 years or more", "Under 70 years")
  ) |>
  tabyl(continent, life_expectancy_group, year)
$`1952`
 continent 70 years or more Under 70 years
    Africa                0             52
  Americas                0             25
      Asia                0             33
    Europe                5             25
   Oceania                0              2

$`2007`
 continent 70 years or more Under 70 years
    Africa                7             45
  Americas               22              3
      Asia               22             11
    Europe               30              0
   Oceania                2              0

If the three-way result feels dense, split it into separate tables or make a faceted plot instead.

9.4 From summarize to table

A common workflow is to compute a summary with group_by() and summarize() and then format the result as a display table. The two steps are separate: the first produces a data frame, the second formats it.

continent_summary <- gapminder |>
  filter(year == 2007) |>
  group_by(continent) |>
  summarize(
    countries      = n(),
    median_life    = median(lifeExp),
    median_gdp     = median(gdpPercap)
  )

continent_summary

Pass the result directly to tt() for a formatted table. Rename the columns before calling tt() so the display names are readable.

continent_summary |>
  rename(
    "Continent"              = continent,
    "Countries"              = countries,
    "Median life expectancy" = median_life,
    "Median GDP per capita"  = median_gdp
  ) |>
  tt(
    caption = "Summary statistics by continent, 2007",
    digits  = 1
  )
Summary statistics by continent, 2007
Continent Countries Median life expectancy Median GDP per capita
Africa 52 53 1452
Americas 25 73 8948
Asia 33 72 4471
Europe 30 79 28054
Oceania 2 81 29810

Renaming columns in the data frame before calling tt() is the most reliable way to control display names across output formats.

9.5 Reshaping for tables

Long-format data is easy to compute on but often hard to read as a table. A summary grouped by two variables — say, continent and year — has one row per combination, which makes for a long, narrow table.

life_by_year <- gapminder |>
  filter(year %in% c(1957, 1977, 1997, 2007)) |>
  group_by(continent, year) |>
  summarize(median_life = median(lifeExp), .groups = "drop")

life_by_year

The same continent appears in four rows. Pivot year into columns to put a continent’s trajectory in one row.

life_by_year |>
  pivot_wider(names_from = year, values_from = median_life) |>
  rename("Continent" = continent) |>
  tt(
    caption = "Median life expectancy by continent, selected years",
    digits  = 1
  )
Median life expectancy by continent, selected years
Continent 1957 1977 1997 2007
Africa 41 49 53 53
Americas 56 66 72 73
Asia 48 61 70 72
Europe 68 72 76 79
Oceania 70 73 78 81

Long format is right for computation and ggplot; wide format is often right for display.

9.6 Formatting numbers

Raw R numbers — 1234.5678, 0.043, 1.23e+09 — are often not the right form for a published table. The scales package provides helpers that turn numbers into currency, percentages, comma-separated thousands, and other common formats. Apply them with mutate() before passing the data frame to a display function.

continent_summary |>
  mutate(
    countries   = comma(countries),
    median_life = number(median_life, accuracy = 0.1),
    median_gdp  = comma(median_gdp, accuracy = 1)
  ) |>
  rename(
    "Continent"              = continent,
    "Countries"              = countries,
    "Median life expectancy" = median_life,
    "Median GDP per capita"  = median_gdp
  ) |>
  tt(caption = "Summary statistics by continent, 2007")
Summary statistics by continent, 2007
Continent Countries Median life expectancy Median GDP per capita
Africa 52 52.9 1,452
Americas 25 72.9 8,948
Asia 33 72.4 4,471
Europe 30 78.6 28,054
Oceania 2 80.7 29,810

A few common formatters from scales:

  • dollar() — currency with $ and commas
  • percent() — multiplies by 100 and appends %
  • comma() — comma-separated thousands
  • number(accuracy = 0.1) — controls decimal places

Formatting in the data frame works across any table package. As an alternative, tinytable::format_tt() formats inside the table object while keeping the underlying column numeric.

9.7 Publication-style data tables with tinytable

tinytable is useful when the table needs to look good in a document. It starts with a data frame and returns a formatted table.

vehicle_sample <- mtcars |>
  rownames_to_column("car") |>
  slice_sample(n = 6) |>
  select(car, mpg, cyl, disp, hp, wt)

tt(vehicle_sample)
car mpg cyl disp hp wt
Toyota Corona 21.5 4 120.1 97 2.465
Lotus Europa 30.4 4 95.1 113 1.513
Chrysler Imperial 14.7 8 440.0 230 5.345
Dodge Challenger 15.5 8 318.0 150 3.520
Honda Civic 30.4 4 75.7 52 1.615
Camaro Z28 13.3 8 350.0 245 3.840

Add a caption and format the numbers.

tt(
  vehicle_sample,
  caption = "A Small Sample of Vehicle Characteristics",
  digits = 1
)
A Small Sample of Vehicle Characteristics
car mpg cyl disp hp wt
Toyota Corona 22 4 120 97 2
Lotus Europa 30 4 95 113 2
Chrysler Imperial 15 8 440 230 5
Dodge Challenger 16 8 318 150 4
Honda Civic 30 4 76 52 2
Camaro Z28 13 8 350 245 4

You can add notes to explain the data source or a measurement choice.

tt(
  vehicle_sample,
  caption = "A Small Sample of Vehicle Characteristics",
  digits = 1,
  notes = "Data are from the built-in mtcars dataset."
)
A Small Sample of Vehicle Characteristics
car mpg cyl disp hp wt
Data are from the built-in mtcars dataset.
Toyota Corona 22 4 120 97 2
Lotus Europa 30 4 95 113 2
Chrysler Imperial 15 8 440 230 5
Dodge Challenger 16 8 318 150 4
Honda Civic 30 4 76 52 2
Camaro Z28 13 8 350 245 4

You can also style headers and selected cells.

tt(vehicle_sample, digits = 1) |>
  style_tt(i = 0, color = "white", background = "steelblue", fontweight = "bold") |>
  style_tt(i = 2, j = 4, background = "lightyellow")
car mpg cyl disp hp wt
Toyota Corona 22 4 120 97 2
Lotus Europa 30 4 95 113 2
Chrysler Imperial 15 8 440 230 5
Dodge Challenger 16 8 318 150 4
Honda Civic 30 4 76 52 2
Camaro Z28 13 8 350 245 4

Column groups help when several variables belong together.

tt(vehicle_sample, digits = 1) |>
  group_tt(
    j = list(
      "Identity" = 1,
      "Engine and Performance" = 2:6
    )
  )
Identity Engine and Performance
car mpg cyl disp hp wt
Toyota Corona 22 4 120 97 2
Lotus Europa 30 4 95 113 2
Chrysler Imperial 15 8 440 230 5
Dodge Challenger 16 8 318 150 4
Honda Civic 30 4 76 52 2
Camaro Z28 13 8 350 245 4

9.8 Conditional formatting

A table reads faster when extreme values stand out. tinytable::style_tt() accepts row and column indices to apply background colors, font weights, and other styles. Combine it with which.max() and which.min() to color cells based on the data itself.

wide_life <- life_by_year |>
  pivot_wider(names_from = year, values_from = median_life) |>
  rename("Continent" = continent)

highest_2007 <- which.max(wide_life[["2007"]])
lowest_2007  <- which.min(wide_life[["2007"]])

tt(
  wide_life,
  digits  = 1,
  caption = "Highest and lowest continent in 2007 highlighted"
) |>
  style_tt(i = highest_2007, background = "#cde7c2") |>
  style_tt(i = lowest_2007, background = "#f7c8c2")
Highest and lowest continent in 2007 highlighted
Continent 1957 1977 1997 2007
Africa 41 49 53 53
Americas 56 66 72 73
Asia 48 61 70 72
Europe 68 72 76 79
Oceania 70 73 78 81

Highlighting works best when one or two values per table need attention. Past three or four, the colors compete with each other and the reader loses the cue.

9.9 Summary tables with modelsummary

The modelsummary package includes useful functions for descriptive statistics. The function datasummary_skim() comes from modelsummary.

gapminder_2007 |>
  select(continent, lifeExp, gdpPercap) |>
  datasummary_skim()
Unique Missing Pct. Mean SD Min Median Max Histogram
lifeExp 142 0 67.0 12.1 39.6 71.9 82.6
gdpPercap 142 0 11680.1 12859.9 277.6 6124.4 49357.2
continent N %
Africa 52 36.6
Americas 25 17.6
Asia 33 23.2
Europe 30 21.1
Oceania 2 1.4

The default skim table is good for numeric variables. Categorical variables can be summarized separately.

gapminder_2007 |>
  select(continent, life_expectancy_group) |>
  datasummary_skim(type = "categorical")
N %
continent Africa 52 36.6
Americas 25 17.6
Asia 33 23.2
Europe 30 21.1
Oceania 2 1.4
life_expectancy_group 70 years or more 83 58.5
Under 70 years 59 41.5

datasummary_crosstab() creates crosstabs using a formula. The left side of the formula becomes the rows. The right side becomes the columns.

datasummary_crosstab(
  continent ~ life_expectancy_group,
  data = gapminder_2007
)
continent 70 years or more Under 70 years All
Africa N 7 45 52
% row 13.5 86.5 100.0
Americas N 22 3 25
% row 88.0 12.0 100.0
Asia N 22 11 33
% row 66.7 33.3 100.0
Europe N 30 0 30
% row 100.0 0.0 100.0
Oceania N 2 0 2
% row 100.0 0.0 100.0
All N 83 59 142
% row 58.5 41.5 100.0

9.10 Correlation tables with corrr

A correlation table inspects linear relationships among numeric variables. The corrr package is built for correlation workflows: correlate() computes the matrix, and helpers like shave() and fashion() format it for display.

correlation_data <- gapminder_2007 |>
  transmute(
    `Life expectancy`    = lifeExp,
    `Log GDP per capita` = log10(gdpPercap),
    `Log population`     = log10(pop)
  )

correlation_data |>
  correlate(quiet = TRUE) |>
  fashion(decimals = 2)
correlation_data |>
  correlate(quiet = TRUE) |>
  shave() |>
  fashion(decimals = 2)

shave() hides the redundant upper triangle and fashion() rounds and right-pads the numbers so the columns align cleanly.

For a publication version, pass the result of correlate() to tt().

correlation_data |>
  correlate(quiet = TRUE, diagonal = 1) |>
  tt(
    caption = "Correlations among Gapminder variables, 2007",
    digits  = 2
  )
Correlations among Gapminder variables, 2007
term Life expectancy Log GDP per capita Log population
Life expectancy 1 0.809 0.065
Log GDP per capita 0.809 1 -0.046
Log population 0.065 -0.046 1

9.10.1 As a heatmap

The same correlations can be shown as a heatmap. Color makes direction and strength easier to scan than rows of numbers.

correlation_table <- correlation_data |>
  correlate(quiet = TRUE, diagonal = 1) |>
  stretch()

ggplot(correlation_table, aes(x = x, y = y, fill = r)) +
  geom_tile(color = "white", linewidth = 1) +
  geom_text(aes(label = sprintf("%.2f", r)), size = 4) +
  scale_fill_gradient2(
    low    = "firebrick",
    mid    = "white",
    high   = "steelblue",
    limits = c(-1, 1),
    name   = "Correlation"
  ) +
  labs(
    title = "Correlations Among Gapminder Variables",
    x = NULL,
    y = NULL
  ) +
  coord_equal() +
  theme_minimal() +
  theme(
    panel.grid  = element_blank(),
    axis.text.x = element_text(angle = 30, hjust = 1)
  )

A small number of variables fits cleanly in a table; once there are eight or ten, the heatmap usually reads faster.

9.11 Cross-referencing tables in text

Quarto can number tables automatically and let you refer to them by name. Add a label: and tbl-cap: to the chunk that produces the table, then use @tbl-... in prose.


::: {#tbl-continents .cell tbl-cap='Summary statistics by continent, 2007'}

```{.r .cell-code}
continent_summary |>
  tt(digits = 1)
```

::: {.cell-output-display}

```{=html}
<!-- preamble start -->

    <script src="https://cdn.jsdelivr.net/gh/vincentarelbundock/tinytable@main/inst/tinytable.js"></script>

    <script>
      // Create table-specific functions using external factory
      const tableFns_qarvnvmxtk3m824n76jm = TinyTable.createTableFunctions("tinytable_qarvnvmxtk3m824n76jm");
      // tinytable span after
      window.addEventListener('load', function () {
          var cellsToStyle = [
            // tinytable style arrays after
          { positions: [ { i: '5', j: 1 }, { i: '5', j: 2 }, { i: '5', j: 3 }, { i: '5', j: 4 } ], css_id: 'tinytable_css_se0zpeguxz9qiqx8wnwv',}, 
          { positions: [ { i: '0', j: 1 }, { i: '0', j: 2 }, { i: '0', j: 3 }, { i: '0', j: 4 } ], css_id: 'tinytable_css_pebm7962340rmvlvukh6',}, 
          ];

          // Loop over the arrays to style the cells
          cellsToStyle.forEach(function (group) {
              group.positions.forEach(function (cell) {
                  tableFns_qarvnvmxtk3m824n76jm.styleCell(cell.i, cell.j, group.css_id);
              });
          });
      });
    </script>

    <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/vincentarelbundock/tinytable@main/inst/tinytable.css">
    <style>
    /* tinytable css entries after */
    #tinytable_qarvnvmxtk3m824n76jm td.tinytable_css_se0zpeguxz9qiqx8wnwv, #tinytable_qarvnvmxtk3m824n76jm th.tinytable_css_se0zpeguxz9qiqx8wnwv {  position: relative; --border-bottom: 1; --border-left: 0; --border-right: 0; --border-top: 0; --line-color-bottom: var(--tt-line-color); --line-color-left: var(--tt-line-color); --line-color-right: var(--tt-line-color); --line-color-top: var(--tt-line-color); --line-width-bottom: 0.08em; --line-width-left: 0.1em; --line-width-right: 0.1em; --line-width-top: 0.1em; --trim-bottom-left: 0%; --trim-bottom-right: 0%; --trim-left-bottom: 0%; --trim-left-top: 0%; --trim-right-bottom: 0%; --trim-right-top: 0%; --trim-top-left: 0%; --trim-top-right: 0%;  }
    #tinytable_qarvnvmxtk3m824n76jm td.tinytable_css_pebm7962340rmvlvukh6, #tinytable_qarvnvmxtk3m824n76jm th.tinytable_css_pebm7962340rmvlvukh6 {  position: relative; --border-bottom: 1; --border-left: 0; --border-right: 0; --border-top: 1; --line-color-bottom: var(--tt-line-color); --line-color-left: var(--tt-line-color); --line-color-right: var(--tt-line-color); --line-color-top: var(--tt-line-color); --line-width-bottom: 0.05em; --line-width-left: 0.1em; --line-width-right: 0.1em; --line-width-top: 0.08em; --trim-bottom-left: 0%; --trim-bottom-right: 0%; --trim-left-bottom: 0%; --trim-left-top: 0%; --trim-right-bottom: 0%; --trim-right-top: 0%; --trim-top-left: 0%; --trim-top-right: 0%;  }
    </style>
    <div class="container">
      <table class="tinytable" id="tinytable_qarvnvmxtk3m824n76jm" style="width: auto; margin-left: auto; margin-right: auto;" data-quarto-disable-processing='true'>
        
        <thead>
              <tr>
                <th scope="col" data-row="0" data-col="1">continent</th>
                <th scope="col" data-row="0" data-col="2">countries</th>
                <th scope="col" data-row="0" data-col="3">median_life</th>
                <th scope="col" data-row="0" data-col="4">median_gdp</th>
              </tr>
        </thead>
        
        <tbody>
                <tr>
                  <td data-row="1" data-col="1">Africa</td>
                  <td data-row="1" data-col="2">52</td>
                  <td data-row="1" data-col="3">53</td>
                  <td data-row="1" data-col="4">1452</td>
                </tr>
                <tr>
                  <td data-row="2" data-col="1">Americas</td>
                  <td data-row="2" data-col="2">25</td>
                  <td data-row="2" data-col="3">73</td>
                  <td data-row="2" data-col="4">8948</td>
                </tr>
                <tr>
                  <td data-row="3" data-col="1">Asia</td>
                  <td data-row="3" data-col="2">33</td>
                  <td data-row="3" data-col="3">72</td>
                  <td data-row="3" data-col="4">4471</td>
                </tr>
                <tr>
                  <td data-row="4" data-col="1">Europe</td>
                  <td data-row="4" data-col="2">30</td>
                  <td data-row="4" data-col="3">79</td>
                  <td data-row="4" data-col="4">28054</td>
                </tr>
                <tr>
                  <td data-row="5" data-col="1">Oceania</td>
                  <td data-row="5" data-col="2">2</td>
                  <td data-row="5" data-col="3">81</td>
                  <td data-row="5" data-col="4">29810</td>
                </tr>
        </tbody>
      </table>
    </div>
<!-- hack to avoid NA insertion in last line -->
```

:::
:::


The summary appears in @tbl-continents.

Quarto renders @tbl-continents as Table 1, Table 2, and so on, and the reference is a clickable link in HTML output. The label must start with tbl- for the cross-reference to register. The same pattern applies to figures with fig- and equations with eq-.

9.12 Exporting tables

When a table belongs in another file, save it deliberately. The examples below are not run during rendering because they write files. If you use them, make sure the output folder exists.

vehicle_table <- tt(
  vehicle_sample,
  caption = "A Small Sample of Vehicle Characteristics",
  digits = 1
)

vehicle_table |> save_tt("Tables/vehicle_characteristics.html", overwrite = TRUE)
vehicle_table |> save_tt("Tables/vehicle_characteristics.docx", overwrite = TRUE)
vehicle_table |> save_tt("Tables/vehicle_characteristics.png", overwrite = TRUE)

In a Quarto project, keep exported tables in a clear output folder such as Tables/ or Output/. Avoid saving tables into the Data/ folder, which should hold source data.

9.13 Short exercise

Use gapminder_2007.

  1. Create a new variable that classifies countries as above or below the median GDP per capita.
  2. Make a janitor::tabyl() table of continent by your new GDP group.
  3. Add row percentages and counts.
  4. Make a tinytable version of a six-row sample containing country, continent, lifeExp, and gdpPercap.

9.14 Extra: highly designed tables with gt

gt is built for HTML tables with fine-grained control over every cell, header, and footer. It is less concise than tinytable but offers more design surface for HTML-first publications.

library(gt)

continent_summary |>
  gt() |>
  tab_header(
    title    = "Life expectancy and GDP by continent",
    subtitle = "Gapminder, 2007"
  ) |>
  fmt_number(columns = median_life, decimals = 1) |>
  fmt_currency(columns = median_gdp, decimals = 0) |>
  cols_label(
    continent   = "Continent",
    countries   = "Countries",
    median_life = "Median life expectancy",
    median_gdp  = "Median GDP per capita"
  )
Life expectancy and GDP by continent
Gapminder, 2007
Continent Countries Median life expectancy Median GDP per capita
Africa 52 52.9 $1,452
Americas 25 72.9 $8,948
Asia 33 72.4 $4,471
Europe 30 78.6 $28,054
Oceania 2 80.7 $29,810

gt has many more options for color, grouping, and conditional formatting. For a Word-first workflow, flextable is a similar choice with stronger Word output.

9.15 Extra: interactive tables with DT

Interactive tables are useful when readers need to search, sort, or inspect more rows than would fit comfortably on a page. The DT package creates HTML tables with built-in search and pagination.

table_for_browsing <- gapminder_2007 |>
  select(country, continent, lifeExp, gdpPercap) |>
  mutate(gdpPercap = round(gdpPercap)) |>
  arrange(continent, desc(lifeExp))

if (knitr::is_html_output() && requireNamespace("DT", quietly = TRUE)) {
  DT::datatable(
    table_for_browsing,
    rownames = FALSE,
    options = list(
      pageLength = 8,
      autoWidth = TRUE
    )
  )
} else {
  tt(table_for_browsing |> slice_head(n = 12), digits = 1)
}

Interactive tables work best for exploration or web appendices. For a printed report, a smaller static table is usually better.