How to Visualize Likert Scale Data with a Diverging Stacked Bar Chart

โฑ๏ธ 9 min read ๐Ÿ“Š Visualization

Plot agree/disagree survey responses as a diverging stacked bar chart: one horizontal bar per question, disagreement extending left of a center line, agreement extending right, and the neutral category straddling the middle. Naomi Robbins and Richard Heiberger made the case for this design in their 2011 paper "Plotting Likert and Other Rating Scales," and it beats an ordinary 100% stacked bar because the lean of every item is visible against one shared center line.

Quick answer: Use a horizontal diverging stacked bar chart. Keep the segments in scale order (strongly disagree to strongly agree), shift each bar so the middle of the neutral category sits on a common zero line, sort the questions by net agreement (% agree minus % disagree), and color it with a diverging palette: two hues that darken toward the extremes, with gray for neutral. This is the display Robbins and Heiberger (2011) recommend for Likert and other rating scales.

What is a diverging stacked bar chart for Likert data?

It is a stacked bar chart in which each bar is slid sideways so the scale's midpoint lands on a vertical zero line. Everything on the negative side of the scale (strongly disagree, disagree) stacks outward to the left, everything on the positive side (agree, strongly agree) stacks outward to the right, and the neutral category is split in half across the line. Each bar still totals 100% of respondents, but its position now tells you something: a bar that sits mostly right of zero is a question people agreed with, and a bar that sits mostly left is one they rejected. Axis labels show absolute percentages on both sides (40%, 20%, 0%, 20%, 40%) rather than negative numbers.

Why use a diverging bar instead of a 100% stacked bar?

Because a 100% stacked bar has no visual center. It anchors every bar at 0% on the left and 100% on the right, so total disagreement reads from the left edge and total agreement from the right, but to see whether a question leaned positive you have to compare two segments at opposite ends of the bar, and the neutral block floats wherever the disagree segments happen to end. A diverging bar puts the scale's midpoint on one shared line, so each question's lean becomes a position judgment against a common reference: bars shifted right leaned positive, bars shifted left leaned negative. Cleveland and McGill's graphical perception experiments found that people judge position along a common scale more accurately than lengths or positions on unaligned scales, which is why a shared center line makes a long list of questions easier to compare (see the visual encoding hierarchy). The general trade-offs of stacking are covered in when to use stacked bar charts.

100% stacked barDiverging stacked bar
Where each bar is anchored0% at the left edge, 100% at the rightThe midpoint of the scale, on a shared zero line
Can you see which way an item leans?Only by comparing segments at opposite ends of the barYes, from how far the bar reaches left vs. right of zero
Neutral categoryFloats mid-bar, wherever the disagree segments endCentered on zero on every row
What it shows wellThat each item's responses sum to 100%The balance of opinion across many items

Where should the neutral category go?

Split it in half across the zero line unless the neutral share varies a lot between questions. Splitting is what Robbins and Heiberger do, and it is how Heiberger's HH package for R draws an odd-numbered scale by default: half the neutral respondents sit left of zero and half sit right, so the bar stays centered on the scale's true midpoint. The cost is that the agree segments no longer start exactly at zero; they start half a neutral block out, so a question with 30% neutral pushes its agree and disagree blocks further apart than a question with 10% neutral. When that distortion matters, move neutral out of the bar entirely.

Neutral placementWhat sits on the zero lineUse it when
Split across zero (Robbins & Heiberger)The middle of the neutral blockNeutral shares are similar across items, or you want the full distribution in one bar
Separate neutral bar beside the chartThe inner edges of both the agree and disagree blocksNeutral shares differ a lot and the agree-vs-disagree comparison is the point
All on one side of zeroOne edge of neutralNever: it inflates whichever side receives it
Dropped from the chartAgree and disagree inner edgesOnly if you report the neutral share elsewhere; otherwise it hides respondents

Scales with an even number of points (4 or 6) have no neutral category, so zero falls between the two middle responses. "Don't know" and "Not applicable" are not midpoints of the scale; plot them as a separate bar or footnote, and say which denominator your percentages use.

How should I sort the survey questions?

Sort the questions (the rows) by net agreement, the percentage who agree or strongly agree minus the percentage who disagree or strongly disagree, with the highest at the top. The segments inside each bar always stay in scale order; it is only the order of the questions that changes. Net agreement is a better sort key than % agree alone because it also accounts for how many people pushed back, which breaks ties that % agree cannot. The exception is when the questions have a meaningful order of their own, such as the same question asked every year or items grouped by questionnaire section; keep that order, as covered in how to sort chart data.

Avoid sorting by the mean of 1 to 5 codes. It treats the gap between "neutral" and "agree" as equal to the gap between "agree" and "strongly agree," which an ordinal scale does not guarantee, and it hides whether a middling mean came from consensus or polarization.

What colors should a Likert chart use?

Use a diverging palette: one hue for the disagree side, a contrasting hue for the agree side, darker for the extreme categories, lighter for the moderate ones, and a neutral gray in the middle. The color then encodes the same thing the position does, direction and intensity of opinion, as explained in choosing a color palette. Blue-red, blue-orange, or purple-orange pairs from ColorBrewer (RdBu, PuOr) work well; avoid red-green, which is the pairing most readers with color vision deficiency struggle to separate. If disagreeing is not inherently bad (for example, "I feel stressed at work"), a blue-orange palette avoids implying a verdict that red would.

How do you calculate where each bar starts?

Each bar starts at minus the sum of strongly disagree, disagree, and half of neutral, then its segments are laid end to end from there, so every bar spans exactly 100 points and the neutral midpoint lands on zero. Here are four questions from a hypothetical employee survey of 200 people, in percent of respondents (SD = strongly disagree through SA = strongly agree), already sorted by net agreement:

QuestionSD / D / N / A / SAAgreeDisagreeNetBar spans
I have the tools I need5 / 10 / 15 / 45 / 257015+55โˆ’22.5 to 77.5
My manager gives useful feedback10 / 15 / 20 / 35 / 205525+30โˆ’35 to 65
I see a path to promotion15 / 25 / 30 / 20 / 103040โˆ’10โˆ’55 to 45
My workload is manageable20 / 30 / 20 / 20 / 103050โˆ’20โˆ’60 to 40

For "I have the tools I need," the bar starts at โˆ’(5 + 10 + 15/2) = โˆ’22.5 and ends at โˆ’22.5 + 100 = 77.5. Notice the bottom two questions: both have exactly 30% agreeing, so a sort on % agree would treat them as a tie. Net agreement separates them, because the workload question has 50% disagreeing versus 40%. This is the same data used in all three code examples below.

How do I make a diverging stacked bar chart in Excel?

Excel has no diverging bar chart type, so build a regular stacked bar chart with an invisible "spacer" series in front of the real data. The spacer pushes each bar right by just enough that every neutral midpoint lines up at the same position. With the question in column A and the five response percentages in B through F:

1. G2 (Left side):  =B2+C2+D2/2          ' SD + D + half of Neutral
   H2 (Spacer):     =MAX($G$2:$G$5)-G2   ' fill both down
   I2 (Net):        =E2+F2-B2-C2         ' sort the table by this, largest first
2. Select A1:F5, Ctrl-select H1:H5, then Insert โ†’ Column or Bar Chart โ†’ Stacked Bar
   (if the questions show up in the legend, click Chart Design โ†’ Switch Row/Column)
3. Chart Design โ†’ Select Data โ†’ select Spacer โ†’ Move Up until it is first
4. Format the Spacer series: No fill, No border; delete it from the legend
5. Format the vertical axis โ†’ tick "Categories in reverse order"
   (Excel plots the first row at the bottom by default)
6. Format the horizontal axis โ†’ Vertical axis crosses โ†’ Axis value:
   type the largest value in column G (60 in the example); the axis line
   now runs through every neutral midpoint and acts as the zero line
7. Format the vertical axis โ†’ Labels โ†’ Label Position: Low
   (moves the question labels back to the left edge)
8. Set the series colors to your diverging palette and Gap Width to about 50%

With the example data, column G holds 22.5, 35, 55, and 60, so the spacers are 37.5, 25, 5, and 0, and every bar's neutral midpoint lands at 60. The horizontal axis numbers now measure from the spacer's left edge rather than from the midpoint, so hide them (Label Position: None) and add data labels to the segments instead.

How do I make a Likert chart in Python?

Use matplotlib's barh with a left offset: compute each bar's starting point, draw one call per response level, and move the offset along after each level. There is no built-in Likert chart in matplotlib or pandas, but the offset approach needs only a short loop:

import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.ticker import FuncFormatter

levels = ["Strongly disagree", "Disagree", "Neutral", "Agree", "Strongly agree"]
colors = ["#ca0020", "#f4a582", "#bababa", "#92c5de", "#0571b0"]

df = pd.DataFrame(
    [[10, 15, 20, 35, 20],
     [ 5, 10, 15, 45, 25],
     [20, 30, 20, 20, 10],
     [15, 25, 30, 20, 10]],
    index=["My manager gives useful feedback",
           "I have the tools I need",
           "My workload is manageable",
           "I see a path to promotion"],
    columns=levels,
)

# Net agreement; barh draws the first row at the bottom,
# so sort ascending to put the most positive question on top
net = (df["Agree"] + df["Strongly agree"]) - (df["Strongly disagree"] + df["Disagree"])
df = df.loc[net.sort_values().index]

# Each bar starts left of zero by SD + D + half of Neutral
left = -(df["Strongly disagree"] + df["Disagree"] + df["Neutral"] / 2)

fig, ax = plt.subplots(figsize=(9, 3.5))
for level, color in zip(levels, colors):
    ax.barh(df.index, df[level], left=left, color=color, label=level)
    left = left + df[level]

ax.set_xlim(-80, 80)                      # symmetric, so zero is centered
ax.axvline(0, color="black", linewidth=1)
ax.xaxis.set_major_formatter(FuncFormatter(lambda x, _: f"{abs(x):.0f}%"))
ax.legend(ncol=5, loc="lower center", bbox_to_anchor=(0.5, 1.0),
          frameon=False, fontsize="small")
plt.tight_layout()
plt.show()

If your raw data has one row per answer (a question column and a response column), build the percentage table first with pd.crosstab(raw["question"], raw["response"], normalize="index") * 100, then put its columns into scale order with .reindex(columns=levels, fill_value=0), which also adds a zero column for any response nobody chose.

How do I make a Likert chart in R?

The quickest route is the likert() function in Heiberger's HH package, the implementation behind Heiberger and Robbins' 2014 Journal of Statistical Software article on diverging stacked bar charts. Give it a matrix of counts with one row per question and one column per response level in scale order; as.percent = TRUE converts each row to percentages and prints the row totals (the n per question) on the right-hand axis:

library(HH)

# Counts from 200 respondents per question
m <- matrix(c(10, 20, 30, 90, 50,
              20, 30, 40, 70, 40,
              40, 60, 40, 40, 20,
              30, 50, 60, 40, 20),
            nrow = 4, byrow = TRUE,
            dimnames = list(
              Question = c("I have the tools I need",
                           "My manager gives useful feedback",
                           "My workload is manageable",
                           "I see a path to promotion"),
              Response = c("Strongly disagree", "Disagree", "Neutral",
                           "Agree", "Strongly agree")))

likert(m, as.percent = TRUE, positive.order = TRUE,
       main = "Employee survey", xlab = "Percent")

With an odd number of levels, HH centers the middle (neutral) column on zero automatically. It keeps rows in their original order unless you set positive.order = TRUE, which puts the bar that reaches farthest to the right at the top; that ranks by agreement plus half of neutral, which is close to, but not the same as, net agreement. If you want full control in ggplot2, compute each segment's start and end yourself and draw them with geom_tile():

library(dplyr)
library(tidyr)
library(ggplot2)

scale_levels <- c("Strongly disagree", "Disagree", "Neutral",
                  "Agree", "Strongly agree")

survey <- tibble::tribble(
  ~item,                              ~sd, ~d, ~n, ~a, ~sa,
  "My manager gives useful feedback",  10, 15, 20, 35, 20,
  "I have the tools I need",            5, 10, 15, 45, 25,
  "My workload is manageable",         20, 30, 20, 20, 10,
  "I see a path to promotion",         15, 25, 30, 20, 10
)

long <- survey |>
  mutate(net   = (a + sa) - (sd + d),
         start = -(sd + d + n / 2)) |>
  pivot_longer(sd:sa, names_to = "response", values_to = "pct") |>
  mutate(response = factor(response, levels = c("sd", "d", "n", "a", "sa"),
                           labels = scale_levels)) |>
  arrange(item, response) |>
  group_by(item) |>
  mutate(xmax = start + cumsum(pct), xmin = xmax - pct) |>
  ungroup() |>
  mutate(item = reorder(item, net))   # highest net agreement on top

ggplot(long, aes(x = (xmin + xmax) / 2, y = item,
                 width = pct, fill = response)) +
  geom_tile(height = 0.7) +
  geom_vline(xintercept = 0) +
  scale_fill_manual(values = c("#ca0020", "#f4a582", "#bababa",
                               "#92c5de", "#0571b0")) +
  scale_x_continuous(limits = c(-80, 80),
                     labels = function(x) paste0(abs(x), "%")) +
  labs(x = NULL, y = NULL, fill = NULL) +
  theme_minimal() +
  theme(legend.position = "top")

What are the common mistakes when charting Likert data?

Most bad Likert charts fail in one of a handful of predictable ways, and each one either misplaces the center of the scale or makes readers work harder to compare questions:

Pro Tip: Print the total % disagree just outside the left end of each bar and the total % agree just outside the right end. Readers get the two numbers they actually quote in meetings without decoding individual segments, and the segments themselves can then go unlabeled, which keeps a 20-question chart readable.

โ† Back to Visualization Tips