makeui
DocsComponents

© 2026 makeui. Open source, MIT licensed.

DocsComponentsGitHub

Activity Graph

A year of activity where every square slides into its place.

Preview

Installation

pnpm dlx shadcn@latest add https://makeui.dev/r/activity-graph.json

Dependencies

  • motion

Interaction

When the graph scrolls into view each square slides in from the side you choose. Up and down ripple row by row, left and right sweep week by week. Reduced motion shows the graph straight away.

Props

PropTypeDescription
variant"up" | "down" | "left" | "right"Direction the squares arrive from. Defaults to "up".
yearnumberYear to draw. Defaults to 2026.
dataRecord<string, number>Counts keyed by ISO date. Sample data is used when omitted.
classNamestringExtra classes merged onto the root element.

How to use

import ActivityGraph from "@/components/ui/activity-graph"

export function Example() {
  return <ActivityGraph variant="left" data={{ "2026-10-13": 4 }} />
}

Source code

components/ui/activity-graph.tsx
"use client"

import { useMemo, useRef } from "react"
import { motion, useInView, useReducedMotion, type Variants } from "motion/react"
import { cn } from "@/lib/utils"

type Variant = "up" | "down" | "left" | "right"
type Cell = { date: string; count: number }

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

const LEVELS = [
  "bg-foreground/[0.07]",
  "bg-emerald-500/30",
  "bg-emerald-500/55",
  "bg-emerald-500/80",
  "bg-emerald-500",
]

const level = (n: number) => (n <= 0 ? 0 : n <= 2 ? 1 : n <= 4 ? 2 : n <= 6 ? 3 : 4)

// Deterministic sample data: the same on the server and the client, so hydration never mismatches.
function sampleData(year: number): Record<string, number> {
  let seed = year * 9301 + 49297
  const rand = () => {
    seed = (seed * 1664525 + 1013904223) >>> 0
    return seed / 2 ** 32
  }
  const out: Record<string, number> = {}
  const d = new Date(Date.UTC(year, 0, 1))
  while (d.getUTCFullYear() === year) {
    const weekend = d.getUTCDay() === 0 || d.getUTCDay() === 6
    const r = rand()
    if (r > (weekend ? 0.7 : 0.25))
      out[d.toISOString().slice(0, 10)] = Math.ceil(rand() * 8)
    d.setUTCDate(d.getUTCDate() + 1)
  }
  return out
}

function buildWeeks(year: number, data: Record<string, number>) {
  const start = new Date(Date.UTC(year, 0, 1))
  const offset = start.getUTCDay()
  const days = (Date.UTC(year + 1, 0, 1) - start.getTime()) / 86_400_000
  const weeks: (Cell | null)[][] = []
  for (let i = 0; i < offset + days; i++) {
    const w = Math.floor(i / 7)
    ;(weeks[w] ??= Array(7).fill(null))[i % 7] = null
    if (i >= offset) {
      const date = new Date(Date.UTC(year, 0, 1 + i - offset)).toISOString().slice(0, 10)
      weeks[w][i % 7] = { date, count: data[date] ?? 0 }
    }
  }
  const labels = weeks.map((week, w) => {
    const first = week.find(Boolean)
    const firstOfMonth = week.find((c) => c && c.date.endsWith("-01"))
    if (w === 0) return first ? MONTHS[Number(first.date.slice(5, 7)) - 1] : ""
    return firstOfMonth ? MONTHS[Number(firstOfMonth.date.slice(5, 7)) - 1] : ""
  })
  return { weeks, labels }
}

export default function ActivityGraph({
  variant = "up",
  year = 2026,
  data,
  className,
}: {
  variant?: Variant
  year?: number
  data?: Record<string, number>
  className?: string
}) {
  const ref = useRef<HTMLDivElement>(null)
  const inView = useInView(ref, { once: true, margin: "0px 0px -15% 0px" })
  const reduce = useReducedMotion()
  const counts = useMemo(() => data ?? sampleData(year), [data, year])
  const { weeks, labels } = useMemo(() => buildWeeks(year, counts), [year, counts])
  const total = useMemo(() => Object.values(counts).reduce((a, b) => a + b, 0), [counts])

  const from = {
    up: { y: -12 },
    down: { y: 12 },
    left: { x: -18 },
    right: { x: 18 },
  }[variant]

  const variants: Variants = {
    hidden: { opacity: 0, x: 0, y: 0, ...from },
    show: ({ row, week }: { row: number; week: number }) => ({
      opacity: 1,
      x: 0,
      y: 0,
      transition: {
        duration: 0.45,
        ease: [0.22, 1, 0.36, 1],
        delay:
          variant === "up"
            ? row * 0.045 + week * 0.002
            : variant === "down"
              ? (6 - row) * 0.045 + week * 0.002
              : variant === "left"
                ? week * 0.014
                : (weeks.length - 1 - week) * 0.014,
      },
    }),
  }

  return (
    <div ref={ref} className={cn("w-full", className)}>
      <div className="overflow-x-auto pb-1">
        <div className="min-w-[640px]">
          <div
            className="mb-2 grid gap-[3px] text-[10px] leading-none text-muted-foreground"
            style={{ gridTemplateColumns: `repeat(${weeks.length}, minmax(0, 1fr))` }}
            aria-hidden
          >
            {labels.map((l, w) => (
              <span key={w} className="overflow-visible whitespace-nowrap">
                {l}
              </span>
            ))}
          </div>
          <div
            className="grid grid-flow-col grid-rows-7 gap-[3px]"
            style={{ gridTemplateColumns: `repeat(${weeks.length}, minmax(0, 1fr))` }}
            role="img"
            aria-label={`${total} contributions in ${year}`}
          >
            {weeks.flatMap((week, w) =>
              week.map((cell, row) => {
                const key = `${w}-${row}`
                if (!cell) return <span key={key} className="aspect-square" aria-hidden />
                const cls = cn("aspect-square rounded-[3px]", LEVELS[level(cell.count)])
                const title = `${cell.count} on ${cell.date}`
                if (reduce) return <span key={key} title={title} className={cls} />
                return (
                  <motion.span
                    key={key}
                    title={title}
                    custom={{ row, week: w }}
                    variants={variants}
                    initial="hidden"
                    animate={inView ? "show" : "hidden"}
                    className={cls}
                  />
                )
              }),
            )}
          </div>
        </div>
      </div>
      <div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
        <span className="tabular-nums">
          {total} contributions in {year}
        </span>
        <span className="flex items-center gap-1" aria-hidden>
          Less
          {LEVELS.map((l) => (
            <span key={l} className={cn("size-2.5 rounded-[3px]", l)} />
          ))}
          More
        </span>
      </div>
    </div>
  )
}

On this page

JanFebMarAprMayJunJulAugSepOctNovDec
963 contributions in 2026LessMore