TLF Gallery: Mock Shell → R Output

Clinical trial tables, listings and figures produced in R — demography Table 1, adverse-event summary, lab shift table, Kaplan-Meier curve and subgroup forest plot, each shown against its mock shell.

Every clinical deliverable starts as a mock shell and ends as a table or figure that must match it to the cell. Below are five standard CSR outputs, each shown as shell → R output. Everything on this page is produced live by R when the page is built — the code is the deliverable.

Data note: outputs use the gtsummary::trial teaching dataset and small synthetic ADaM-style datasets generated on this page — structures are CDISC-shaped, values are fictional.

1. Demography — Table 1

Shell:

Table 14.1.1  Demographic and Baseline Characteristics — ITT Population
                        Drug A          Drug B          Total
                        (N = xx)        (N = xx)        (N = xx)
Age, years
  Mean (SD)             xx.x (xx.x)     xx.x (xx.x)     xx.x (xx.x)
Grade, n (%)
  I / II / III          xx (xx.x) ...

R output (gtsummary):

library(gtsummary)
trial |>
  select(trt, age, grade) |>
  tbl_summary(
    by = trt,
    statistic = list(all_continuous() ~ "{mean} ({sd})"),
    digits = all_continuous() ~ 1,
    missing_text = "Missing"
  ) |>
  add_overall(last = TRUE) |>
  modify_header(label ~ "**Characteristic**") |>
  bold_labels()
Characteristic Drug A
N = 981
Drug B
N = 1021
Overall
N = 2001
Age 47.0 (14.7) 47.4 (14.0) 47.2 (14.3)
    Missing 7 4 11
Grade


    I 35 (36%) 33 (32%) 68 (34%)
    II 32 (33%) 36 (35%) 68 (34%)
    III 31 (32%) 33 (32%) 64 (32%)
1 Mean (SD); n (%)

2. Adverse events by SOC and preferred term

Shell:

Table 14.3.1  Adverse Events by System Organ Class and Preferred Term — Safety Population
                                    Drug A (N = xx)    Placebo (N = xx)
System Organ Class
  Patients with >=1 event           xx (xx.x%)         xx (xx.x%)
    Preferred term                  xx                 xx

R output (rtables + tern):

library(rtables)
library(tern)      # clinical analyze functions on top of rtables
library(dplyr)

set.seed(2026)
adsl <- tibble(
  USUBJID = sprintf("01-%03d", 1:80),
  ARM     = rep(c("Drug A", "Placebo"), each = 40)
)
adae <- tibble(
  USUBJID = sample(adsl$USUBJID, 120, replace = TRUE),
  AESOC   = sample(c("Gastrointestinal disorders", "Nervous system disorders",
                     "Infections and infestations"), 120, replace = TRUE,
                   prob = c(.4, .35, .25)),
  AEDECOD = sample(c("Nausea", "Diarrhoea", "Headache", "Dizziness",
                     "Nasopharyngitis"), 120, replace = TRUE)
) |> left_join(adsl, by = "USUBJID")

basic_table(show_colcounts = TRUE) |>
  split_cols_by("ARM") |>
  split_rows_by("AESOC", split_fun = drop_split_levels) |>
  summarize_num_patients(var = "USUBJID", .stats = "unique",
                         .labels = c(unique = "Patients with >=1 event")) |>
  count_occurrences(vars = "AEDECOD") |>
  build_table(adae, alt_counts_df = adsl)
                            Drug A      Placebo  
                            (N=40)       (N=40)  
—————————————————————————————————————————————————
Patients with >=1 event   18 (45.0%)   14 (35.0%)
  Diarrhoea               4 (10.0%)    4 (10.0%) 
  Dizziness               5 (12.5%)     3 (7.5%) 
  Headache                5 (12.5%)     3 (7.5%) 
  Nasopharyngitis          2 (5.0%)    5 (12.5%) 
  Nausea                  6 (15.0%)    4 (10.0%) 
Patients with >=1 event   20 (50.0%)   19 (47.5%)
  Diarrhoea               6 (15.0%)    12 (30.0%)
  Dizziness                2 (5.0%)     3 (7.5%) 
  Headache                 3 (7.5%)    5 (12.5%) 
  Nasopharyngitis          3 (7.5%)     3 (7.5%) 
  Nausea                  7 (17.5%)     2 (5.0%) 
Patients with >=1 event   11 (27.5%)   13 (32.5%)
  Diarrhoea                3 (7.5%)     3 (7.5%) 
  Dizziness               4 (10.0%)     3 (7.5%) 
  Headache                 1 (2.5%)     3 (7.5%) 
  Nasopharyngitis             0        4 (10.0%) 
  Nausea                  4 (10.0%)     3 (7.5%) 

Note the header Ns come from adsl (the safety population), not from the rows of the AE data — the classic denominator trap, handled by alt_counts_df.

3. Laboratory shift table

Shell:

Table 14.3.5  Shift from Baseline in ALT Grade — Safety Population
Baseline         Post-baseline: Normal   High    Total
Drug A   Normal           xx             xx      xx
         High             xx             xx      xx

R output:

set.seed(7)
shift <- tibble(
  USUBJID = adsl$USUBJID,
  ARM     = adsl$ARM,
  BASE    = sample(c("Normal", "High"), 80, replace = TRUE, prob = c(.85, .15)),
  POST    = sample(c("Normal", "High"), 80, replace = TRUE, prob = c(.8, .2))
) |>
  mutate(across(c(BASE, POST), ~ factor(.x, levels = c("Normal", "High"))))

basic_table(show_colcounts = TRUE) |>
  split_cols_by("ARM") |>
  split_rows_by("BASE", label_pos = "topleft", split_label = "Baseline ALT") |>
  count_values("POST", values = "Normal", .labels = c(count_fraction = "Post: Normal")) |>
  count_values("POST", values = "High",   .labels = c(count_fraction = "Post: High")) |>
  build_table(shift)
Warning: Non-unique sibling analysis table names. Using Labels instead. Use the table_names argument to analyze to avoid this when analyzing the same variable multiple times.
    occured at (row) path: BASE[Normal]
Warning: Non-unique sibling analysis table names. Using Labels instead. Use the table_names argument to analyze to avoid this when analyzing the same variable multiple times.
    occured at (row) path: BASE[High]
                   Drug A        Placebo  
Baseline ALT       (N=40)        (N=40)   
——————————————————————————————————————————
Normal                                    
  Post: Normal   29 (87.88%)   27 (75.00%)
  Post: High     4 (12.12%)    9 (25.00%) 
High                                      
  Post: Normal   5 (71.43%)    3 (75.00%) 
  Post: High     2 (28.57%)    1 (25.00%) 

4. Kaplan-Meier figure

Shell: Figure 14.2.1 — Kaplan-Meier plot of overall survival by treatment, ITT population; number at risk below the x-axis; log-rank p in the panel.

R output:

library(survival)
library(ggplot2)

fit <- survfit(Surv(ttdeath, death) ~ trt, data = trial)
lr  <- survdiff(Surv(ttdeath, death) ~ trt, data = trial)
pval <- format.pval(1 - pchisq(lr$chisq, length(lr$n) - 1), digits = 2, eps = .001)

km <- data.frame(time = fit$time, surv = fit$surv,
                 strata = rep(names(fit$strata), fit$strata))
ggplot(km, aes(time, surv, colour = strata)) +
  geom_step(linewidth = 0.9) +
  scale_y_continuous(labels = scales::percent, limits = c(0, 1)) +
  scale_colour_manual(values = c("#17a2b8", "#2f6fed"),
                      labels = c("Drug A", "Drug B")) +
  labs(x = "Months", y = "Survival probability", colour = NULL,
       title = "Overall survival by treatment — ITT population",
       subtitle = paste("Log-rank p =", pval)) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "bottom", plot.title = element_text(face = "bold"))

5. Subgroup forest plot

Shell: Figure 14.2.4 — Treatment effect (odds ratio, 95% CI) by subgroup; reference line at OR = 1.

R output:

library(dplyr)
subgroups <- bind_rows(
  trial |> mutate(sub = ifelse(age < 65, "Age < 65", "Age >= 65")),
  trial |> mutate(sub = paste("Grade", grade))
) |>
  filter(!is.na(sub), !is.na(age)) |>
  group_by(sub) |>
  group_modify(~ {
    m <- glm(response ~ trt, family = binomial, data = .x)
    ci <- suppressMessages(confint(m))
    data.frame(or = exp(coef(m)[2]), lo = exp(ci[2, 1]), hi = exp(ci[2, 2]))
  }) |> ungroup()

ggplot(subgroups, aes(or, reorder(sub, or))) +
  geom_vline(xintercept = 1, linetype = 2, colour = "grey55") +
  geom_pointrange(aes(xmin = lo, xmax = hi), colour = "#2f6fed", linewidth = 0.8) +
  scale_x_log10() +
  labs(x = "Odds ratio (Drug B vs Drug A), log scale", y = NULL,
       title = "Treatment effect by subgroup") +
  theme_minimal(base_size = 12) +
  theme(plot.title = element_text(face = "bold"))


How these become deliverables

Each output exports to the format your shell demands — as_flex_table() to Word for gtsummary, export_as_rtf() for rtables, ggsave() at journal DPI for figures — inside a parameterised Quarto pipeline that regenerates the whole TLF package from the analysis datasets in one run. The datasets themselves are derived with admiral, and the QC side is double programming.

Have a shell package waiting?

Send us two or three representative shells and we’ll quote the whole package — fixed price, double-programmed. Contact us →