Build the column-header band(s) above the rendered table. Each named argument is one band; the value is either a character vector of column names (leaf band) or a named list of further bands (inner band). Nesting depth is arbitrary — the engine renders one band row per depth level, with each cell spanning the columns of its leaves.
Arguments
- .spec
The
tabular_specto attach the header tree to.<tabular_spec>: required. Dot-prefixed so R's partial argument matching cannot accidentally bind a short user-supplied band label in...to the spec slot.- ...
Named header bands. Each name is the band label (must be non-blank); each value is either:
a character vector of data-column names — leaf band, or
a named list whose entries follow the same recursive pattern — inner band.
Inside a nested-list value, an unnamed character-vector entry declares a passthrough leaf (see the Passthrough section below).
Restriction: Every column referenced must exist in
.spec@data. A column may appear under at most one leaf. Names must be unique within oneheaders()call. Tip: Passheaders()with no arguments to clear the tree. Interaction: Band labels support glue-style{expr}interpolation, evaluated as R code in the calling environment at build time (double a brace for a literal one). The non-blank and uniqueness checks apply to the raw author-typed name, before interpolation.
Value
The updated tabular_spec. Continue chaining with
sort_rows(), style().
Details
Replace, not stack. A second headers() call REPLACES the
prior tree — header structure is a single spec, not a stackable
list. Call with no arguments to clear the tree.
Strict label rule. Every declared band label must carry
visible text — empty strings, NA, and whitespace-only labels are
rejected at every nesting level. This is stricter than
col_spec(), which DOES accept empty labels (a row-label
column with no header text is a legitimate clinical case). A
silently-blank band would be a layout artefact.
Uncovered columns render naked. Columns not referenced under
any band render with their col_spec.label only — no extra band
row above them. This is the canonical pattern for row-label
columns (variable, soc, stat_label).
Multi-line band labels. Embed \n in a band label for a
two-line band cell (arm name on row 1, BigN on row 2).
Spanner underline trim (backend limitation). Each spanner's
underline is trimmed at both ends, booktabs \cmidrule(lr) style,
so adjacent spanners are separated by a visible gap rather than
merging into one continuous line. PDF / LaTeX (tabularray
leftpos/rightpos) and HTML (an inset rule) render the trim
natively. RTF and DOCX cannot inset a cell border horizontally, so
there the spanner underline spans the full band width (adjacent
spanner rules abut). This is a known, documented limitation of the
OOXML / RTF cell-border model, not a bug.
Passthrough leaves inside a nested band
Inside a nested-list value, a child entry may be unnamed — the entry is then a character vector of column names that sit directly under the parent with no intermediate band at this depth. Use this when one column under a band has no sub-grouping while its siblings do. The strict-label rule still applies to every declared band; an unnamed passthrough is NOT a band with a missing label — it is "no band declared at this depth for this column."
See also
Companion verb: cols() / col_spec() sets per-column
labels — the leaf-row header text that sits below the band rows
this verb builds.
Sibling build verbs: sort_rows(),
style(), paginate(), preset().
Examples
# ---- Example 1: Single "Treatment Group" band over four arms ----
#
# AE-by-SOC/PT table with one flat band labelled "Treatment Group"
# spanning the four arm columns and the Total column. The
# row-label column (`soc`) sits to the left of the band with no
# header covering it — the canonical clinical layout.
n <- stats::setNames(cdisc_saf_n$n, cdisc_saf_n$arm_short)
tabular(
cdisc_saf_aesocpt,
titles = c(
"Table 14.3.1",
"Adverse Events by System Organ Class and Preferred Term",
"Safety Population"
),
footnotes = "Subjects are counted once per SOC and once per PT."
) |>
cols(
label = col_spec(label = "SOC / PT", indent = "indent_level"),
soc = col_spec(visible = FALSE),
row_type = col_spec(visible = FALSE),
soc_n = col_spec(visible = FALSE),
n_total = col_spec(visible = FALSE),
placebo = col_spec(label = "Placebo\nN={n['placebo']}"),
drug_50 = col_spec(label = "Drug 50\nN={n['drug_50']}"),
drug_100 = col_spec(label = "Drug 100\nN={n['drug_100']}"),
Total = col_spec(label = "Total\nN={n['Total']}")
) |>
headers(
"Treatment Group" = c("placebo", "drug_50", "drug_100", "Total")
) |>
sort_rows(by = c("soc_n", "n_total"), descending = c(TRUE, TRUE))
Table 14.3.1
Adverse Events by System Organ Class and Preferred Term
Safety Population
Treatment Group SOC / PT Placebo
N=86 Drug 50
N=96 Drug 100
N=72 Total
N=254 TOTAL SUBJECTS WITH AN EVENT 52 (60.5) 81 (84.4) 66 (91.7) 199 (78.3) SKIN AND SUBCUTANEOUS TISSUE DISORDERS 19 (22.1) 36 (37.5) 35 (48.6) 90 (35.4) PRURITUS 8 (9.3) 21 (21.9) 25 (34.7) 54 (21.3) ERYTHEMA 8 (9.3) 14 (14.6) 14 (19.4) 36 (14.2) RASH 5 (5.8) 13 (13.5) 8 (11.1) 26 (10.2) HYPERHIDROSIS 2 (2.3) 4 (4.2) 8 (11.1) 14 (5.5) SKIN IRRITATION 3 (3.5) 6 (6.2) 5 (6.9) 14 (5.5) GENERAL DISORDERS AND ADMINISTRATION SITE CONDITIONS 15 (17.4) 36 (37.5) 30 (41.7) 81 (31.9) APPLICATION SITE PRURITUS 6 (7.0) 23 (24.0) 21 (29.2) 50 (19.7) APPLICATION SITE ERYTHEMA 3 (3.5) 13 (13.5) 14 (19.4) 30 (11.8) APPLICATION SITE DERMATITIS 5 (5.8) 9 (9.4) 7 (9.7) 21 (8.3) APPLICATION SITE IRRITATION 3 (3.5) 9 (9.4) 9 (12.5) 21 (8.3) APPLICATION SITE VESICLES 1 (1.2) 5 (5.2) 5 (6.9) 11 (4.3) GASTROINTESTINAL DISORDERS 13 (15.1) 12 (12.5) 17 (23.6) 42 (16.5) DIARRHOEA 9 (10.5) 5 (5.2) 3 (4.2) 17 (6.7) VOMITING 3 (3.5) 4 (4.2) 6 (8.3) 13 (5.1) NAUSEA 3 (3.5) 3 (3.1) 6 (8.3) 12 (4.7) ABDOMINAL PAIN 1 (1.2) 3 (3.1) 1 (1.4) 5 (2.0) SALIVARY HYPERSECRETION 0 (0.0) 0 (0.0) 4 (5.6) 4 (1.6) NERVOUS SYSTEM DISORDERS 6 (7.0) 18 (18.8) 17 (23.6) 41 (16.1) DIZZINESS 2 (2.3) 9 (9.4) 10 (13.9) 21 (8.3) HEADACHE 3 (3.5) 3 (3.1) 5 (6.9) 11 (4.3) SYNCOPE 0 (0.0) 5 (5.2) 2 (2.8) 7 (2.8) SOMNOLENCE 2 (2.3) 3 (3.1) 1 (1.4) 6 (2.4) TRANSIENT ISCHAEMIC ATTACK 0 (0.0) 2 (2.1) 1 (1.4) 3 (1.2) CARDIAC DISORDERS 7 (8.1) 12 (12.5) 14 (19.4) 33 (13.0) SINUS BRADYCARDIA 2 (2.3) 7 (7.3) 8 (11.1) 17 (6.7) MYOCARDIAL INFARCTION 4 (4.7) 2 (2.1) 4 (5.6) 10 (3.9) ATRIAL FIBRILLATION 1 (1.2) 2 (2.1) 2 (2.8) 5 (2.0) SUPRAVENTRICULAR EXTRASYSTOLES 1 (1.2) 1 (1.0) 1 (1.4) 3 (1.2) VENTRICULAR EXTRASYSTOLES 0 (0.0) 2 (2.1) 1 (1.4) 3 (1.2) INFECTIONS AND INFESTATIONS 12 (14.0) 6 (6.2) 11 (15.3) 29 (11.4) NASOPHARYNGITIS 2 (2.3) 4 (4.2) 6 (8.3) 12 (4.7) UPPER RESPIRATORY TRACT INFECTION 6 (7.0) 1 (1.0) 3 (4.2) 10 (3.9) INFLUENZA 1 (1.2) 1 (1.0) 1 (1.4) 3 (1.2) URINARY TRACT INFECTION 2 (2.3) 0 (0.0) 1 (1.4) 3 (1.2) CYSTITIS 1 (1.2) 0 (0.0) 1 (1.4) 2 (0.8) RESPIRATORY, THORACIC AND MEDIASTINAL DISORDERS 5 (5.8) 8 (8.3) 9 (12.5) 22 (8.7) COUGH 1 (1.2) 5 (5.2) 5 (6.9) 11 (4.3) NASAL CONGESTION 3 (3.5) 1 (1.0) 3 (4.2) 7 (2.8) DYSPNOEA 1 (1.2) 1 (1.0) 1 (1.4) 3 (1.2) EPISTAXIS 0 (0.0) 1 (1.0) 2 (2.8) 3 (1.2) PHARYNGOLARYNGEAL PAIN 0 (0.0) 1 (1.0) 1 (1.4) 2 (0.8) PSYCHIATRIC DISORDERS 7 (8.1) 9 (9.4) 3 (4.2) 19 (7.5) CONFUSIONAL STATE 2 (2.3) 3 (3.1) 1 (1.4) 6 (2.4) AGITATION 2 (2.3) 3 (3.1) 0 (0.0) 5 (2.0) INSOMNIA 2 (2.3) 0 (0.0) 2 (2.8) 4 (1.6) ANXIETY 0 (0.0) 3 (3.1) 0 (0.0) 3 (1.2) DELUSION 1 (1.2) 0 (0.0) 1 (1.4) 2 (0.8) MUSCULOSKELETAL AND CONNECTIVE TISSUE DISORDERS 3 (3.5) 6 (6.2) 5 (6.9) 14 (5.5) BACK PAIN 1 (1.2) 1 (1.0) 3 (4.2) 5 (2.0) ARTHRALGIA 1 (1.2) 2 (2.1) 1 (1.4) 4 (1.6) SHOULDER PAIN 1 (1.2) 2 (2.1) 0 (0.0) 3 (1.2) MUSCLE SPASMS 0 (0.0) 1 (1.0) 1 (1.4) 2 (0.8) ARTHRITIS 0 (0.0) 0 (0.0) 1 (1.4) 1 (0.4) INVESTIGATIONS 5 (5.8) 4 (4.2) 3 (4.2) 12 (4.7) ELECTROCARDIOGRAM ST SEGMENT DEPRESSION 4 (4.7) 1 (1.0) 0 (0.0) 5 (2.0) ELECTROCARDIOGRAM T WAVE INVERSION 2 (2.3) 1 (1.0) 1 (1.4) 4 (1.6) BLOOD GLUCOSE INCREASED 0 (0.0) 1 (1.0) 1 (1.4) 2 (0.8) ELECTROCARDIOGRAM T WAVE AMPLITUDE DECREASED 1 (1.2) 1 (1.0) 0 (0.0) 2 (0.8) BIOPSY 0 (0.0) 0 (0.0) 1 (1.4) 1 (0.4)
Subjects are counted once per SOC and once per PT.
# ---- Example 2: Two-level nested band — Control vs Active arms ----
#
# Efficacy BOR table where the active arms are grouped under an
# "Active" sub-band and the placebo arm under a "Control"
# sub-band, both under a single "Treatment Group" parent.
# Demonstrates the named-list value form for arbitrary-depth
# nesting.
bor_levels <- c(
"CR", "PR", "SD", "NON-CR/NON-PD", "PD", "NE", "MISSING",
"ORR (CR + PR)", "CBR (CR + PR + SD)",
"DCR (CR + PR + SD + NON-CR/NON-PD)", "95% CI (Clopper-Pearson)"
)
eff <- cdisc_eff_resp
eff$stat_label <- factor(eff$stat_label, levels = bor_levels)
ne <- stats::setNames(cdisc_eff_n$n, cdisc_eff_n$arm_short)
tabular(
eff,
titles = c(
"Table 14.2.1",
"Best Overall Response and Response Rates",
"Efficacy Evaluable Population"
),
footnotes = "Response per RECIST 1.1, investigator assessment."
) |>
cols(
stat_label = col_spec(label = "Response"),
row_type = col_spec(visible = FALSE),
groupid = col_spec(visible = FALSE),
group_label = col_spec(visible = FALSE),
placebo = col_spec(label = "Placebo\nN={ne['placebo']}"),
drug_50 = col_spec(label = "Drug 50\nN={ne['drug_50']}"),
drug_100 = col_spec(label = "Drug 100\nN={ne['drug_100']}")
) |>
headers(
"Treatment Group" = list(
"Control" = "placebo",
"Active" = c("drug_50", "drug_100")
)
) |>
sort_rows(by = c("groupid", "stat_label"))
Table 14.2.1
Best Overall Response and Response Rates
Efficacy Evaluable Population
Treatment Group Control Active Response Placebo
N=86 Drug 50
N=84 Drug 100
N=84 CR 1 (1.2) 1 (1.2) 1 (1.2) PR 1 (1.2) 0 0 SD 1 (1.2) 0 0 NON-CR/NON-PD 0 0 1 (1.2) PD 0 0 1 (1.2) NE 0 1 (1.2) 0 MISSING 83 (96.5) 82 (97.6) 81 (96.4) ORR (CR + PR) 2 (2.3) 1 (1.2) 1 (1.2) 95% CI (Clopper-Pearson) (0.3, 8.1) (0.0, 6.5) (0.0, 6.5) CBR (CR + PR + SD) 3 (3.5) 1 (1.2) 1 (1.2) 95% CI (Clopper-Pearson) (0.7, 9.9) (0.0, 6.5) (0.0, 6.5) DCR (CR + PR + SD + NON-CR/NON-PD) 3 (3.5) 1 (1.2) 2 (2.4) 95% CI (Clopper-Pearson) (0.7, 9.9) (0.0, 6.5) (0.3, 8.3)
Response per RECIST 1.1, investigator assessment.
# ---- Example 3: Multiple peer bands side by side ----
#
# Vital-signs summary where the parameter columns (param,
# paramcd, visit, stat_label) sit on the left under a "Variable"
# band, and the arm columns sit on the right under "Treatment
# Group". Demonstrates multiple top-level bands in one call --
# bands render side by side in the order declared.
vit <- cdisc_saf_vital
tabular(vit, titles = c("Table 14.4.1", "Vital Signs Summary")) |>
cols(
param = col_spec(usage = "group", label = "Parameter"),
paramcd = col_spec(visible = FALSE),
visit = col_spec(usage = "group", label = "Visit"),
stat_label = col_spec(label = "Statistic"),
placebo = col_spec(label = "Placebo", align = "decimal"),
drug_50 = col_spec(label = "Drug 50", align = "decimal"),
drug_100 = col_spec(label = "Drug 100", align = "decimal")
) |>
headers(
"Variable" = c("param", "paramcd", "visit", "stat_label"),
"Treatment Group" = c("placebo", "drug_50", "drug_100")
)
Table 14.4.1
Vital Signs Summary
Variable Treatment Group Statistic Placebo Drug 50 Drug 100 Diastolic Blood Pressure (mmHg) Baseline n 340 384 288 Mean (SD) 77.1 (10.7) 76.6 (9.8) 78.2 (10.3) Median 77.7 76.7 78.8 Min, Max 40 , 110 48 , 108 51 , 108 Week 8 n 292 240 224 Mean (SD) 75.2 (9.1) 75.4 (10.6) 77.4 (9.1) Median 76.0 74.0 78.3 Min, Max 49 , 101 52 , 100 54 , 98 Week 16 n 272 168 148 Mean (SD) 75.1 (10.9) 75.2 (10.0) 76.0 (9.0) Median 76.0 75.7 77.3 Min, Max 49 , 98 55 , 98 50 , 92 End of Treatment n 222 177 168 Mean (SD) 74.4 (10.7) 76.0 (11.2) 76.0 (9.9) Median 73.5 76.0 78.0 Min, Max 49 , 104 50 , 100 56 , 98 Pulse Rate (beats/min) Baseline n 340 384 288 Mean (SD) 73.5 (11.6) 72.1 (10.8) 72.4 (9.7) Median 72.3 70.0 71.7 Min, Max 51 , 134 50 , 104 52 , 100 Week 8 n 292 240 224 Mean (SD) 71.8 (9.0) 72.6 (11.1) 74.0 (8.9) Median 72.0 72.0 73.2 Min, Max 52 , 102 49 , 104 50 , 104 Week 16 n 272 168 148 Mean (SD) 70.6 (8.8) 68.8 (9.4) 73.2 (9.5) Median 70.2 68.0 72.0 Min, Max 50 , 90 48 , 104 51 , 96 End of Treatment n 222 177 168 Mean (SD) 75.2 (11.5) 74.1 (9.4) 73.6 (9.6) Median 74.0 75.0 73.0 Min, Max 51 , 106 50 , 94 50 , 98 Systolic Blood Pressure (mmHg) Baseline n 340 384 288 Mean (SD) 136.8 (17.6) 137.9 (18.5) 137.8 (17.2) Median 136.3 138.0 138.0 Min, Max 80 , 184 100 , 194 100 , 192 Week 8 n 292 240 224 Mean (SD) 136.3 (17.0) 134.9 (17.8) 135.1 (15.5) Median 136.5 132.3 134.0 Min, Max 90 , 189 92 , 200 91 , 198 Week 16 n 272 168 148 Mean (SD) 134.6 (18.3) 132.5 (14.3) 133.7 (16.0) Median 134.0 130.0 132.0 Min, Max 76 , 190 100 , 168 99 , 186 End of Treatment n 222 177 168 Mean (SD) 132.7 (15.4) 133.0 (17.1) 132.3 (15.6) Median 131.0 130.0 131.0 Min, Max 78 , 172 92 , 178 100 , 177 Temperature (C) Baseline n 172 190 144 Mean (SD) 36.6 (0.4) 36.5 (0.4) 36.6 (0.4) Median 36.7 36.6 36.6 Min, Max 35 , 37 35 , 37 36 , 37 Week 8 n 146 118 112 Mean (SD) 36.6 (0.4) 36.6 (0.4) 36.6 (0.4) Median 36.6 36.7 36.7 Min, Max 36 , 37 36 , 37 36 , 37 Week 16 n 136 82 74 Mean (SD) 36.7 (0.3) 36.6 (0.4) 36.6 (0.4) Median 36.7 36.6 36.7 Min, Max 36 , 37 36 , 37 36 , 37 End of Treatment n 74 59 56 Mean (SD) 36.7 (0.4) 36.6 (0.4) 36.6 (0.4) Median 36.8 36.7 36.7 Min, Max 35 , 37 35 , 38 36 , 37
# ---- Example 4: Three-tier band over efficacy arms + Total ----
#
# Demographics-style three-tier nesting: top band labels the
# whole arm strip, middle band splits Active vs Placebo, leaf
# bands carry the per-arm column labels. Each child within a
# `list(...)` may itself be a `list(...)` — bands nest to
# arbitrary depth using nested list literals.
n <- stats::setNames(cdisc_saf_n$n, cdisc_saf_n$arm_short)
tabular(cdisc_saf_demo, titles = "Demographics, hierarchical headers") |>
cols(
variable = col_spec(usage = "group", label = "Characteristic"),
stat_label = col_spec(label = "Statistic"),
placebo = col_spec(label = "N={n['placebo']}"),
drug_50 = col_spec(label = "N={n['drug_50']}"),
drug_100 = col_spec(label = "N={n['drug_100']}"),
Total = col_spec(label = "N={n['Total']}")
) |>
headers(
"Treatment Group" = list(
"Control" = "placebo",
"Active" = list(
"Drug 50" = "drug_50",
"Drug 100" = "drug_100"
),
"Pooled" = "Total"
)
)
Demographics, hierarchical headers
Treatment Group Control Active Pooled Drug 50 Drug 100 Statistic N=86 N=96 N=72 N=254 Age (years) n 86 96 72 254 Mean (SD) 75.2 (8.59) 76.0 (8.11) 73.8 (7.94) 75.1 (8.25) Median 76.0 78.0 75.5 77.0 Q1, Q3 69.2, 81.8 71.0, 82.0 70.5, 79.0 70.0, 81.0 Min, Max 52, 89 51, 88 56, 88 51, 89 Sex, n (%) F 53 (61.6) 55 (57.3) 35 (48.6) 143 (56.3) M 33 (38.4) 41 (42.7) 37 (51.4) 111 (43.7) Race, n (%) WHITE 78 (90.7) 90 (93.8) 62 (86.1) 230 (90.6) BLACK OR AFRICAN AMERICAN 8 (9.3) 6 (6.2) 9 (12.5) 23 (9.1) ASIAN 0 (0.0) 0 (0.0) 0 (0.0) 0 (0.0) AMERICAN INDIAN OR ALASKA NATIVE 0 (0.0) 0 (0.0) 1 (1.4) 1 (0.4)