Accessible colour contrasts with {coloratio}

The word 'coloratio' in green text on a black background and vice versa to its right.

This blog’s theme: insufficient contrast!

tl;dr

I made a small R package called {coloratio} to evaluate colour-contrast ratios for accessibility. Then I found out that {savonliquide} already exists to do this.

Accessible charts

The UK government’s website, GOV.UK, was developed with user needs and accessibility in mind. I’ve been using {ggplot2} to recreate the simple, accessible chart styles suggested for use on GOV.UK by the Government Statistical Service.

But I wondered: is it possible to programmatically select a high-contrast text colour to overlay the fill colours of a {ggplot2} barplot? You would want black text over white and vice versa, for example.

What is ‘high contrast’ anyway? GOV.UK’s Design System refers to W3C’s contrast guidance from WCAG 2.1, which suggests a ratio of 4.5:1 for regular text on a block-coloured background.

It isn’t a big deal to program this ‘manually’, but that’s not fun.

Ratio calculation

Is the contrast accessible?

How about a small package with some functions to derive colour contrast ratios? Introducing {coloratio}.

remotes::install_github("matt-dray/coloratio")

Pass two colours to cr_get_ratio() as hex values or named colours—see colors()—and it performs the necessary calculations to derive relative luminance and return a colour contrast ratio.

library(coloratio)  # attach package

cr_get_ratio(
  "papayawhip", "#000000",  # colours to compare
  view = TRUE  # optional demo of colours 
)

## [1] 18.55942

This contrast value is above the 4.5 threshold, so we’re good to go. You’ll get a warning if the contrast is insufficient.

cr_get_ratio("olivedrab", "olivedrab2")
## Warning in cr_get_ratio("olivedrab", "olivedrab2"): Aim for a value of 4.5 or higher.
## [1] 2.755693

Surprise: as stunning as an all-olivedrab palette might be, these colours aren’t distinct enough to be accessible.

Black or white?

cr_get_ratio() in turn powers the function cr_choose_bw(), which returns black or white depending on the greatest contrast with a supplied background colour.

cr_choose_bw("snow")
## [1] "black"
cr_choose_bw("saddlebrown")
## [1] "white"

To demonstrate better, let’s create a grouped barplot with lighter (lemonchiffon3) and darker (hotpink4) fill colours, then use cr_choose_bw() to choose black or white for the overlaying text.

library(tidyverse)  # for data manipulation

# Example data
d <- data.frame(
  x_val = c("A", "A", "B", "B"),
  y_val = c(3, 6, 4, 10),
  z_val = c("a", "b", "a", "b")
) %>% 
  mutate(  # add colour columns
    fill_col = rep(c("hotpink4", "lemonchiffon3"), 2),
    text_col = map_chr(fill_col, coloratio::cr_choose_bw)
  )

d  # preview
##   x_val y_val z_val      fill_col text_col
## 1     A     3     a      hotpink4    white
## 2     A     6     b lemonchiffon3    black
## 3     B     4     a      hotpink4    white
## 4     B    10     b lemonchiffon3    black

No surprise: white was returned for the darker fill and black for the lighter fill.

We can now refer to this information in the colour argument of geom_text().

ggplot(d, aes(x_val, y_val, fill = z_val)) +
  geom_bar(position = "dodge", stat = "identity") +
  scale_fill_manual(values = d$fill_col) +    # fill colour
  geom_text(aes(y = 0.5, label = y_val), 
            position = position_dodge(0.9), 
            size = 5, colour = d$text_col) +  # text colour 
  coord_flip() + 
  theme_minimal(base_size = 16) +  # clean up the theme
  theme(axis.text.x = element_blank(), axis.title = element_blank(), 
        legend.title = element_blank(), panel.grid = element_blank())

As desired: black on the lighter fill; white on the darker fill. The default would be black text, which would provide insufficient contrast for darker fills.

Aside: cr_choose_bw() in geom_text()?

Originally I wanted geom_text() to choose text colours on the fly, rather than adding them to the input data. This roundabout solution—which outputs a similar plot to the one above—requires you to build the plot object, then interrogate it with ggplot_build() to identify the bar-fill colours.

# Build simple grouped barplot again
p <- ggplot(d, aes(x_val, y_val, fill = z_val)) +
  geom_bar(position = "dodge", stat = "identity") +
  scale_fill_manual(values = c("hotpink4", "lemonchiffon3")) +
  coord_flip()

# Extract the p-object fills and choose text overlay colour
p + geom_text(
  aes(y = 0.5, label = y_val), position = position_dodge(0.9), size = 5,
  colour = map_chr(  # make text colour dependent on bar colour
    ggplot_build(p)[[1]][[1]]$fill,  # access p-object fills
    coloratio::cr_choose_bw   # choose black/white text based on fill
  )
)

I put this to the RStudio Community with no answer to date. Let me know if you have any ideas.

A soapy slip-up

Having addressed my need, I was suspicious. Surely this has been done in R before? I put out a tweet to investigate.

I soon realised my error. Merry Christmas!

Whoops. {savonliquide} by Ihaddaden M. EL Fodil can query the WebAIM contrast checker API to get the contrast ratio for two colours. And it’s on CRAN.

install.packages("savonliquide")

Maybe I missed it because of the name, which translates to ‘liquid soap’?

Anyway, like coloratio::cr_get_ratio(), you can pass two hex values or named colours to {savonliquide}’s check_contrast() function.

savonliquide::check_contrast("blanchedalmond", "bisque2")
## 
## * The Contrast Ratio is 1.04
## 
## * The result for the AA check is : FAIL
## 
## * The result for the AALarge check is : FAIL
## 
## * The result for the AAA check is : FAIL
## 
## * The result for the AAALarge check is : FAIL

The output is richer than coloratio::cr_get_ratio(). You can see here that the supplied colours fail additional accessibility checks from WCAG 2.1 that involve large text and more stringent contrast thresholds.

Handily, there’s also the savonliquide::check_contrast_raw() variant that returns a list with each result as an element.

Acceptance

So… should you wash your hands of {coloratio}?1 Well, it fills the micro-niche of an R package that doesn’t require an internet connection to fetch colour contrast ratios. But it’s probably never going to go on CRAN, so you should use {savonliquide}.

I certainly learnt a lesson about due diligence during package development. Especially because I also discovered recently that I had also somehow managed to reinvent the {badger} package with my own {badgr} package.2 Whoops again.

At worst, I got to learn more about accessibility, practice some package building, and solve my initial problem (kinda).

I also got to admire the creativity of the names in the named-colour set. ‘Papayawhip’ sounds really appealing. Or perhaps painful. Just like package development.3


Session info
## ─ Session info ───────────────────────────────────────────────────────────────
##  setting  value                       
##  version  R version 4.0.2 (2020-06-22)
##  os       macOS Mojave 10.14.6        
##  system   x86_64, darwin17.0          
##  ui       X11                         
##  language (EN)                        
##  collate  en_GB.UTF-8                 
##  ctype    en_GB.UTF-8                 
##  tz       Europe/London               
##  date     2020-12-30                  
## 
## ─ Packages ───────────────────────────────────────────────────────────────────
##  package      * version    date       lib source                          
##  assertthat     0.2.1      2019-03-21 [1] CRAN (R 4.0.0)                  
##  backports      1.1.8      2020-06-17 [1] CRAN (R 4.0.0)                  
##  blob           1.2.1      2020-01-20 [1] CRAN (R 4.0.2)                  
##  blogdown       0.19       2020-05-22 [1] CRAN (R 4.0.0)                  
##  bookdown       0.19       2020-05-15 [1] CRAN (R 4.0.0)                  
##  broom          0.7.0      2020-07-09 [1] CRAN (R 4.0.2)                  
##  cellranger     1.1.0      2016-07-27 [1] CRAN (R 4.0.2)                  
##  cli            2.2.0      2020-11-20 [1] CRAN (R 4.0.2)                  
##  coloratio    * 0.0.0.9003 2020-12-28 [1] local                           
##  colorspace     2.0-0      2020-11-11 [1] CRAN (R 4.0.2)                  
##  crayon         1.3.4      2017-09-16 [1] CRAN (R 4.0.0)                  
##  curl           4.3        2019-12-02 [1] CRAN (R 4.0.0)                  
##  DBI            1.1.0      2019-12-15 [1] CRAN (R 4.0.0)                  
##  dbplyr         1.4.4      2020-05-27 [1] CRAN (R 4.0.2)                  
##  digest         0.6.27     2020-10-24 [1] CRAN (R 4.0.2)                  
##  dplyr        * 1.0.0      2020-08-10 [1] Github (tidyverse/dplyr@5e3f3ec)
##  ellipsis       0.3.1      2020-05-15 [1] CRAN (R 4.0.0)                  
##  evaluate       0.14       2019-05-28 [1] CRAN (R 4.0.0)                  
##  fansi          0.4.1      2020-01-08 [1] CRAN (R 4.0.0)                  
##  farver         2.0.3      2020-01-16 [1] CRAN (R 4.0.0)                  
##  forcats      * 0.5.0      2020-03-01 [1] CRAN (R 4.0.2)                  
##  fs             1.5.0      2020-07-31 [1] CRAN (R 4.0.2)                  
##  generics       0.1.0      2020-10-31 [1] CRAN (R 4.0.2)                  
##  ggplot2      * 3.3.2      2020-06-19 [1] CRAN (R 4.0.2)                  
##  glue           1.4.2      2020-08-27 [1] CRAN (R 4.0.2)                  
##  gtable         0.3.0      2019-03-25 [1] CRAN (R 4.0.0)                  
##  haven          2.3.1      2020-06-01 [1] CRAN (R 4.0.2)                  
##  hms            0.5.3      2020-01-08 [1] CRAN (R 4.0.2)                  
##  htmltools      0.5.0      2020-06-16 [1] CRAN (R 4.0.2)                  
##  httr           1.4.2      2020-07-20 [1] CRAN (R 4.0.2)                  
##  jsonlite       1.7.2      2020-12-09 [1] CRAN (R 4.0.2)                  
##  knitr          1.30       2020-09-22 [1] CRAN (R 4.0.2)                  
##  labeling       0.4.2      2020-10-20 [1] CRAN (R 4.0.2)                  
##  lifecycle      0.2.0      2020-03-06 [1] CRAN (R 4.0.0)                  
##  lubridate      1.7.9.2    2020-11-13 [1] CRAN (R 4.0.2)                  
##  magrittr       2.0.1      2020-11-17 [1] CRAN (R 4.0.2)                  
##  modelr         0.1.8      2020-05-19 [1] CRAN (R 4.0.2)                  
##  munsell        0.5.0      2018-06-12 [1] CRAN (R 4.0.0)                  
##  pillar         1.4.7      2020-11-20 [1] CRAN (R 4.0.2)                  
##  pkgconfig      2.0.3      2019-09-22 [1] CRAN (R 4.0.0)                  
##  purrr        * 0.3.4      2020-04-17 [1] CRAN (R 4.0.0)                  
##  R6             2.5.0      2020-10-28 [1] CRAN (R 4.0.2)                  
##  Rcpp           1.0.5      2020-07-06 [1] CRAN (R 4.0.2)                  
##  readr        * 1.4.0      2020-10-05 [1] CRAN (R 4.0.2)                  
##  readxl         1.3.1      2019-03-13 [1] CRAN (R 4.0.2)                  
##  reprex         0.3.0      2019-05-16 [1] CRAN (R 4.0.2)                  
##  rlang          0.4.9      2020-11-26 [1] CRAN (R 4.0.2)                  
##  rmarkdown      2.5        2020-10-21 [1] CRAN (R 4.0.2)                  
##  rstudioapi     0.13       2020-11-12 [1] CRAN (R 4.0.2)                  
##  rvest          0.3.6      2020-07-25 [1] CRAN (R 4.0.2)                  
##  savonliquide   0.1.0      2020-12-07 [1] CRAN (R 4.0.2)                  
##  scales         1.1.1      2020-05-11 [1] CRAN (R 4.0.0)                  
##  sessioninfo    1.1.1      2018-11-05 [1] CRAN (R 4.0.0)                  
##  stringi        1.5.3      2020-09-09 [1] CRAN (R 4.0.2)                  
##  stringr      * 1.4.0      2019-02-10 [1] CRAN (R 4.0.0)                  
##  tibble       * 3.0.4      2020-10-12 [1] CRAN (R 4.0.2)                  
##  tidyr        * 1.1.2      2020-08-27 [1] CRAN (R 4.0.2)                  
##  tidyselect     1.1.0      2020-05-11 [1] CRAN (R 4.0.0)                  
##  tidyverse    * 1.3.0      2019-11-21 [1] CRAN (R 4.0.2)                  
##  vctrs          0.3.6      2020-12-17 [1] CRAN (R 4.0.2)                  
##  withr          2.3.0      2020-09-22 [1] CRAN (R 4.0.2)                  
##  xfun           0.19       2020-10-30 [1] CRAN (R 4.0.2)                  
##  xml2           1.3.2      2020-04-23 [1] CRAN (R 4.0.0)                  
##  yaml           2.2.1      2020-02-01 [1] CRAN (R 4.0.0)                  
## 
## [1] /Library/Frameworks/R.framework/Versions/4.0/Resources/library

  1. I assure you this is an excellent savon liquide pun.↩︎

  2. {badger} has functions to generate a bunch of badges you’re likely to want. {badgr} focuses only on custom badges and has some extra options relative to badger::badge_custom(), like the ability to add an icon. But wow, how did I miss this?↩︎

  3. #deep↩︎