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.jsonDependencies
- 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
| Prop | Type | Description |
|---|---|---|
| variant | "up" | "down" | "left" | "right" | Direction the squares arrive from. Defaults to "up". |
| year | number | Year to draw. Defaults to 2026. |
| data | Record<string, number> | Counts keyed by ISO date. Sample data is used when omitted. |
| className | string | Extra 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>
)
}