The Twitter Followers demo is a re-creation of the table from the FiveThirtyEight article, Which 2020 Candidates Have The Most In Common … On Twitter?

It’s an interactive HTML table with sorting, data formatting, embedded bar charts, and custom styling. In this article, we’ll walk through how we made this table using reactable, and show a typical workflow for building tables.

Get the data

FiveThirtyEight shares the data for many of their articles online at https://data.fivethirtyeight.com, licensed under CC by 4.0.

You can find the raw data for this article here, but we’ll conveniently begin working with a cleaned CSV file: https://glin.github.io/reactable/articles/twitter-followers/twitter_followers.csv

data <- read.csv("https://glin.github.io/reactable/articles/twitter-followers/twitter_followers.csv",
                 stringsAsFactors = FALSE)

dplyr::glimpse(data)
## Rows: 20
## Columns: 3
## $ account                 <chr> "marwilliamson", "BernieSanders", "Hickenloop…
## $ followers               <int> 2610335, 9254423, 144816, 4246252, 3558333, 2…
## $ exclusive_followers_pct <dbl> 0.748, 0.632, 0.563, 0.525, 0.438, 0.434, 0.3…

Create a basic table

The first thing we’ll do is create a basic table using reactable():

You can already sort the table, but there’s no default sorting on the “exclusive followers” column. The numeric columns are still unformatted and sort in ascending order (smallest to largest) by default.

Let’s customize the default sorting, add proper column names, and format the data.

We’ll use reactable’s built-in column formatters to add an @ symbol to the Twitter handles, add thousands separators to the follower counts, and format the percentages with 1 decimal place.

reactable(
  data,
  defaultSorted = "exclusive_followers_pct",
  columns = list(
    account = colDef(
      name = "Account",
      format = colFormat(prefix = "@")
    ),
    followers = colDef(
      name = "Followers",
      defaultSortOrder = "desc",
      format = colFormat(separators = TRUE)
    ),
    exclusive_followers_pct = colDef(
      name = "Exclusive Followers",
      defaultSortOrder = "desc",
      format = colFormat(percent = TRUE, digits = 1)
    )
  )
)

Add bar charts

Next, we’ll add bar charts to the numeric and percentage columns. The FiveThirtyEight table uses pure HTML and CSS to create these bar charts, so we’ll do something similar using a method based on CSS flexbox (and also shown in the Demo Cookbook).

We’ll generate the bar chart HTML with help from the htmltools package, and render them in the cells via custom render functions.

Since we’re taking over cell rendering with custom render functions, we’ll also have to manually format the numbers and percentages now. Custom cell renderers override column formatters, but this may change in the future.

If you’re ever curious to see how an HTML table was made, you can open your browser’s developer tools and inspect the HTML and CSS behind the table. This is how we figured out how the bar charts were made, which colors and fonts were used, and so on.

library(htmltools)

# Render a bar chart with a label on the left
bar_chart <- function(label, width = "100%", height = "14px", fill = "#00bfc4", background = NULL) {
  bar <- div(style = list(background = fill, width = width, height = height))
  chart <- div(style = list(flexGrow = 1, marginLeft = "6px", background = background), bar)
  div(style = list(display = "flex", alignItems = "center"), label, chart)
}

reactable(
  data,
  defaultSorted = "exclusive_followers_pct",
  columns = list(
    account = colDef(
      name = "Account",
      format = colFormat(prefix = "@")
    ),
    followers = colDef(
      name = "Followers",
      defaultSortOrder = "desc",
      # Render the bar charts using a custom cell render function
      cell = function(value) {
        width <- paste0(value * 100 / max(data$followers), "%")
        # Add thousands separators
        value <- format(value, big.mark = ",")
        bar_chart(value, width = width, fill = "#3fc1c9")
      },
      # And left-align the columns
      align = "left"
    ),
    exclusive_followers_pct = colDef(
      name = "Exclusive Followers",
      defaultSortOrder = "desc",
      # Render the bar charts using a custom cell render function
      cell = function(value) {
        # Format as percentages with 1 decimal place
        value <- paste0(format(value * 100, nsmall = 1), "%")
        bar_chart(value, width = value, fill = "#fc5185", background = "#e1e1e1")
      },
      # And left-align the columns
      align = "left"
    )
  )
)

The bar charts look good, but they aren’t aligned because the numbers have different widths. Let’s fix this by giving each numeric label the same width. One way to do this would be to format the labels as fixed-width strings, and use a monospaced font so that each character takes up the same width. (An alternate method is shown in the Demo Cookbook.)

Some fonts have numerals that are all equal in width, others do not. In tables with numeric columns, using a font with tabular (or monospaced) figures can make the numbers easier to align and read. You can learn more about the different types of fonts here.

reactable(
  data,
  defaultSorted = "exclusive_followers_pct",
  columns = list(
    account = colDef(
      name = "Account",
      format = colFormat(prefix = "@")
    ),
    followers = colDef(
      name = "Followers",
      defaultSortOrder = "desc",
      cell = function(value) {
        width <- paste0(value * 100 / max(data$followers), "%")
        value <- format(value, big.mark = ",")
        # Fix each label using the width of the widest number (incl. thousands separators)
        value <- format(value, width = 9, justify = "right")
        bar_chart(value, width = width, fill = "#3fc1c9")
      },
      align = "left",
      # Use the operating system's default monospace font, and
      # preserve white space to prevent it from being collapsed by default
      style = list(fontFamily = "monospace", whiteSpace = "pre")
    ),
    exclusive_followers_pct = colDef(
      name = "Exclusive Followers",
      defaultSortOrder = "desc",
      cell = function(value) {
        value <- paste0(format(value * 100, nsmall = 1), "%")
        # Fix width here to align single and double-digit percentages
        value <- format(value, width = 5, justify = "right")
        bar_chart(value, width = value, fill = "#fc5185", background = "#e1e1e1")
      },
      align = "left",
      style = list(fontFamily = "monospace", whiteSpace = "pre")
    )
  )
)

Dynamic formatting

The FiveThirtyEight table has a nifty little detail of only showing the percent sign in first row of the “exclusive followers” column to reduce repetition. If you sort the table, you’ll notice that the percent always appears in the first row regardless of row order.

To achieve dynamic behavior like this, we’ll have to write some JavaScript. We need access to the client-side (browser) state of the table to know which row is first after sorting. This isn’t possible to do with R (at least without Shiny), so we’ll render the cells using a custom JavaScript render function as shown in the Demo Cookbook.

Since we’re switching to a JavaScript render function, we’ll unfortunately have to reformat the data and recreate the bar chart in JavaScript. We’ll generate the same bar chart HTML by concatenating strings, and it’ll be kind of ugly written as a character string in R.

reactable(
  data,
  defaultSorted = "exclusive_followers_pct",
  columns = list(
    account = colDef(
      name = "Account",
      format = colFormat(prefix = "@")
    ),
    followers = colDef(
      name = "Followers",
      defaultSortOrder = "desc",
      cell = function(value) {
        width <- paste0(value * 100 / max(data$followers), "%")
        value <- format(value, big.mark = ",")
        value <- format(value, width = 9, justify = "right")
        bar_chart(value, width = width, fill = "#3fc1c9")
      },
      align = "left",
      style = list(fontFamily = "monospace", whiteSpace = "pre")
    ),
    exclusive_followers_pct = colDef(
      name = "Exclusive Followers",
      defaultSortOrder = "desc",
      # Format and render the cell with a JavaScript render function
      cell = JS("function(cellInfo) {
        // Format as a percentage with 1 decimal place
        const pct = (cellInfo.value * 100).toFixed(1) + '%'
        // Fix width of numeric labels
        let value = pct.padStart(5)
        // Show percent sign on first row only
        if (cellInfo.viewIndex > 0) {
          value = value.replace('%', ' ')
        }
        // Render bar chart
        return (
          '<div style=\"display: flex; align-items: center;\">' +
            '<span style=\"font-family: monospace; white-space: pre;\">' + value + '</span>' +
            '<div style=\"flex-grow: 1; margin-left: 6px; height: 14px; background-color: #e1e1e1\">' +
              '<div style=\"height: 100%; width: ' + pct + '; background-color: #fc5185\"></div>' +
            '</div>' +
          '</div>'
        )
      }"),
      # Render this column as HTML
      html = TRUE,
      align = "left"
    )
  )
)

Finishing touches

Finally, we’ll style the table and add some extra niceties.

We’ll display everything on one page using pagination = FALSE and reduce the white space in the table with compact = TRUE. We’ll apply custom styling by adding our own CSS classes to the table and column headers. This will be used to fix the table width, highlight column headers on hover, and change the font of the table.

FiveThirtyEight uses two commercial fonts for their table: Atlas Grotesk for text, and Decima Mono for numbers. We’ll use similar-looking free fonts from Google Fonts instead: Karla for text, and Fira Mono for numbers.

We’ll also insert links to Twitter accounts using custom cell renderers and move the bar chart styles to CSS for better organization.

While we won’t go into detail here, you can check out the Examples and Demo Cookbook to learn more about:

The final table and code is shown below. You can also find the source code for the demo at vignettes/twitter-followers/twitter-followers.Rmd.


tbl <- reactable(
  data,
  pagination = FALSE,
  defaultSorted = "exclusive_followers_pct",
  defaultColDef = colDef(headerClass = "header", align = "left"),
  columns = list(
    account = colDef(
      cell = function(value) {
        url <- paste0("https://twitter.com/", value)
        tags$a(href = url, target = "_blank", paste0("@", value))
      },
      width = 150
    ),
    followers = colDef(
      defaultSortOrder = "desc",
      cell = function(value) {
        width <- paste0(value * 100 / max(data$followers), "%")
        value <- format(value, big.mark = ",")
        value <- format(value, width = 9, justify = "right")
        bar <- div(
          class = "bar-chart",
          style = list(marginRight = "6px"),
          div(class = "bar", style = list(width = width, backgroundColor = "#3fc1c9"))
        )
        div(class = "bar-cell", span(class = "number", value), bar)
      }
    ),
    exclusive_followers_pct = colDef(
      name = "Exclusive Followers",
      defaultSortOrder = "desc",
      cell = JS("function(cellInfo) {
        // Format as percentage
        const pct = (cellInfo.value * 100).toFixed(1) + '%'
        // Pad single-digit numbers
        let value = pct.padStart(5)
        // Show % on first row only
        if (cellInfo.viewIndex > 0) {
          value = value.replace('%', ' ')
        }
        // Render bar chart
        return (
          '<div class=\"bar-cell\">' +
            '<span class=\"number\">' + value + '</span>' +
            '<div class=\"bar-chart\" style=\"background-color: #e1e1e1\">' +
              '<div class=\"bar\" style=\"width: ' + pct + '; background-color: #fc5185\"></div>' +
            '</div>' +
          '</div>'
        )
      }"),
      html = TRUE
    )
  ),
  compact = TRUE,
  class = "followers-tbl"
)
# Add the title and subtitle
div(class = "twitter-followers",
    div(class = "followers-header",
        div(class = "followers-title", "Candidates whose followers are loyal only to them"),
        "Share of each 2020 candidate's followers who don't follow any other candidates"
    ),
    tbl
)
# Add Google Fonts to the page
tags$link(href = "https://fonts.googleapis.com/css?family=Karla:400,700|Fira+Mono&display=fallback",
          rel = "stylesheet")