When visualizing statistical significance on bar plots using ggplot2, rstatix, and ggpubr, a common frustration arises with label placement. By default, rstatix::add_xy_position() determines bracket heights using the maximum observed raw value (max). While this works well for boxplots or jitter plots, it often leaves an awkward, overly large gap above bar charts that display means rather than maximums.

The Problem: Excessive Spacing with add_xy_position()

Consider a standard pipeline where you aggregate data into means, run a pairwise t-test, and attempt to add coordinates:

library(ggplot2)
library(ggpubr)
library(rstatix)
library(dplyr)

df <- ToothGrowth
df$dose <- factor(df$dose)
dfsummary <- aggregate(df, len ~ dose + supp, mean)

stat.test <- df %>%
  group_by(dose) %>%
  t_test(len ~ supp) %>%
  adjust_pvalue(method = "bonferroni") %>%
  add_significance("p.adj") %>%
  add_xy_position(x = "dose", dodge = 0.8)

Because add_xy_position() scans the full df dataset by default (where individual points might reach values like 30+), the p-value brackets float high above mean bars that only reach 26.

While you can manually hardcode positions using mutate(y.position = c(...)), this is brittle and fails when data updates. Here are the best dynamic solutions.

Solution 1: Pass the Aggregated Data directly to add_xy_position() (Easiest)

The cleanest approach is to pass your aggregated summary table (dfsummary) into the data argument of add_xy_position(). Because the summary dataset only contains mean values, finding the "maximum" value within a group automatically selects the higher mean.

# Calculate positions using the summarized data frame
stat.test <- stat.test %>%
  add_xy_position(x = "dose", dodge = 0.8, data = dfsummary, formula = len ~ supp)

# Render the plot
ggplot(data = dfsummary, aes(x = dose, y = len, fill = supp)) +
  geom_col(position = position_dodge(0.8), width = 0.7) +
  theme_classic() +
  scale_fill_manual(values = c("#00AFBB", "#E7B800")) +
  stat_pvalue_manual(stat.test, label = "p.adj.signif", tip.length = 0.02)

How it works:

  • By providing data = dfsummary, add_xy_position() evaluates the maximum len within each dose in dfsummary.
  • Since len in dfsummary holds group means, it automatically picks the taller bar as the reference point.

Solution 2: Dynamically Compute y.position with dplyr

If you want full control over the bracket height (for instance, to add custom offsets or padding), you can compute the maximum mean programmatically using standard dplyr pipelines:

# 1. Find the highest mean for each dose group
max_means <- dfsummary %>%
  group_by(dose) %>%
  summarise(max_len = max(len), .groups = "drop")

# 2. Join the heights and set the y.position with custom padding
stat.test <- stat.test %>%
  left_join(max_means, by = "dose") %>%
  mutate(y.position = max_len + 2.5) # Add desired vertical offset

# 3. Plot
ggplot(data = dfsummary, aes(dose, len, fill = supp)) +
  geom_col(position = "dodge") +
  theme_classic() +
  scale_fill_manual(values = c("#00AFBB", "#E7B800")) +
  stat_pvalue_manual(stat.test, label = "p.adj.signif", tip.length = 0)

Solution 3: Using fun = "mean_se" with stat_summary()

If you prefer not to create an intermediate aggregated table (dfsummary) and instead plot raw data directly with stat_summary(), you can combine this with fun = "max" or custom summaries inside add_xy_position():

# Compute mean positions directly
stat.test <- df %>%
  group_by(dose) %>%
  t_test(len ~ supp) %>%
  adjust_pvalue(method = "bonferroni") %>%
  add_significance("p.adj") %>%
  add_xy_position(x = "dose", dodge = 0.8, fun = "mean_sd")

ggplot(df, aes(x = dose, y = len, fill = supp)) +
  stat_summary(fun = mean, geom = "bar", position = position_dodge(0.8)) +
  stat_summary(fun.data = mean_se, geom = "errorbar", position = position_dodge(0.8), width = 0.2) +
  theme_classic() +
  stat_pvalue_manual(stat.test, label = "p.adj.signif")

Summary

To avoid awkward empty space above your bar charts when displaying statistical significance:

  • Pass your summarized table into add_xy_position(data = dfsummary, ...).
  • Or use left_join() with dplyr to set y.position directly based on max(mean) + offset.