Overlapping Bar Charts: A Flexible Approach

Overlapping bar charts are a useful way to compare two data sets in a single view—think year-over-year figures or current progress against a goal. CSS flexbox and a small amount of JavaScript make them straightforward to build, and they’re considerably more flexible than static image-based alternatives.

A two-by-two grid of overlapping chart examples.

Semantic Markup for Chart Structure

The chart takes advantage of HTML description lists (<dl>) for their inherent label-and-value pairing, which is more semantic than ordered or unordered lists for this workflow. There are three primary lists: .numbers contains y-axis labels, .bars holds the visualization, and the x-axis labels are part of the bar list items. Each bar’s height is driven by a data-percentage attribute, keeping CSS lean and avoiding repetitive hard-coded heights.

<div class="container">
  <div class="chart">
    <dl class="numbers">
      <dd><span>100%</span></dd>
      <!-- all the way to 0% -->
    </dl>
    <dl class="bars">
      <div>
          <dt>2018</dt>
          <dd>
            <div class="bar"></div>
            <div class="bar overlap"></div>
          </dd>
        </div>
      <div>
      <!-- more bars -->
    </dl>
  </div>
</div>

Styling the Chart Container

When building the main layout, flexbox is the primary tool. A .chart element becomes a flex container, placing the y-axis labels alongside the chart area. The axis labels themselves are flex items arranged in a column along the vertical axis.

.chart {
  display: flex;
}
.numbers {
  display: flex;
  flex-direction: column;
  list-style: none;
  margin: 0 15px 0 0;
  padding: 0;
}

The .bars container in turn uses flexbox to lay each set of vertical bars out in a row. It flexes to fill the space that remains after the y-axis labels occupy theirs.

.bars {
  display: flex;
  flex: auto; /* fill up the rest of the `.chart` space */
  gap: 60px;
}

Each individual “bar” is actually a pair of bars. The parent element is a <div> wrapping a <dt> (used as a label) and a <dd> containing both bar values.

.bars > div {
  align-items: center;
  display: flex;
  flex-direction: column;
  flex: 1;
  position: relative;
}

Both bars share equal widths via flex: 1. Their heights are set with CSS custom properties that pull from the data-percentage attribute. A small JavaScript loop reads those values and applies the appropriate height to each bar directly.

var bars = document.querySelectorAll("dd .bar");
bars.forEach((bar) => {
  var height = bar.getAttribute("data-percentage");
  bar.style.height = height + "%";
});

Creating the Overlap Effect

The process of producing the overlap is intentional and direct. Each grouped bar’s parent is relatively positioned. The bars inside are then absolutely positioned at the bottom of that container, ensuring both rise from the same baseline. To distinguish the two data sets, one bar receives an additional .overlap class. That bar is widened with padding and set to a lower stacking order using z-index, which places it behind its counterpart, making the overlap clear visually. The styling choices are left to taste, but the result is a single composite bar where the overlapping segment is immediately distinguishable.

Adding a Legend

A legend adds important context for reading which bar belongs to which data set. Wrapping the chart in a <figure> element is a natural fit—the HTML spec specifically calls out diagrams as appropriate uses—and the legend itself is built with plain <div> elements. The visual treatment is custom; the logic helps the viewer decouple the visually integrated bars into their respective categories.

<figure class="legend">
  <div class="type1">Estimate</div>
  <div class="type2">Actual</div>
</figure>

Accessibility and Keyboard Support

Visual styling was only part of the effort; making the finished chart usable for people who don’t consume visual content required deliberate decisions across three fronts.

Color Contrast Compliance

Color choices we tested meet WCAG AA standards for contrast, focusing on three areas: overlap legibility, bar-to-background distinctions, and label readability.

  • The overlapping bars (#25DEAA and #696969) have a 3.16:1 ratio.
  • A bar above the chart background (#696969 against #111) registers at 3.43:1.
  • Y-axis label text (#fff over #333) steps well beyond AA at 12.63:1.

Enabling Tab Navigation

Keyboard users need to focus on each bar individually. The tabindex attribute is applied via JavaScript when generating the bars—each receives a value of 0, placing them in the natural tab order. CSS is then used to improve the visibility of the focus outline.

bar.setAttribute("tabindex", 0);
.bar:focus {
  outline: 1.5px solid #f1f1f1;
}

Screen Reader Announcements

When a bar is focused, the aria-label attribute is used to announce whether the user is interacting with an “Estimated” or “Actual” bar. The numeric value lives inside a <span> which is styled with the classic .visually-hidden pattern, ensuring the figures are present and announced to screen readers without altering the visual design.

<div class="bar" aria-label="Estimate">50%</div>
<div class="bar" aria-label="Estimate">
  <span class="visually-hidden">50%</span>
</div>
.visually-hidden {
  clip: rect(0 0 0 0); 
  clip-path: inset(50%);
  height: 1px;
  overflow: hidden;
  position: absolute;
  white-space: nowrap; 
  width: 1px;
}

Two additional steps hide background content from assistive tech that would otherwise create noise. The y-axis labels are redundant with the values announced on each bar, so they are marked with aria-hidden. The legend, being a purely visual aid, is similarly hidden.

<dl class="numbers" aria-hidden="true">
  <dd><span>100%</span></dd>
  <dd><span>80%</span></dd>
  <dd><span>60%</span></dd>
  <dd><span>40%</span></dd>
  <dd><span>20%</span></dd>
  <dd><span>0%</span></dd>
</dl>
<figure class="legend" aria-hidden="true">
  <div class="type1">Estimate</div>
  <div class="type2">Actual</div>
</figure>

Final Thoughts

The chart represents a complete flow—a clean semantic markup structure, CSS flexbox for layout, JS for setting proportional heights, and deliberate accessibility handling that both supports keyboard navigation and offers a cleaner screen reader experience than the visual composition alone would suggest. Alternative styling and markup approaches certainly exist, but this implementation shows how data visualization on the web can be both compact and explicitly accessible.