Activity Heatmap

Orders per day Last 12 weeks · 785 orders
Orders per day in the last 12 weeks; the columns are the weeks, starting on Monday
Jul 13Jul 20Jul 27Aug 3Aug 10Aug 17Aug 24Aug 31Sep 7Sep 14Sep 21Sep 28
MonJul 13: 10 ordersJul 20: 12 ordersJul 27: 3 ordersAug 3: 4 ordersAug 10: 3 ordersAug 17: 0 ordersAug 24: 16 ordersAug 31: 10 ordersSep 7: 0 ordersSep 14: 22 ordersSep 21: 12 ordersSep 28: 23 orders
TueJul 14: 18 ordersJul 21: 1 orderJul 28: 2 ordersAug 4: 5 ordersAug 11: 6 ordersAug 18: 20 ordersAug 25: 8 ordersSep 1: 21 ordersSep 8: 15 ordersSep 15: 6 ordersSep 22: 11 ordersSep 29: 22 orders
WedJul 15: 10 ordersJul 22: 14 ordersJul 29: 8 ordersAug 5: 8 ordersAug 12: 11 ordersAug 19: 12 ordersAug 26: 12 ordersSep 2: 5 ordersSep 9: 17 ordersSep 16: 14 ordersSep 23: 20 ordersSep 30: 7 orders
ThuJul 16: 18 ordersJul 23: 10 ordersJul 30: 12 ordersAug 6: 19 ordersAug 13: 4 ordersAug 20: 10 ordersAug 27: 0 ordersSep 3: 12 ordersSep 10: 18 ordersSep 17: 16 ordersSep 24: 0 ordersOct 1: 19 orders
FriJul 17: 3 ordersJul 24: 6 ordersJul 31: 5 ordersAug 7: 13 ordersAug 14: 17 ordersAug 21: 16 ordersAug 28: 11 ordersSep 4: 16 ordersSep 11: 12 ordersSep 18: 15 ordersSep 25: 17 ordersOct 2: 9 orders
SatJul 18: 0 ordersJul 25: 0 ordersAug 1: 11 ordersAug 8: 0 ordersAug 15: 7 ordersAug 22: 12 ordersAug 29: 0 ordersSep 5: 11 ordersSep 12: 0 ordersSep 19: 13 ordersSep 26: 9 ordersOct 3: 12 orders
SunJul 19: 0 ordersJul 26: 0 ordersAug 2: 0 ordersAug 9: 0 ordersAug 16: 12 ordersAug 23: 0 ordersAug 30: 0 ordersSep 6: 1 orderSep 13: 0 ordersSep 20: 7 ordersSep 27: 12 ordersOct 4: 12 orders
@use 'sprucecss/scss/spruce' as *;

.activity-heatmap {
  --activity-heatmap-cell: 1.125rem;
  --activity-heatmap-gap: 3px;
  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('s');

  &__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);
  }

  &__scroll {
    overflow-x: auto;

    &:focus-visible {
      outline: 2px solid color('focus-ring');
      outline-offset: 2px;
    }
  }

  &__grid {
    border-collapse: separate;
    border-spacing: var(--activity-heatmap-gap);
    color: color('muted');
    font-size: 0.75rem;
    inline-size: auto;
    margin: 0;
    table-layout: fixed;

    th,
    td {
      border: 0;
      line-height: 1;
      padding: 0;
    }

    thead th {
      block-size: 1.25em;
      font-weight: 400;
      inline-size: var(--activity-heatmap-cell);
      position: relative;
      text-align: start;
    }

    abbr {
      border: 0;
      cursor: default;
      text-decoration: none;
    }

    td[data-level] {
      block-size: var(--activity-heatmap-cell);
    }
  }

  // The month label overflows its column, so it doesn't widen it.
  &__month {
    inset-block-start: 0;
    inset-inline-start: 0;
    position: absolute;
    white-space: nowrap;
  }

  &__day {
    font-weight: 400;
    padding-inline-end: spacer('xxs') !important;
    text-align: start;

    &--hidden abbr {
      @include visually-hidden;
    }
  }

  &__cell,
  &__grid td[data-level] {
    background-color: color('surface-muted');
    border-radius: radius('sm');
    box-shadow: inset 0 0 0 1px color-alpha('border', 0.8);
    inline-size: var(--activity-heatmap-cell);
  }

  &__cell,
  &__grid td {
    &[data-level='1'] {
      background-color: color-alpha('primary', 0.25);
    }

    &[data-level='2'] {
      background-color: color-alpha('primary', 0.5);
    }

    &[data-level='3'] {
      background-color: color-alpha('primary', 0.75);
    }

    &[data-level='4'] {
      background-color: color('primary');
    }
  }

  &__legend {
    align-items: center;
    color: color('muted');
    display: flex;
    font-size: 0.75rem;
    gap: var(--activity-heatmap-gap);
    justify-content: flex-end;

    span:first-child {
      margin-inline-end: spacer('xxs');
    }

    span:last-child {
      margin-inline-start: spacer('xxs');
    }
  }

  &__cell {
    block-size: var(--activity-heatmap-cell);
    display: inline-block;
  }
}
<figure class="activity-heatmap">
    <figcaption class="activity-heatmap__caption">
        <span class="activity-heatmap__title">Orders per day</span>
        <span class="activity-heatmap__subtitle">Last 12 weeks · 785 orders</span>
    </figcaption>
    <div class="activity-heatmap__scroll" tabindex="0" role="region" aria-label="Orders per day, scrollable">
        <table class="activity-heatmap__grid">
            <caption class="sr-only">Orders per day in the last 12 weeks; the columns are the weeks, starting on Monday</caption>
            <thead>
                <tr>
                    <td></td>
                    <th scope="col"><span class="activity-heatmap__month">Jul</span> <span class="sr-only">13</span></th>
                    <th scope="col"><span class="sr-only">Jul 20</span></th>
                    <th scope="col"><span class="sr-only">Jul 27</span></th>
                    <th scope="col"><span class="activity-heatmap__month">Aug</span> <span class="sr-only">3</span></th>
                    <th scope="col"><span class="sr-only">Aug 10</span></th>
                    <th scope="col"><span class="sr-only">Aug 17</span></th>
                    <th scope="col"><span class="sr-only">Aug 24</span></th>
                    <th scope="col"><span class="sr-only">Aug 31</span></th>
                    <th scope="col"><span class="activity-heatmap__month">Sep</span> <span class="sr-only">7</span></th>
                    <th scope="col"><span class="sr-only">Sep 14</span></th>
                    <th scope="col"><span class="sr-only">Sep 21</span></th>
                    <th scope="col"><span class="sr-only">Sep 28</span></th>
                </tr>
            </thead>
            <tbody>
                <tr>
                    <th class="activity-heatmap__day" scope="row"><abbr title="Monday">Mon</abbr></th>
                    <td data-level="2"><span class="sr-only">Jul 13: 10 orders</span></td>
                    <td data-level="3"><span class="sr-only">Jul 20: 12 orders</span></td>
                    <td data-level="1"><span class="sr-only">Jul 27: 3 orders</span></td>
                    <td data-level="1"><span class="sr-only">Aug 3: 4 orders</span></td>
                    <td data-level="1"><span class="sr-only">Aug 10: 3 orders</span></td>
                    <td data-level="0"><span class="sr-only">Aug 17: 0 orders</span></td>
                    <td data-level="4"><span class="sr-only">Aug 24: 16 orders</span></td>
                    <td data-level="2"><span class="sr-only">Aug 31: 10 orders</span></td>
                    <td data-level="0"><span class="sr-only">Sep 7: 0 orders</span></td>
                    <td data-level="4"><span class="sr-only">Sep 14: 22 orders</span></td>
                    <td data-level="3"><span class="sr-only">Sep 21: 12 orders</span></td>
                    <td data-level="4"><span class="sr-only">Sep 28: 23 orders</span></td>
                </tr>
                <tr>
                    <th class="activity-heatmap__day activity-heatmap__day--hidden" scope="row"><abbr title="Tuesday">Tue</abbr></th>
                    <td data-level="4"><span class="sr-only">Jul 14: 18 orders</span></td>
                    <td data-level="1"><span class="sr-only">Jul 21: 1 order</span></td>
                    <td data-level="1"><span class="sr-only">Jul 28: 2 orders</span></td>
                    <td data-level="1"><span class="sr-only">Aug 4: 5 orders</span></td>
                    <td data-level="2"><span class="sr-only">Aug 11: 6 orders</span></td>
                    <td data-level="4"><span class="sr-only">Aug 18: 20 orders</span></td>
                    <td data-level="2"><span class="sr-only">Aug 25: 8 orders</span></td>
                    <td data-level="4"><span class="sr-only">Sep 1: 21 orders</span></td>
                    <td data-level="3"><span class="sr-only">Sep 8: 15 orders</span></td>
                    <td data-level="2"><span class="sr-only">Sep 15: 6 orders</span></td>
                    <td data-level="3"><span class="sr-only">Sep 22: 11 orders</span></td>
                    <td data-level="4"><span class="sr-only">Sep 29: 22 orders</span></td>
                </tr>
                <tr>
                    <th class="activity-heatmap__day" scope="row"><abbr title="Wednesday">Wed</abbr></th>
                    <td data-level="2"><span class="sr-only">Jul 15: 10 orders</span></td>
                    <td data-level="3"><span class="sr-only">Jul 22: 14 orders</span></td>
                    <td data-level="2"><span class="sr-only">Jul 29: 8 orders</span></td>
                    <td data-level="2"><span class="sr-only">Aug 5: 8 orders</span></td>
                    <td data-level="3"><span class="sr-only">Aug 12: 11 orders</span></td>
                    <td data-level="3"><span class="sr-only">Aug 19: 12 orders</span></td>
                    <td data-level="3"><span class="sr-only">Aug 26: 12 orders</span></td>
                    <td data-level="1"><span class="sr-only">Sep 2: 5 orders</span></td>
                    <td data-level="4"><span class="sr-only">Sep 9: 17 orders</span></td>
                    <td data-level="3"><span class="sr-only">Sep 16: 14 orders</span></td>
                    <td data-level="4"><span class="sr-only">Sep 23: 20 orders</span></td>
                    <td data-level="2"><span class="sr-only">Sep 30: 7 orders</span></td>
                </tr>
                <tr>
                    <th class="activity-heatmap__day activity-heatmap__day--hidden" scope="row"><abbr title="Thursday">Thu</abbr></th>
                    <td data-level="4"><span class="sr-only">Jul 16: 18 orders</span></td>
                    <td data-level="2"><span class="sr-only">Jul 23: 10 orders</span></td>
                    <td data-level="3"><span class="sr-only">Jul 30: 12 orders</span></td>
                    <td data-level="4"><span class="sr-only">Aug 6: 19 orders</span></td>
                    <td data-level="1"><span class="sr-only">Aug 13: 4 orders</span></td>
                    <td data-level="2"><span class="sr-only">Aug 20: 10 orders</span></td>
                    <td data-level="0"><span class="sr-only">Aug 27: 0 orders</span></td>
                    <td data-level="3"><span class="sr-only">Sep 3: 12 orders</span></td>
                    <td data-level="4"><span class="sr-only">Sep 10: 18 orders</span></td>
                    <td data-level="4"><span class="sr-only">Sep 17: 16 orders</span></td>
                    <td data-level="0"><span class="sr-only">Sep 24: 0 orders</span></td>
                    <td data-level="4"><span class="sr-only">Oct 1: 19 orders</span></td>
                </tr>
                <tr>
                    <th class="activity-heatmap__day" scope="row"><abbr title="Friday">Fri</abbr></th>
                    <td data-level="1"><span class="sr-only">Jul 17: 3 orders</span></td>
                    <td data-level="2"><span class="sr-only">Jul 24: 6 orders</span></td>
                    <td data-level="1"><span class="sr-only">Jul 31: 5 orders</span></td>
                    <td data-level="3"><span class="sr-only">Aug 7: 13 orders</span></td>
                    <td data-level="4"><span class="sr-only">Aug 14: 17 orders</span></td>
                    <td data-level="4"><span class="sr-only">Aug 21: 16 orders</span></td>
                    <td data-level="3"><span class="sr-only">Aug 28: 11 orders</span></td>
                    <td data-level="4"><span class="sr-only">Sep 4: 16 orders</span></td>
                    <td data-level="3"><span class="sr-only">Sep 11: 12 orders</span></td>
                    <td data-level="3"><span class="sr-only">Sep 18: 15 orders</span></td>
                    <td data-level="4"><span class="sr-only">Sep 25: 17 orders</span></td>
                    <td data-level="2"><span class="sr-only">Oct 2: 9 orders</span></td>
                </tr>
                <tr>
                    <th class="activity-heatmap__day activity-heatmap__day--hidden" scope="row"><abbr title="Saturday">Sat</abbr></th>
                    <td data-level="0"><span class="sr-only">Jul 18: 0 orders</span></td>
                    <td data-level="0"><span class="sr-only">Jul 25: 0 orders</span></td>
                    <td data-level="3"><span class="sr-only">Aug 1: 11 orders</span></td>
                    <td data-level="0"><span class="sr-only">Aug 8: 0 orders</span></td>
                    <td data-level="2"><span class="sr-only">Aug 15: 7 orders</span></td>
                    <td data-level="3"><span class="sr-only">Aug 22: 12 orders</span></td>
                    <td data-level="0"><span class="sr-only">Aug 29: 0 orders</span></td>
                    <td data-level="3"><span class="sr-only">Sep 5: 11 orders</span></td>
                    <td data-level="0"><span class="sr-only">Sep 12: 0 orders</span></td>
                    <td data-level="3"><span class="sr-only">Sep 19: 13 orders</span></td>
                    <td data-level="2"><span class="sr-only">Sep 26: 9 orders</span></td>
                    <td data-level="3"><span class="sr-only">Oct 3: 12 orders</span></td>
                </tr>
                <tr>
                    <th class="activity-heatmap__day activity-heatmap__day--hidden" scope="row"><abbr title="Sunday">Sun</abbr></th>
                    <td data-level="0"><span class="sr-only">Jul 19: 0 orders</span></td>
                    <td data-level="0"><span class="sr-only">Jul 26: 0 orders</span></td>
                    <td data-level="0"><span class="sr-only">Aug 2: 0 orders</span></td>
                    <td data-level="0"><span class="sr-only">Aug 9: 0 orders</span></td>
                    <td data-level="3"><span class="sr-only">Aug 16: 12 orders</span></td>
                    <td data-level="0"><span class="sr-only">Aug 23: 0 orders</span></td>
                    <td data-level="0"><span class="sr-only">Aug 30: 0 orders</span></td>
                    <td data-level="1"><span class="sr-only">Sep 6: 1 order</span></td>
                    <td data-level="0"><span class="sr-only">Sep 13: 0 orders</span></td>
                    <td data-level="2"><span class="sr-only">Sep 20: 7 orders</span></td>
                    <td data-level="3"><span class="sr-only">Sep 27: 12 orders</span></td>
                    <td data-level="3"><span class="sr-only">Oct 4: 12 orders</span></td>
                </tr>
            </tbody>
        </table>
    </div>
    <div class="activity-heatmap__legend" aria-hidden="true">
        <span>Less</span>
        <span class="activity-heatmap__cell" data-level="0"></span>
        <span class="activity-heatmap__cell" data-level="1"></span>
        <span class="activity-heatmap__cell" data-level="2"></span>
        <span class="activity-heatmap__cell" data-level="3"></span>
        <span class="activity-heatmap__cell" data-level="4"></span>
        <span>More</span>
    </div>
</figure>

Activity Heatmap

A calendar heatmap that shows the daily activity (orders, commits, sign-ups) of the last weeks. Dependency-free: a table with one cell per day and five intensity levels.

Anatomy

  • .activity-heatmap__grid: a table with the weeks as columns and the weekdays as rows. Every cell has a data-level from 0 (none) to 4 (most) and the exact value as hidden text.
  • .activity-heatmap__day--hidden: hides the row label visually (only Mon, Wed and Fri are shown) but keeps it for screen readers.
  • .activity-heatmap__legend: the "Less – More" scale, using the same data-level colors.

Accessibility and Usability

  • It's a real data table with a caption and row and column headers, so screen reader users can move through it cell by cell and hear "Tuesday, Sep 14, Sep 15: 12 orders".
  • The intensity is not only a color: every cell holds its value as text. The legend is decorative (aria-hidden).
  • On small screens the table scrolls; the scroll container is a focusable, labelled region, so keyboard users can scroll it too.

Technical Details

  • Calculate the levels on the server: split your range into four buckets (for example by quartiles) and print data-level for every day. The demo markup was generated, which is what you'd do in your template.
  • The levels are color-alpha('primary', …), so the heatmap follows the primary color of every style and the dark mode.
  • Change the cell size and the gap with --activity-heatmap-cell (default 1.125rem) and --activity-heatmap-gap (default 3px).

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.