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 a nice interactive 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():

library(reactable)

reactable(data)

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 format the numbers and percentages manually now. Column formatters are currently overridden by custom cell renderers, although 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, what colors and fonts were used, and etc.

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 way 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 shows 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 state of the table to know which row is first in the table 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 in 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 using compact = TRUE. We’ll apply CSS to the table and headers by adding custom class names through the class and headerClass arguments.

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. (See the Demo Cookbook for how to add web fonts to an HTML document).

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

The final table and code is shown below.


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
)
tags$link(href = "https://fonts.googleapis.com/css?family=Karla:400,700|Fira+Mono&display=fallback",
          rel = "stylesheet")