Multiple Sequence Alignments with r-gt!

Author

Victor Yuan

Published

October 3, 2025

NoteCredit

This page reproduces work created by Victor Yuan for the 2025 Posit Table Contest: Visualizing Conserved Regions Across Coronavirus Spike Proteins (contest entry Β· source code). All credit for the original analysis and design goes to the author.

TipπŸ‘‘

This submission won β€œBest gt Table” from the 2025 Table Contest by Posit

Multiple sequence alignment (MSA) is a computational technique to compare biological sequences, and identify regions of similarity and differences. This approach is used for identifying conserved functional domains, and understanding evolutionary relationships between related proteins.

For the 2025 Posit Table contest I wanted to explore how MSAs can be effectively visualized using the r package gt. This has been something I have wanted to do for a long time, and I’m excited to share this exploration.

The data we are working with is an MSA from (Wang et al. 2021), where they identified a potential universally conserved β€œweak spot” in Coronavirus spike proteins to specific cross-reactive monoclonal antibodies. The spike protein was the primary target for developing the COVID-19 vaccines, which crucially saved millions of lives during in the fight against the pandemic.

Figure 5 from their study presents an MSA comparing spike proteins from several coronavirus species: SARS-CoV, SARS-CoV-2, MERS-CoV, and HCoV-OC43 (which causes the common cold). This visualization left an immediate impression on me. Despite there being large divergence between these species, the authors were able to identify a region with enough similarity to serve as target for broadly reactive antibodies.

Let’s explore how we can leverage gt to better understand the evolutionary relationships between coronavirus protein sequences!

Visualizing Conserved Regions Across Coronvirus Spike Proteins

Click through the tabs below to explore each region of the coronavirus spike protein alignment in detail.

If you enjoyed this article and want to use some of these functions yourself, check out the development of gtseq

How it is built, step by step

The tabs above vary the palette and the highlighted region, but they all share one base_table. This section takes that table apart: each tab adds one piece of the pipeline on top of the previous one, so you can see what every part contributes. The code for each step is shown below its output.

The alignment arrives as one row per sequence with the residues in a single string. Splitting it into characters, numbering the positions, then pivoting wide gives one column per alignment position (pos_1 … pos_95), which is what lets gt treat residues as cells.

# A tibble: 8 Γ— 12
  group name  start pos_1 pos_2 pos_3 pos_4 pos_5 pos_6 pos_7 pos_8 pos_9
  <chr> <chr> <dbl> <chr> <chr> <chr> <chr> <chr> <chr> <chr> <chr> <chr>
1 beta  OC43   1225 T     S     I     P     N     L     P     D     F    
2 beta  MHV    1191 T     S     I     P     N     P     P     D     F    
3 beta  HKU1   1226 H     S     V     P     K     L     S     D     F    
4 beta  SARS   1122 P     L     Q     P     E     L     D     S     F    
5 beta  SARS2  1140 P     L     Q     P     E     L     D     S     F    
6 beta  MERS   1223 L     G     N     S     T     G     I     D     F    
7 alpha 229E   1025 T     I     V     P     E     Y     I     D     V    
8 alpha NL63   1208 T     V     I     P     D     Y     V     D     V    
tbl_data[, 1:12]

gt() with rowname_col and groupname_col puts the virus names in the stub and groups them by species. The 95 position columns are far too wide at their default size, which the next steps fix.

start pos_1 pos_2 pos_3 pos_4 pos_5 pos_6 pos_7 pos_8 pos_9 pos_10 pos_11 pos_12 pos_13 pos_14 pos_15 pos_16 pos_17 pos_18 pos_19 pos_20 pos_21 pos_22 pos_23 pos_24 pos_25 pos_26 pos_27 pos_28 pos_29 pos_30 pos_31 pos_32 pos_33 pos_34 pos_35 pos_36 pos_37 pos_38 pos_39 pos_40 pos_41 pos_42 pos_43 pos_44 pos_45 pos_46 pos_47 pos_48 pos_49 pos_50 pos_51 pos_52 pos_53 pos_54 pos_55 pos_56 pos_57 pos_58 pos_59 pos_60 pos_61 pos_62 pos_63 pos_64 pos_65 pos_66 pos_67 pos_68 pos_69 pos_70 pos_71 pos_72 pos_73 pos_74 pos_75 pos_76 pos_77 pos_78 pos_79 pos_80 pos_81 pos_82 pos_83 pos_84 pos_85 pos_86 pos_87 pos_88 pos_89 pos_90 pos_91 pos_92 pos_93 pos_94 pos_95
beta
OC43 1225 T S I P N L P D F K E E L D Q W F K N Q T S - V A P D L S L D Y - - I N V T F L D L Q V - - - - - - - - - - - - - - E M N R L Q E A I K V L N Q S Y I N L K D I G T Y E Y Y V K W P W Y V W L
MHV 1191 T S I P N P P D F K E E L D Q W F K K Q T S - I A P D L S L D F E K L N V T L L D L T Y - - - - - - - - - - - - - - E M N R I Q D A I K K L N E S Y I N L K E V G T Y E M Y V K W P W Y V W L
HKU1 1226 H S V P K L S D F E S E L S H W F K N Q T S - I A P N L T L N L H T I N A T F L D L Y Y - - - - - - - - - - - - - - E M N L I Q E S K L S L N N S Y I N L K D I G T Y E M Y V K W P W Y V W L
SARS 1122 P L Q P E L D S F K E E L D K Y F K N H T S - P D V D F G - D I S G I N A S V V N I Q K - - - - - - - - - - - - - - E I D R L N E V A K N L N E S L I D L Q E L G K Y E Q Y I K W P W Y V W L
SARS2 1140 P L Q P E L D S F K E E L D K Y F K N H T S - P D V D L G - D I S G I N A S V V N I Q K - - - - - - - - - - - - - - E I D R L N E V A K N L N E S Y I D L K E L G N Y T Y Y N K W P W Y I W L
MERS 1223 L G N S T G I D F Q D E L D E Y F K N V S T - S I P N F G - S L T Q I N T T L L D L T Y - - - - - - - - - - - - - - E M L S L Q Q V V K A L N E S Y I D L K E L G N Y T Y Y N K W P W Y I W L
alpha
229E 1025 T I V P E Y I D V N K T L Q E L S Y K L P N Y T V P D L - - V V E Q Y N Q T I L N L T S E I S T L E N K S A E L N Y T V Q K L Q T L I D N I N S T L V D L K W L N R V E T Y I K W P W Y V W V
NL63 1208 T V I P D Y V D V N K T L Q E F A Q N L P K Y V K P N F - - D L T P F N L T Y L N L S S E L K Q L E A K T A S L F Q T T V E L Q G L I D Q I N S T Y V D L K L L N R F E N Y I K W P W Y V W V
m2 <- tbl_data |>
  gt(rowname_col = c("name"), groupname_col = "group")
m2

Squeezing every position column to 13px and centring the text is what makes the table read as an alignment: residues line up into vertical columns the eye can scan for conservation.

start pos_1 pos_2 pos_3 pos_4 pos_5 pos_6 pos_7 pos_8 pos_9 pos_10 pos_11 pos_12 pos_13 pos_14 pos_15 pos_16 pos_17 pos_18 pos_19 pos_20 pos_21 pos_22 pos_23 pos_24 pos_25 pos_26 pos_27 pos_28 pos_29 pos_30 pos_31 pos_32 pos_33 pos_34 pos_35 pos_36 pos_37 pos_38 pos_39 pos_40 pos_41 pos_42 pos_43 pos_44 pos_45 pos_46 pos_47 pos_48 pos_49 pos_50 pos_51 pos_52 pos_53 pos_54 pos_55 pos_56 pos_57 pos_58 pos_59 pos_60 pos_61 pos_62 pos_63 pos_64 pos_65 pos_66 pos_67 pos_68 pos_69 pos_70 pos_71 pos_72 pos_73 pos_74 pos_75 pos_76 pos_77 pos_78 pos_79 pos_80 pos_81 pos_82 pos_83 pos_84 pos_85 pos_86 pos_87 pos_88 pos_89 pos_90 pos_91 pos_92 pos_93 pos_94 pos_95
beta
OC43 1225 T S I P N L P D F K E E L D Q W F K N Q T S - V A P D L S L D Y - - I N V T F L D L Q V - - - - - - - - - - - - - - E M N R L Q E A I K V L N Q S Y I N L K D I G T Y E Y Y V K W P W Y V W L
MHV 1191 T S I P N P P D F K E E L D Q W F K K Q T S - I A P D L S L D F E K L N V T L L D L T Y - - - - - - - - - - - - - - E M N R I Q D A I K K L N E S Y I N L K E V G T Y E M Y V K W P W Y V W L
HKU1 1226 H S V P K L S D F E S E L S H W F K N Q T S - I A P N L T L N L H T I N A T F L D L Y Y - - - - - - - - - - - - - - E M N L I Q E S K L S L N N S Y I N L K D I G T Y E M Y V K W P W Y V W L
SARS 1122 P L Q P E L D S F K E E L D K Y F K N H T S - P D V D F G - D I S G I N A S V V N I Q K - - - - - - - - - - - - - - E I D R L N E V A K N L N E S L I D L Q E L G K Y E Q Y I K W P W Y V W L
SARS2 1140 P L Q P E L D S F K E E L D K Y F K N H T S - P D V D L G - D I S G I N A S V V N I Q K - - - - - - - - - - - - - - E I D R L N E V A K N L N E S Y I D L K E L G N Y T Y Y N K W P W Y I W L
MERS 1223 L G N S T G I D F Q D E L D E Y F K N V S T - S I P N F G - S L T Q I N T T L L D L T Y - - - - - - - - - - - - - - E M L S L Q Q V V K A L N E S Y I D L K E L G N Y T Y Y N K W P W Y I W L
alpha
229E 1025 T I V P E Y I D V N K T L Q E L S Y K L P N Y T V P D L - - V V E Q Y N Q T I L N L T S E I S T L E N K S A E L N Y T V Q K L Q T L I D N I N S T L V D L K W L N R V E T Y I K W P W Y V W V
NL63 1208 T V I P D Y V D V N K T L Q E F A Q N L P K Y V K P N F - - D L T P F N L T Y L N L S S E L K Q L E A K T A S L F Q T T V E L Q G L I D Q I N S T Y V D L K L L N R F E N Y I K W P W Y V W V
m3 <- m2 |>
  tab_style(
    style = cell_text(size = "small", align = "center", indent = 0),
    locations = cells_body(columns = contains("pos_"))
  ) |>
  cols_width(
    1 ~ px(60),
    name ~ px(50),
    start ~ px(40),
    everything() ~ px(13)
  ) |>
  cols_align("right", group:start)
m3

The column labels are treated as an axis: generate_breaks() picks every fifth position and cols_label_with() blanks the rest, so the header behaves like axis ticks instead of 95 crowded numbers.

start 5 10 15 20 25 30 35 40 45 50 55 60 65 70 75 80 85 90 95
beta
OC43 1225 T S I P N L P D F K E E L D Q W F K N Q T S - V A P D L S L D Y - - I N V T F L D L Q V - - - - - - - - - - - - - - E M N R L Q E A I K V L N Q S Y I N L K D I G T Y E Y Y V K W P W Y V W L
MHV 1191 T S I P N P P D F K E E L D Q W F K K Q T S - I A P D L S L D F E K L N V T L L D L T Y - - - - - - - - - - - - - - E M N R I Q D A I K K L N E S Y I N L K E V G T Y E M Y V K W P W Y V W L
HKU1 1226 H S V P K L S D F E S E L S H W F K N Q T S - I A P N L T L N L H T I N A T F L D L Y Y - - - - - - - - - - - - - - E M N L I Q E S K L S L N N S Y I N L K D I G T Y E M Y V K W P W Y V W L
SARS 1122 P L Q P E L D S F K E E L D K Y F K N H T S - P D V D F G - D I S G I N A S V V N I Q K - - - - - - - - - - - - - - E I D R L N E V A K N L N E S L I D L Q E L G K Y E Q Y I K W P W Y V W L
SARS2 1140 P L Q P E L D S F K E E L D K Y F K N H T S - P D V D L G - D I S G I N A S V V N I Q K - - - - - - - - - - - - - - E I D R L N E V A K N L N E S Y I D L K E L G N Y T Y Y N K W P W Y I W L
MERS 1223 L G N S T G I D F Q D E L D E Y F K N V S T - S I P N F G - S L T Q I N T T L L D L T Y - - - - - - - - - - - - - - E M L S L Q Q V V K A L N E S Y I D L K E L G N Y T Y Y N K W P W Y I W L
alpha
229E 1025 T I V P E Y I D V N K T L Q E L S Y K L P N Y T V P D L - - V V E Q Y N Q T I L N L T S E I S T L E N K S A E L N Y T V Q K L Q T L I D N I N S T L V D L K W L N R V E T Y I K W P W Y V W V
NL63 1208 T V I P D Y V D V N K T L Q E F A Q N L P K Y V K P N F - - D L T P F N L T Y L N L S S E L K Q L E A K T A S L F Q T T V E L Q G L I D Q I N S T Y V D L K L L N R F E N Y I K W P W Y V W V
m4 <- m3 |>
  cols_label_with(
    fn = ~ ifelse(. %in% breaks, ., "") |> str_remove("pos_"),
    columns = contains("pos_")
  ) |>
  tab_style(
    style = list(cell_borders(sides = "all", weight = px(0))),
    locations = list(cells_body())
  )
m4

grand_summary_rows() adds two summary rows across every position column: the most frequent residue, and a bar whose height is that residue’s frequency. The bar is plain HTML from create_html_vertical_bar(), so conservation becomes a miniature profile plot along the bottom of the alignment.

start 5 10 15 20 25 30 35 40 45 50 55 60 65 70 75 80 85 90 95
beta
OC43 1225 T S I P N L P D F K E E L D Q W F K N Q T S - V A P D L S L D Y - - I N V T F L D L Q V - - - - - - - - - - - - - - E M N R L Q E A I K V L N Q S Y I N L K D I G T Y E Y Y V K W P W Y V W L
MHV 1191 T S I P N P P D F K E E L D Q W F K K Q T S - I A P D L S L D F E K L N V T L L D L T Y - - - - - - - - - - - - - - E M N R I Q D A I K K L N E S Y I N L K E V G T Y E M Y V K W P W Y V W L
HKU1 1226 H S V P K L S D F E S E L S H W F K N Q T S - I A P N L T L N L H T I N A T F L D L Y Y - - - - - - - - - - - - - - E M N L I Q E S K L S L N N S Y I N L K D I G T Y E M Y V K W P W Y V W L
SARS 1122 P L Q P E L D S F K E E L D K Y F K N H T S - P D V D F G - D I S G I N A S V V N I Q K - - - - - - - - - - - - - - E I D R L N E V A K N L N E S L I D L Q E L G K Y E Q Y I K W P W Y V W L
SARS2 1140 P L Q P E L D S F K E E L D K Y F K N H T S - P D V D L G - D I S G I N A S V V N I Q K - - - - - - - - - - - - - - E I D R L N E V A K N L N E S Y I D L K E L G N Y T Y Y N K W P W Y I W L
MERS 1223 L G N S T G I D F Q D E L D E Y F K N V S T - S I P N F G - S L T Q I N T T L L D L T Y - - - - - - - - - - - - - - E M L S L Q Q V V K A L N E S Y I D L K E L G N Y T Y Y N K W P W Y I W L
alpha
229E 1025 T I V P E Y I D V N K T L Q E L S Y K L P N Y T V P D L - - V V E Q Y N Q T I L N L T S E I S T L E N K S A E L N Y T V Q K L Q T L I D N I N S T L V D L K W L N R V E T Y I K W P W Y V W V
NL63 1208 T V I P D Y V D V N K T L Q E F A Q N L P K Y V K P N F - - D L T P F N L T Y L N L S S E L K Q L E A K T A S L F Q T T V E L Q G L I D Q I N S T Y V D L K L L N R F E N Y I K W P W Y V W V
Consensus
Sequence
T S I P E L D D F K E E L D E W F K N Q T S – I A P D L G – D L E G I N A T F L D L Q Y – – – – – – – – – – – – – – E M N R L Q E V I K N L N E S Y I D L K E L G T Y E Y Y I K W P W Y V W L
m5 <- m4 |>
  grand_summary_rows(
    columns = contains("pos"),
    fns = list(
      Consensus ~ get_consensus_return_bar(.) |>
        div(style = "width:100%;height:50px;") |>
        as.character(),
      Sequence ~ names(get_consensus(.)) |>
        stringr::str_replace("-", "--")
    ),
    fmt = list(bar ~ fmt_markdown(.)),
    missing = ""
  ) |>
  tab_style(
    style = cell_text(align = "center"),
    locations = cells_grand_summary(columns = contains("pos_"))
  ) |>
  tab_style(
    style = cell_borders(sides = "bottom", style = "hidden"),
    locations = list(
      cells_grand_summary(rows = 1),
      cells_stub_grand_summary(rows = 1)
    )
  ) |>
  tab_style(
    style = cell_text(size = "small", align = "center", indent = 0),
    locations = cells_grand_summary(columns = contains("pos_"))
  )
m5

Spanners name the functional regions (stem helix, HR2, TM), and relabelling five individual columns marks the critical epitope positions with asterisks and the glycosylation sequon with an exclamation mark. tab_options() then tightens the padding, which the consensus bars need in order to sit flush against each other. This is base_table.

start
Stem helix
25 30
HR2 region
85 90
TM region
5 10 * * * ! 20 35 40 45 50 55 60 65 70 75 80 95
beta OC43 1225 T S I P N L P D F K E E L D Q W F K N Q T S - V A P D L S L D Y - - I N V T F L D L Q V - - - - - - - - - - - - - - E M N R L Q E A I K V L N Q S Y I N L K D I G T Y E Y Y V K W P W Y V W L
MHV 1191 T S I P N P P D F K E E L D Q W F K K Q T S - I A P D L S L D F E K L N V T L L D L T Y - - - - - - - - - - - - - - E M N R I Q D A I K K L N E S Y I N L K E V G T Y E M Y V K W P W Y V W L
HKU1 1226 H S V P K L S D F E S E L S H W F K N Q T S - I A P N L T L N L H T I N A T F L D L Y Y - - - - - - - - - - - - - - E M N L I Q E S K L S L N N S Y I N L K D I G T Y E M Y V K W P W Y V W L
SARS 1122 P L Q P E L D S F K E E L D K Y F K N H T S - P D V D F G - D I S G I N A S V V N I Q K - - - - - - - - - - - - - - E I D R L N E V A K N L N E S L I D L Q E L G K Y E Q Y I K W P W Y V W L
SARS2 1140 P L Q P E L D S F K E E L D K Y F K N H T S - P D V D L G - D I S G I N A S V V N I Q K - - - - - - - - - - - - - - E I D R L N E V A K N L N E S Y I D L K E L G N Y T Y Y N K W P W Y I W L
MERS 1223 L G N S T G I D F Q D E L D E Y F K N V S T - S I P N F G - S L T Q I N T T L L D L T Y - - - - - - - - - - - - - - E M L S L Q Q V V K A L N E S Y I D L K E L G N Y T Y Y N K W P W Y I W L
alpha 229E 1025 T I V P E Y I D V N K T L Q E L S Y K L P N Y T V P D L - - V V E Q Y N Q T I L N L T S E I S T L E N K S A E L N Y T V Q K L Q T L I D N I N S T L V D L K W L N R V E T Y I K W P W Y V W V
NL63 1208 T V I P D Y V D V N K T L Q E F A Q N L P K Y V K P N F - - D L T P F N L T Y L N L S S E L K Q L E A K T A S L F Q T T V E L Q G L I D Q I N S T Y V D L K L L N R F E N Y I K W P W Y V W V
Consensus
Sequence
T S I P E L D D F K E E L D E W F K N Q T S – I A P D L G – D L E G I N A T F L D L Q Y – – – – – – – – – – – – – – E M N R L Q E V I K N L N E S Y I D L K E L G T Y E Y Y I K W P W Y V W L
m6 <- m5 |>
  tab_spanner(columns = pos_1:pos_21, label = "Stem helix") |>
  tab_spanner(columns = pos_33:pos_80, label = "HR2 region") |>
  tab_spanner(columns = pos_91:pos_95, label = "TM region") |>
  cols_label(
    pos_14 ~ "*",
    pos_15 ~ "",
    pos_16 ~ "*",
    pos_17 ~ "*",
    pos_19 ~ "!",
  ) |>
  tab_options(
    row_group.as_column = TRUE,
    table.font.size = 14,
    data_row.padding.horizontal = px(2),
    data_row.padding = px(2),
    grand_summary_row.padding.horizontal = px(0),
    grand_summary_row.padding = px(2),
    table.border.top.style = "hidden",
    grand_summary_row.border.width = px(2)
  )
m6

data_color() with apply_color_to_aa() maps each residue letter to its colour in the chosen scheme. Because it is the very last step, the same base_table can be recoloured by palette or restricted to a region, which is exactly what the tabs earlier on the page do.

start
Stem helix
25 30
HR2 region
85 90
TM region
5 10 * * * ! 20 35 40 45 50 55 60 65 70 75 80 95
beta OC43 1225 T S I P N L P D F K E E L D Q W F K N Q T S - V A P D L S L D Y - - I N V T F L D L Q V - - - - - - - - - - - - - - E M N R L Q E A I K V L N Q S Y I N L K D I G T Y E Y Y V K W P W Y V W L
MHV 1191 T S I P N P P D F K E E L D Q W F K K Q T S - I A P D L S L D F E K L N V T L L D L T Y - - - - - - - - - - - - - - E M N R I Q D A I K K L N E S Y I N L K E V G T Y E M Y V K W P W Y V W L
HKU1 1226 H S V P K L S D F E S E L S H W F K N Q T S - I A P N L T L N L H T I N A T F L D L Y Y - - - - - - - - - - - - - - E M N L I Q E S K L S L N N S Y I N L K D I G T Y E M Y V K W P W Y V W L
SARS 1122 P L Q P E L D S F K E E L D K Y F K N H T S - P D V D F G - D I S G I N A S V V N I Q K - - - - - - - - - - - - - - E I D R L N E V A K N L N E S L I D L Q E L G K Y E Q Y I K W P W Y V W L
SARS2 1140 P L Q P E L D S F K E E L D K Y F K N H T S - P D V D L G - D I S G I N A S V V N I Q K - - - - - - - - - - - - - - E I D R L N E V A K N L N E S Y I D L K E L G N Y T Y Y N K W P W Y I W L
MERS 1223 L G N S T G I D F Q D E L D E Y F K N V S T - S I P N F G - S L T Q I N T T L L D L T Y - - - - - - - - - - - - - - E M L S L Q Q V V K A L N E S Y I D L K E L G N Y T Y Y N K W P W Y I W L
alpha 229E 1025 T I V P E Y I D V N K T L Q E L S Y K L P N Y T V P D L - - V V E Q Y N Q T I L N L T S E I S T L E N K S A E L N Y T V Q K L Q T L I D N I N S T L V D L K W L N R V E T Y I K W P W Y V W V
NL63 1208 T V I P D Y V D V N K T L Q E F A Q N L P K Y V K P N F - - D L T P F N L T Y L N L S S E L K Q L E A K T A S L F Q T T V E L Q G L I D Q I N S T Y V D L K L L N R F E N Y I K W P W Y V W V
Consensus
Sequence
T S I P E L D D F K E E L D E W F K N Q T S – I A P D L G – D L E G I N A T F L D L Q Y – – – – – – – – – – – – – – E M N R L Q E V I K N L N E S Y I D L K E L G T Y E Y Y I K W P W Y V W L
m6 |>
  gt::data_color(
    columns = contains("pos_"),
    fn = apply_color_to_aa(palette = "Chemistry")
  )

References

Wang, Chunyan, Rien van Haperen, Javier GutiΓ©rrez-Álvarez, et al. 2021. β€œA Conserved Immunogenic and Vulnerable Site on the Coronavirus Spike Protein Delineated by Cross-Reactive Monoclonal Antibodies.” Nature Communications 12 (1): 1715. https://doi.org/10.1038/s41467-021-21968-w.