Skip to contentVibraUI
Charts

Activity calendar

A year of daily counts as a grid of weeks, in GitHub's proportions and the theme's tokens.

No recharts: it is CSS grid and colour. endDate defaults to the latest date in data, and only falls back to today — read after hydration through useSyncExternalStore — when there is no data at all, so the server and the client never disagree about which day the grid ends on. Dates are plain YYYY-MM-DD calendar days, read in no timezone at all, and repeat entries for a day are added together. Shades are quantiles of this calendar's own non-zero counts, so it answers "busy for this calendar" rather than "busy on some absolute scale". The window widens to whole weeks at both ends, so the grid stays rectangular. It is a calendar first and a chart second: it shows rhythm — which weeks were busy and which were not — and it is the wrong form for comparing two periods or reading a total, which are a bar chart and a number. Under about three months there is no rhythm to see and a bar chart of days says more. It is role="grid" and every day announces its own count and date — "3 events on Sep 3, 2026" unless countLabel names what the days count, as a noun ({ one, other }, which a server component can pass) or a function; buildWeeks is exported and tested. Wider than its card, it opens on its latest weeks — the inline end of its scroller, so the left on a right-to-left page — and keeps them in view as the card resizes until the reader scrolls away; a new window opens on its latest weeks again. While it is wider than its card the scroller is a tab stop and a region named like the grid, so the arrow keys can scroll it; while it fits it is neither. The root is min-w-0, so it scrolls inside a flex row too.

Install

npx shadcn@latest add @vibra/activity-calendar

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
data{ date: string; count: number }[]—One entry per day as YYYY-MM-DD. Repeats for a day are added together.
monthsnumber12How far back the window runs from endDate.
endDateDate | stringthe latest date in data, then todayThe last day shown. Pass it to keep the grid identical on the server and the client.
levelsnumber4Shades above empty; every busy day is graded into one of them by quantile.
colorChartToken"chart-1"The hue the shades are mixed from.
showMonthLabelsbooleantrueNames the month above the week it starts in, skipping any that would collide.
showWeekdayLabelsbooleantrueLabels Monday, Wednesday, and Friday down the left.
showLegendbooleantrueThe "Less to More" key beneath the grid.
countLabel{ one: string; other: string } | ((count: number) => string){ one: "event", other: "events" }What a day's count is read out as, before its date, and in the summary: "No deploys", "1 deploy", "3 deploys". The noun form is plain data, so a server component can pass it.

Dependencies

Source

components/ui/activity-calendar/index.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { isChartToken, type ChartToken } from "@/components/ui/percentage-bar"
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip"

import { useLatestWeeksInView, useScrollsSideways } from "./viewport"
import { buildWeeks, dayLabel, fromKey, monthLabels, type ActivityDay } from "./weeks"

export { buildWeeks }
export type { ActivityDay, ActivityWeek } from "./weeks"

const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
// Sunday, Tuesday, Thursday and Saturday stay unlabelled: at 11px a caption on
// every row is a wall of type, and every other row is enough to count from.
const LABELLED_WEEKDAYS = [1, 3, 5]

// GitHub's proportions, in the theme's tokens: an 11px cell with a 3px gutter.
const CELL = 11
const GAP = 3

/** What the days count, in the singular and the plural — plain data, so a server component can pass it. */
export type ActivityCountNoun = { one: string; other: string }

/** "No contributions", "1 contribution", "3 contributions" from the noun. */
function nounLabel({ one, other }: ActivityCountNoun): (count: number) => string {
  return (count) => (count === 0 ? `No ${other}` : `${count} ${count === 1 ? one : other}`)
}

/** "No events", "1 event", "3 events": what a day's count is read out as by default. */
const DEFAULT_COUNT_LABEL = nounLabel({ one: "event", other: "events" })

// Nothing to subscribe to: the store exists only to hand the server and the
// client different snapshots, the same shape countdown uses to reveal a
// browser-only value after hydration.
const subscribeToNothing = () => () => {}
const onClient = () => true
const onServer = () => false

/** False through the server render and the hydrating one, true from the commit on. */
function useHasClock(): boolean {
  return React.useSyncExternalStore(subscribeToNothing, onClient, onServer)
}

export type ActivityCalendarProps = React.ComponentProps<"div"> & {
  /** One entry per day as `YYYY-MM-DD`; repeats for a day are added together. */
  data: ActivityDay[]
  months?: number
  /** The last day shown. Defaults to the latest date in `data`, then to today. */
  endDate?: Date | string
  /** Shades above empty; every busy day is graded into one of them by quantile. */
  levels?: number
  color?: ChartToken
  showMonthLabels?: boolean
  showWeekdayLabels?: boolean
  showLegend?: boolean
  /**
   * What a day's count is read out as, before its date — "No events",
   * "1 event", "3 events" by default. Name what the days count when it is
   * something else, as a noun (`{ one: "deploy", other: "deploys" }`, which
   * a server component can pass) or a function of the count; the grid's
   * summary uses it too.
   */
  countLabel?: ((count: number) => string) | ActivityCountNoun
}

function ActivityCalendar({
  className,
  data,
  months = 12,
  endDate,
  levels = 4,
  color,
  showMonthLabels = true,
  showWeekdayLabels = true,
  showLegend = true,
  countLabel,
  "aria-label": ariaLabel,
  ...props
}: ActivityCalendarProps) {
  const labelOf =
    countLabel === undefined ? DEFAULT_COUNT_LABEL : typeof countLabel === "function" ? countLabel : nounLabel(countLabel)
  // Guarded rather than trusted: `color` is typed to the palette, but an
  // unchecked string from JavaScript would go into a custom property name.
  const hue: ChartToken = color !== undefined && isChartToken(color) ? color : "chart-1"
  const steps = Math.max(1, Math.round(levels))

  // The clock is read only when there is nothing else to end on, and only after
  // hydration — a calendar with an endDate or a day of data renders identically
  // on the server and in the browser.
  const hasClock = useHasClock()
  const end = React.useMemo(() => {
    if (endDate !== undefined) return endDate instanceof Date ? endDate : fromKey(endDate)
    let latest: string | null = null
    for (const entry of data) if (latest === null || entry.date > latest) latest = entry.date
    return latest !== null ? fromKey(latest) : hasClock ? new Date() : null
  }, [endDate, data, hasClock])

  const weeks = React.useMemo(
    () => (end === null ? [] : buildWeeks(data, end, months, steps)),
    [data, end, months, steps]
  )
  const captions = React.useMemo(() => monthLabels(weeks), [weeks])
  const days = weeks.flat()
  const total = days.reduce((sum, day) => sum + day.count, 0)
  // The count label opens a clause here, so a plain first word is lowercased
  // — "No events" reads "no events" — but a word with more capitals than its
  // first letter keeps them: "PRs merged" stays as it is.
  const counted = labelOf(total)
  const clause = /^[A-Z][a-z]*\b/.test(counted) ? `${counted.charAt(0).toLowerCase()}${counted.slice(1)}` : counted
  const summary =
    ariaLabel ??
    (days.length === 0
      ? "Activity calendar with no data."
      : `Activity calendar, ${clause} across ${days.length} days ending ${dayLabel(days[days.length - 1].date)}.`)

  const scrollerRef = React.useRef<HTMLDivElement>(null)
  useLatestWeeksInView(scrollerRef, days.length === 0 ? "" : `${weeks.length}:${days[days.length - 1].date}`)
  const scrolling = useScrollsSideways(scrollerRef)

  const track = `repeat(${weeks.length}, ${CELL}px)`
  const columns = showWeekdayLabels ? `auto ${track}` : track
  const rows = `${showMonthLabels ? "auto " : ""}repeat(7, ${CELL}px)`

  return (
    <div
      data-slot="activity-calendar"
      data-levels={steps}
      // min-w-0: a scroller must be allowed to be narrower than what it
      // scrolls, in a flex row as much as in a column.
      className={cn("flex w-full min-w-0 flex-col gap-2", className)}
      {...props}
    >
      <TooltipProvider>
        <div
          ref={scrollerRef}
          data-slot="activity-calendar-viewport"
          {...(scrolling ? { tabIndex: 0, role: "region", "aria-label": summary } : null)}
          // relative: the containing block of the visually hidden headers,
          // so they clip with the weeks rather than widen the page.
          className="relative overflow-x-auto rounded-sm focus-ring-inset"
        >
          <div
            role="grid"
            aria-label={summary}
            className="grid w-max"
            style={{ gridTemplateColumns: columns, gridTemplateRows: rows, gap: GAP }}
          >
            {/* Every header names what it heads in text, shown or not — a
                header named by aria-label alone, or not at all, reads as empty
                (axe empty-table-header): the corner names the weekday column,
                an uncaptioned week the day it starts on. */}
            {showMonthLabels ? (
              <div role="row" className="contents">
                {showWeekdayLabels ? (
                  <div role="columnheader">
                    <span className="sr-only">Day</span>
                  </div>
                ) : null}
                {weeks.map((week, w) => (
                  <div
                    key={week[0].date}
                    role="columnheader"
                    className="text-avatar leading-none whitespace-nowrap text-muted-foreground"
                  >
                    {captions[w] ?? <span className="sr-only">{`Week of ${dayLabel(week[0].date)}`}</span>}
                  </div>
                ))}
              </div>
            ) : null}

            {WEEKDAYS.map((weekday, d) => (
              <div key={weekday} role="row" className="contents">
                {showWeekdayLabels ? (
                  <div
                    role="rowheader"
                    className="flex items-center pe-1 text-avatar leading-none text-muted-foreground"
                  >
                    <span className={LABELLED_WEEKDAYS.includes(d) ? undefined : "sr-only"}>{weekday}</span>
                  </div>
                ) : null}
                {weeks.map((week) => {
                  const day = week[d]
                  const label = `${labelOf(day.count)} on ${dayLabel(day.date)}`
                  return (
                    <Tooltip key={day.date}>
                      <TooltipTrigger
                        render={
                          <div
                            data-slot="activity-calendar-day"
                            data-level={day.level}
                            role="gridcell"
                            aria-label={label}
                            className="rounded-[2px] bg-muted"
                            style={levelStyle(day.level, steps, hue)}
                          />
                        }
                      />
                      <TooltipContent>{label}</TooltipContent>
                    </Tooltip>
                  )
                })}
              </div>
            ))}
          </div>
        </div>
      </TooltipProvider>

      {showLegend ? (
        <div
          data-slot="activity-calendar-legend"
          className="flex items-center gap-[3px] self-end text-avatar text-muted-foreground"
        >
          <span className="pe-1">Less</span>
          {Array.from({ length: steps + 1 }, (_, level) => (
            <span
              key={level}
              aria-hidden="true"
              className="size-[11px] rounded-[2px] bg-muted"
              style={levelStyle(level, steps, hue)}
            />
          ))}
          <span className="ps-1">More</span>
        </div>
      ) : null}
    </div>
  )
}

/**
 * A day's paint, laid over the muted cell as a background *image*: one flat
 * layer on top of the class-set background, rather than a second element inside
 * every one of the 371 cells a year takes.
 */
function levelStyle(level: number, levels: number, hue: ChartToken): React.CSSProperties | undefined {
  if (level <= 0) return undefined
  const percent = Math.round(10 + (level / levels) * 90)
  const paint = `color-mix(in oklch, var(--${hue}) ${percent}%, transparent)`
  return { backgroundImage: `linear-gradient(${paint}, ${paint})` }
}

export { ActivityCalendar }
components/ui/activity-calendar/weeks.ts
/**
 * The calendar's weeks, worked out from the data: the day keys the counts
 * arrive under, the window a number of months back from the end, the whole
 * weeks it widens to, the shade each count falls in, and the month captions
 * over the columns. Pure functions of their arguments — the component reads
 * no clock here — so every one is tested on its own.
 */

const DAY = 86_400_000
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]

// A month name is about three cells wide, so one starting fewer than three
// columns after the last labelled month goes uncaptioned rather than on top of it.
const MIN_LABEL_GAP = 3

export type ActivityDay = { date: string; count: number }
export type ActivityWeek = { date: string; count: number; level: number }[]

/** The `YYYY-MM-DD` key for a UTC timestamp — the shape the data arrives in. */
function dayKey(ms: number): string {
  return new Date(ms).toISOString().slice(0, 10)
}

/**
 * Midnight UTC of the calendar day a `Date` names, read through its *local*
 * parts — the day a reader means by "today" is their own.
 */
function toDay(date: Date): number {
  return Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())
}

/** A `Date` standing for the calendar day a `YYYY-MM-DD` string spells, in the reader's own timezone. */
export function fromKey(key: string): Date | null {
  const [year, month, day] = key.split("-").map(Number)
  if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return null
  return new Date(year, month - 1, day)
}

/** "Sep 3, 2026" from a day key — read off the string, so no timezone can shift it. */
export function dayLabel(key: string): string {
  const [year, month, day] = key.split("-").map(Number)
  return `${MONTHS[month - 1]} ${day}, ${year}`
}

/**
 * The level each non-zero count falls in: counts are split at even quantiles of
 * themselves, so the shading answers "busy for this calendar" rather than "busy
 * on some absolute scale that one heroic day would blow out". Zero is always
 * level 0 — an empty day is empty, not merely the quietest one.
 */
function levelScale(counts: number[], levels: number): (count: number) => number {
  const top = Math.max(1, Math.round(levels))
  const sorted = counts.filter((count) => count > 0).sort((a, b) => a - b)
  if (sorted.length === 0) return () => 0

  const thresholds = Array.from({ length: top - 1 }, (_, i) =>
    sorted[Math.floor((sorted.length - 1) * ((i + 1) / top))]
  )
  return (count) => {
    if (count <= 0) return 0
    const index = thresholds.findIndex((threshold) => count <= threshold)
    return index === -1 ? top : index + 1
  }
}

/**
 * Midnight UTC `months` before a day, with the day of the month clamped to the
 * target month's length — so a month back from the 31st is the 28th, not a roll
 * forward into the month after, which would leave a one-month window three days
 * long.
 */
function subtractMonths(date: Date, months: number): number {
  const year = date.getUTCFullYear()
  const month = date.getUTCMonth() - months
  // Day 0 of the month after is the last day of the month itself.
  const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate()
  return Date.UTC(year, month, Math.min(date.getUTCDate(), lastDay))
}

/**
 * The calendar as columns of whole weeks, Sunday first. The window runs back
 * `months` from `endDate`, then widens to week boundaries at both ends, so
 * every column is seven cells tall and the grid stays rectangular. Repeat
 * entries for a day are added together; dates outside the window are ignored
 * rather than stretching it.
 */
export function buildWeeks(
  data: ActivityDay[],
  endDate: Date,
  months: number,
  levels = 4
): ActivityWeek[] {
  const end = toDay(endDate)
  const endParts = new Date(end)
  const from = subtractMonths(endParts, months)
  const start = from - new Date(from).getUTCDay() * DAY
  const last = end + (6 - endParts.getUTCDay()) * DAY

  // A Map, not an object: dates are caller data, and a key like "constructor"
  // must not resolve to something off Object's prototype.
  const counts = new Map<string, number>()
  for (const entry of data) {
    const date = fromKey(entry.date)
    if (date === null) continue
    const key = dayKey(toDay(date))
    counts.set(key, (counts.get(key) ?? 0) + entry.count)
  }

  const days: ActivityDay[] = []
  for (let ms = start; ms <= last; ms += DAY) {
    const date = dayKey(ms)
    days.push({ date, count: counts.get(date) ?? 0 })
  }

  const levelOf = levelScale(
    days.map((day) => day.count),
    levels
  )
  const weeks: ActivityWeek[] = []
  for (let i = 0; i < days.length; i += 7) {
    weeks.push(days.slice(i, i + 7).map((day) => ({ ...day, level: levelOf(day.count) })))
  }
  return weeks
}

/** The month name to print above each column, or null where it would crowd its neighbour. */
export function monthLabels(weeks: ActivityWeek[]): (string | null)[] {
  let lastLabelled = -MIN_LABEL_GAP
  return weeks.map((week, w) => {
    // The leftmost column is a fragment of the month before the window, so it
    // goes unlabelled and the first full month keeps the caption.
    if (w === 0) return null
    const month = Number(week[0].date.slice(5, 7))
    if (month === Number(weeks[w - 1][0].date.slice(5, 7))) return null
    if (w - lastLabelled < MIN_LABEL_GAP) return null
    lastLabelled = w
    return MONTHS[month - 1]
  })
}
components/ui/activity-calendar/viewport.ts
"use client"

import * as React from "react"

/**
 * Whether a box is wider than it can show and scrolls sideways — measured
 * after mount and whenever it, or what it holds, changes size. The arrow keys
 * scroll only what has the focus, so a box that scrolls has to be a tab stop,
 * and a region named for what it shows; one that fits is neither (Table's
 * rule).
 */
export function useScrollsSideways(ref: React.RefObject<HTMLElement | null>): boolean {
  const [scrolling, setScrolling] = React.useState(false)
  React.useEffect(() => {
    const box = ref.current
    if (!box) return
    const measure = () => setScrolling(box.scrollWidth > box.clientWidth)
    measure()
    if (typeof ResizeObserver === "undefined") return
    const observer = new ResizeObserver(measure)
    observer.observe(box)
    if (box.firstElementChild) observer.observe(box.firstElementChild)
    return () => observer.disconnect()
  }, [ref])
  return scrolling
}

/**
 * Keeps a calendar wider than its card on its latest weeks — the scroller's
 * inline end, which is its left on a right-to-left page — from the first
 * frame it can scroll: on mount, whenever the window moves (`span` names
 * it), and when the scroller or the grid in it changes size (a card that was
 * hidden, a phone turned, a font that swapped in wider). Once the reader
 * scrolls away from the latest weeks it leaves them where they are, until
 * they come back to the end.
 */
export function useLatestWeeksInView(scrollerRef: React.RefObject<HTMLDivElement | null>, span: string) {
  const following = React.useRef(true)

  const toEnd = React.useCallback(() => {
    const scroller = scrollerRef.current
    if (!scroller) return
    const travel = scroller.scrollWidth - scroller.clientWidth
    if (travel <= 0) return
    // A right-to-left scroller starts at 0 on its right edge and runs negative.
    scroller.scrollLeft = getComputedStyle(scroller).direction === "rtl" ? -travel : travel
  }, [scrollerRef])

  React.useLayoutEffect(() => {
    following.current = true
    toEnd()
  }, [span, toEnd])

  React.useEffect(() => {
    const scroller = scrollerRef.current
    if (!scroller) return
    const onScroll = () => {
      const travel = scroller.scrollWidth - scroller.clientWidth
      following.current = travel <= 0 || Math.abs(scroller.scrollLeft) >= travel - 1
    }
    scroller.addEventListener("scroll", onScroll, { passive: true })
    const observer =
      typeof ResizeObserver === "undefined"
        ? undefined
        : new ResizeObserver(() => {
            if (following.current) toEnd()
          })
    observer?.observe(scroller)
    if (scroller.firstElementChild) observer?.observe(scroller.firstElementChild)
    return () => {
      scroller.removeEventListener("scroll", onScroll)
      observer?.disconnect()
    }
  }, [scrollerRef, toEnd])
}