ANSI escape codes in R — \033, crayon, fansi::strip_sgr
R supports \033 (octal) directly in double-quoted strings — the most portable ESC literal across every R version since the language's birth. cat("\033[31mred\033[0m\n") works on every interpreter, on macOS, Linux, BSDs, and modern Windows (Windows Terminal / Conhost 1709+ parses ANSI natively). Modern R (≥ 4.0) also accepts "\u{1b}" Unicode and "\x1b" hex (strict 2-digit) escapes. For the canonical helper reach for crayon — the de-facto R ANSI library used by tidyverse, testthat, pkgdown, devtools: crayon::red("error") returns a styled string, crayon::bold and friends compose freely (bold(red(...))), and make_style("#ff8000") factory-builds a truecolor styler. The modern hadley/r-lib companion cli adds semantic helpers (cli_alert_danger(), cli_h1(), pluralized inline markup) on top of crayon's primitives. glue templates the same vocabulary in glue_col("{red error}"). For ANSI-aware string operations (the canonical R log scrubber lives here), fansi::strip_sgr removes every CSI sequence cleanly, and fansi::nchar_ctl/substr_ctl count display width and slice without corrupting bytes.
Capability gating in R: crayon::has_color() is the authoritative call — it already respects NO_COLOR, isatty(stdout()), the crayon.enabled option, and the RStudio Source-pane vs Console divergence (the #1 "why does my crayon output show raw escapes" SERP click — source() runs go through a different sink that strips ANSI; the Console pane parses it correctly). knitr / rmarkdown rendering also disables colour by default to keep the rendered output portable.
Recommended libraries
- crayon
De-facto R ANSI library used by tidyverse, testthat, pkgdown, devtools.
red(),bold(),bgYellow(),underline()— composable string functions (bold(red("x"))).make_style("#ff8000")factory-builds truecolor stylers;combine_styles()builds compound styles.has_color()is the canonical capability check (respects NO_COLOR, isatty, RStudio Source pane).crayon::strip_style()removes ANSI from a crayon-styled string. - cli
Modern hadley/r-lib package — semantic helpers built on crayon:
cli_alert_danger(),cli_alert_warning(),cli_alert_success(),cli_h1()/cli_h2(),cli_li()lists,cli_progress_bar()progress, pluralized inline markup ({?s/are}), themable styles. The right call when you want consistent UX rather than hand-rolling each style call. - glue
String-interpolation companion to crayon.
glue("x={x}")for plain templating;glue_col("{red error} at {green file}")parses inline crayon style names and applies the escapes. Cleaner than concatenating crayon calls when the surrounding string has many interpolations. - fansi
ANSI-aware string-ops companion.
strip_sgr(s)is the canonical R log scrubber — handles every CSI sequence including 256-colour and truecolor cleanly.nchar_ctl(s)returns display width (skips escape bytes, counts East Asian wide chars);substr_ctl(s, 1, n)slices without corrupting an in-progress escape. The right call when crayon'sstrip_style()is too narrow.
Idiomatic patterns
# R supports \033 (octal) and \u{1b} (Unicode) directly in
# double-quoted strings. \x1b (hex) ALSO works in modern R
# (≥ 4.0) but with a strict two-hex-digit constraint. \033
# is the portable form — works on every R back to 1.0.
# Single-quoted strings expand backslash escapes identically
# in R (no Perl/PHP-style quote-style difference).
cat("\033[1;31merror:\033[0m permission denied\n")
cat("\033[33mwarn:\033[0m deprecated flag\n")
cat("\033[32mok:\033[0m 142 tests passed\n")
# Truecolor — 38;2;R;G;B
cat("\033[38;2;255;128;0morange truecolor\033[0m\n")
# message() goes to stderr — useful for logs that should
# survive stdout redirection. print() and cat() go to stdout.
message("\033[33mdeprecated:\033[0m use foo() instead")# install.packages("crayon")
library(crayon)
# Each style is a function — call it with the text to wrap:
cat(red("error: "), "permission denied\n", sep = "")
cat(yellow("warn: "), "deprecated flag\n", sep = "")
cat(green("ok: "), "142 tests passed\n", sep = "")
# Composition is just function composition — bold + red:
cat(bold(red("FATAL")), " server crashed\n", sep = "")
cat(bold(underline(blue("link"))), "\n", sep = "")
# Truecolor via make_style — pass a hex or rgb triple:
orange <- make_style("#ff8000")
violet <- make_style(rgb(0.5, 0.2, 0.8))
cat(orange("warm orange"), " ", violet("violet"), "\n", sep = "")
# Background colours: bgRed, bgYellow, bgBlue, bgGreen, ...
cat(bgYellow(black(" CAUTION ")), " brakes wet\n", sep = "")
# combine_styles() builds a reusable compound:
heading <- combine_styles(bold, underline, blue)
cat(heading("Section 1"), "\n", sep = "")# crayon::has_color() is the canonical answer:
# TRUE → terminal R, RStudio Console pane,
# VSCode R extension, vim + nvim-R
# FALSE → RStudio Source pane (source() output sink
# strips ANSI bytes — the #1 "my colours
# disappeared" surprise on R), Rscript with
# piped stdout, R BATCH, knitr / rmarkdown
# rendering (disabled to keep output portable).
#
# crayon ALREADY respects NO_COLOR, isatty, the
# crayon.enabled option, and RStudio detection — call it
# as-is. Only roll your own when you don't want crayon
# as a hard dependency:
ansi_capable <- function() {
if (nzchar(Sys.getenv("NO_COLOR"))) return(FALSE)
if (!interactive()) return(FALSE)
if (!isatty(stdout())) return(FALSE)
TRUE
}
style <- function(text, sgr) {
if (!ansi_capable()) return(text)
sprintf("\033[%sm%s\033[0m", sgr, text)
}
cat(style("OK", "32"), "\n", sep = "")
cat(style("FAIL", "1;31"), "\n", sep = "")
# Force-disable globally (useful in CRAN checks / CI):
options(crayon.enabled = FALSE)
# Force-enable when crayon's detection guesses wrong
# (e.g. piping to less -R, which DOES render ANSI):
options(crayon.enabled = TRUE)# install.packages("fansi")
library(fansi)
# Canonical R strip-ANSI — handles every CSI sequence
# including the harder-to-strip 256-colour / truecolor
# forms and cursor-control bytes that simple regexes miss.
dirty <- paste0(
"\033[1;31mERROR\033[0m at line 42 ",
"\033[38;2;255;128;0m(slow)\033[0m"
)
cat(strip_sgr(dirty), "\n", sep = "")
# → "ERROR at line 42 (slow)"
# Pure base-R regex equivalent (no fansi dep) — close
# enough for common SGR / cursor / erase forms:
gsub("\033\\[[0-9;]*[mGKHJABCDsuhl]", "", dirty)
# ANSI-aware substring — base substr() would slice
# inside an escape sequence and corrupt the bytes;
# substr_ctl() only counts visible characters:
substr_ctl(dirty, 1, 5) # → "\033[1;31mERROR\033[0m"
# Display width respecting ANSI bytes (and East Asian
# wide chars) — crucial when laying out terminal tables:
nchar_ctl(dirty) # → 18 (not 49)
nchar_ctl("\033[1;31mERROR\033[0m") # → 5 (not 13)
# Round-trip a whole log file:
strip_log <- function(path_in, path_out) {
lines <- readLines(path_in, warn = FALSE)
writeLines(strip_sgr(lines), path_out)
}
# strip_log("app.log", "app.clean.log")