Donut Chart

Traffic sources Last 30 days

24.8k visits

  • Organic search42%
  • Direct28%
  • Social18%
  • Referral12%
@use 'sprucecss/scss/spruce' as *;

.donut-chart {
  --donut-chart-size: 11rem;
  --donut-chart-thickness: 1.5rem;
  --donut-chart-color-1: #{color('primary')};
  --donut-chart-color-2: #{color('info', 'alert')};
  --donut-chart-color-3: #{color('warning', 'alert')};
  --donut-chart-color-4: #{color('success', 'alert')};
  background-color: color('surface');
  border: config('border-width-base', $display) solid color('border');
  border-radius: radius('lg');
  margin: 0;
  padding: spacer('m');
  @include layout-stack('m');

  &__caption {
    display: flex;
    flex-wrap: wrap;
    gap: spacer('xxs') spacer('xs');
    justify-content: space-between;
  }

  &__title {
    color: color('heading');
    font-weight: 700;
  }

  &__subtitle {
    color: color('muted');
    font-size: config('font-size-sm', $typography);
  }

  &__body {
    align-items: center;
    display: flex;
    flex-wrap: wrap;
    gap: spacer('m') spacer('l');
    justify-content: center;
  }

  &__ring {
    align-items: center;
    aspect-ratio: 1;
    display: grid;
    flex-shrink: 0;
    inline-size: var(--donut-chart-size);
    justify-items: center;
    position: relative;

    // The ring is a conic gradient with a hole cut out by a radial mask.
    &::before {
      background: conic-gradient(
        var(--donut-chart-color-1) 0 var(--segment-1),
        var(--donut-chart-color-2) 0 var(--segment-2),
        var(--donut-chart-color-3) 0 var(--segment-3),
        var(--donut-chart-color-4) 0
      );
      border-radius: 50%;
      content: '';
      inset: 0;
      mask: radial-gradient(farthest-side, transparent calc(100% - var(--donut-chart-thickness)), #000 calc(100% - var(--donut-chart-thickness) + 1px));
      position: absolute;

      @include motion-safe {
        animation: donut-chart-in 0.8s ease-out;
      }
    }
  }

  &__total {
    color: color('muted');
    display: grid;
    font-size: config('font-size-sm', $typography);
    line-height: 1.3;
    margin: 0;
    text-align: center;

    strong {
      color: color('heading');
      font-family: config('font-family-heading', $typography);
      font-size: font-size('h3');
    }
  }

  &__legend {
    @include clear-list;
    display: grid;
    flex-grow: 1;
    gap: spacer('xs');
    max-inline-size: 18rem;

    // The items sit side by side: clear the stack margins of the base list style.
    > li {
      margin-block: 0;
      padding-block: 0;
    }
  }

  &__key {
    align-items: center;
    display: flex;
    gap: spacer('xs');

    @for $i from 1 through 4 {
      &:nth-child(#{$i}) {
        --donut-chart-key-color: var(--donut-chart-color-#{$i});
      }
    }
  }

  &__swatch {
    background-color: var(--donut-chart-key-color);
    block-size: 0.75rem;
    border-radius: radius('sm');
    flex-shrink: 0;
    inline-size: 0.75rem;
  }

  &__label {
    flex-grow: 1;
  }

  &__value {
    color: color('heading');
    font-variant-numeric: tabular-nums;
    font-weight: 700;
  }
}

@keyframes donut-chart-in {
  from {
    opacity: 0;
    transform: rotate(-90deg) scale(0.8);
  }
}
<figure class="donut-chart">
    <figcaption class="donut-chart__caption">
        <span class="donut-chart__title">Traffic sources</span>
        <span class="donut-chart__subtitle">Last 30 days</span>
    </figcaption>
    <div class="donut-chart__body">
        <div class="donut-chart__ring" style="--segment-1: 42%; --segment-2: 70%; --segment-3: 88%;">
            <p class="donut-chart__total"><strong>24.8k</strong> visits</p>
        </div>
        <ul class="donut-chart__legend" role="list">
            <li class="donut-chart__key"><span class="donut-chart__swatch" aria-hidden="true"></span><span class="donut-chart__label">Organic search</span><span class="donut-chart__value">42%</span></li>
            <li class="donut-chart__key"><span class="donut-chart__swatch" aria-hidden="true"></span><span class="donut-chart__label">Direct</span><span class="donut-chart__value">28%</span></li>
            <li class="donut-chart__key"><span class="donut-chart__swatch" aria-hidden="true"></span><span class="donut-chart__label">Social</span><span class="donut-chart__value">18%</span></li>
            <li class="donut-chart__key"><span class="donut-chart__swatch" aria-hidden="true"></span><span class="donut-chart__label">Referral</span><span class="donut-chart__value">12%</span></li>
        </ul>
    </div>
</figure>

Donut Chart

A dependency-free donut chart with a total in the middle and a legend with the values. The ring is a single CSS conic-gradient.

Anatomy

  • .donut-chart__ring: the ring with the total (__total) in the middle. The segments are set with cumulative custom properties: --segment-1 is the end of the first segment, --segment-2 the end of the second, and so on (42%, 42% + 28% = 70%, 70% + 18% = 88%); the last segment fills the rest.
  • .donut-chart__legend: a list of __key items with a swatch, the label and the value. The nth key gets the nth segment color.

Accessibility and Usability

  • The legend holds the data as text, so screen readers get "Organic search, 42%" for every segment; the ring itself is only a background.
  • The ring colors use the alert tokens next to the primary color. Because the values are written out in the legend, the chart doesn't rely on telling the colors apart.
  • Keep a donut to four or five segments; group the rest into "Other". For more categories, use the Bar Chart.

Technical Details

  • The hole is a mask with a radial-gradient; change the size and the ring width with --donut-chart-size (default 11rem) and --donut-chart-thickness (default 1.5rem).
  • The colors are --donut-chart-color-1 to --donut-chart-color-4. For more segments, add a color, a --segment-* stop to the gradient and raise the loop in the SCSS.
  • Calculate the cumulative stops on the server (or in your template), so no JavaScript is needed.

Documentation

Learn about Spruce CSS through our extensive documentation.

Components

Explore our UI library of accessible components built with Spruce CSS.

Blocks and Pages

Full-width page blocks and complete page templates, ready to copy.

Find us on GitHub

Did you find a bug? Have an idea or a question? Please open an issue to help us develop the project.