The lt Package: Lightweight HTML Tables for R (and Beyond)

A small grammar of tables, built on a JSON spec and a tiny bit of JavaScript

Yihui Xie 2026-07-10

Recently I have released a new package on CRAN: lt, short for lightweight tables (or less than). Its origin story is a small moment of shock. About two years ago a project required gt, so I ran install.packages('gt') in a fresh R session on Linux — and then watched R install one package after another after another, as if it were going to install half of CRAN. Some of them had to be compiled, and a few took ages; V8 in particular was painful. For a while I wondered whether I had mistyped something, because surely a table package could not need this much. That was when I learned just how deep gt’s (recursive) dependency tree goes.

I have, to this day, never actually used gt to make a table — but that install left an impression, and lt is what eventually grew out of it. I’d like to thank my manager Keaven at Merck for his support on developing this package to make packages like gsDesign2 and gsDesign more lightweight and faster.

This post is a quick introduction to lt; you can learn more details and examples on the package site at https://pkg.yihui.org/lt/. BTW, to save a few kilobytes for the planet, I don’t have a hex sticker for this package.

Why bother?

Obviously, I borrowed the lovely idea of the grammar of tables from gt: you take a plain data frame and layer on titles, column spanners, row groups, footnotes, and number formatters. That grammar is a good one, and lt owes it the inspiration.

What I did not want was the machinery that came with it — sass, V8, htmlwidgets, and their dependencies. For a package whose job is to draw a table, that is a lot to haul around. So I asked the question I keep asking: how small can this be if I only care about HTML?

The answer turned out to be quite small. On the R side, lt leans on nothing but base R and xfun. In the browser, the entire runtime is a single vanilla-JS file, lt.js (about 10 KB minified). No LaTeX, no RTF, no build step, no framework. Just HTML.

Which brings me to my usual sermon

Regular readers know where I stand: in HTML I trust. I said it in 2018 and I keep saying it — most recently while adding Typst support to knitr, where I announced that I had quit LaTeX. (And don’t get me started on MS Office, which I quit using many years ago.) HTML is where I want my tables to live. It reflows, it styles with a line of CSS, and it opens on any phone. And when you need a PDF, the tool is a browser — which almost every machine already has. Once you commit to HTML, an enormous amount of complexity simply evaporates. lt is what falls out when you make that commitment for tables.

The trick: a JSON spec, not R code

Here is the part I am most pleased with. lt does not draw the table in R at all. On the R side you merely declare what you want; under the hood, that declaration is serialized to a compact JSON object and handed to the browser, where the tiny lt.js runtime builds the real <table> on page load. One inclusion of lt.js renders any number of tables on a page.

The R interface will look familiar — pipe lt_*() functions onto an lt() object:

library(lt)

d = data.frame(
  Group    = c("Treatment", "Treatment", "Control", "Control"),
  Endpoint = c("Primary", "Secondary", "Primary", "Secondary"),
  Estimate = c(0.6123, 0.7891, 0.4567, 0.5432),
  CI_Lower = c(0.4012, 0.5678, 0.2345, 0.3210),
  CI_Upper = c(0.8234, 1.0104, 0.6789, 0.7654),
  P_Value  = c(0.0012, NA, 0.1234, NA)
)
lt(d) |>
  lt_group(~ Group) |>
  lt_header("Study Results", "Primary and secondary endpoints") |>
  lt_spanner(`95% CI` ~ CI_Lower + CI_Upper) |>
  lt_format(~ Estimate + CI_Lower + CI_Upper, decimals = 3) |>
  lt_sub(~ P_Value, missing = "—") |>
  lt_footnote("Two-sided p-value from a t-test.", "column", ~ P_Value)

But here is why the JSON layer matters far more than it might first appear. R is just one way to produce that spec. The rendering — all the actual table logic — lives in lt.js, which knows nothing about R. So any language that can emit a little JSON can drive it. Load lt.js on a page and call LT.build() with the spec yourself, and you get exactly the same table:

LT.build({
  "data": { "x": [1, 2], "y": [3.1415, 2.7182] },
  "ops": [
    { "type": "fmt_number", "columns": ["y"], "decimals": 2 }
  ]
});

I think this is a meaningfully different bet from how gt evolved. gt is an R package, and when the Python world wanted the same grammar, Great Tables was essentially rebuilt from scratch in Python — a big, careful effort, but a second implementation of the same logic. With lt, the logic lives in the JavaScript runtime, once. An R package is a thin front end that writes JSON. A Python package could be another thin front end writing the same JSON — no re-implementation of the table engine, just a different keyboard for the same piano. That door is wide open, and I would be delighted if someone walked through it. I don’t have much interest in writing this Python package by myself, although I guess this should be a simple task for AI.

My favorite part: access to truth at any time

Because the browser holds the original data and does the rendering, the table is not a dead picture — it can be interactive. The little feature I love most: when a value in a cell is formatted, the raw value is not thrown away. Hover over a cell and a tooltip shows what it really was (so we no longer have to argue about the most appropriate number of decimal places for the table — you can always see the original value). You can Alt + Click a table to reveal all raw values at once, and Alt + Double-Click to toggle raw values for every table on the page. A formatted table you can peek underneath, with no extra setup, is exactly the kind of small delight that a static PDF or image can never give you — and it comes for free from committing to HTML.

I’ve had this idea since 2024, and I’m happy that I’ve finally implemented it (technically, it’s not hard at all).

The grammar in brief

If you have used gt, the vocabulary will feel familiar:

When you need something static

Because the table is drawn by JavaScript, viewing it normally wants a browser. When you need a self-contained artifact instead, lt_export() saves a table to:

The PDF and PNG paths render through a headless browser (Chrome / Chromium / Edge) and crop tightly to the table.

Give it a spin

# CRAN
install.packages("lt")

# development version
install.packages("lt", repos = "https://yihui.r-universe.dev")

There is also an online playground if you would rather poke at it without installing anything, and a migration guide if you are coming from gt.

Of course, I don’t want to match gt feature for feature; I just want to cover the table structure most reports actually need — and to show, one more time, that if you are willing to trust HTML, you can go a remarkably long way with remarkably little.

P.S. I’ll be on vacation for the next 5 weeks in China (to live an AI-less life). While I’d like to hear your feedback on lt (please feel free to leave comments below or file Github issues), I’m afraid I won’t be very responsive until late August. Also stay tuned for the lightweight and interactive version of the grammar of graphics later this year, which is a much more challenging project than this table package.