data visualization
Activity Heatmap
A calendar-aligned activity heatmap with intensity and bounded entry motion.
iOSAndroidExpo Go
reanimated
Installation
Install the dependencies, then copy the files for your preferred renderer.
pnpm add react-native-reanimatedData shape
Each entry provides a local calendar date and a numeric value.
Duplicate dates use the last entry, and non-positive values render as inactive.
Behavior
- The heatmap defaults to the configured number of recent weeks ending today.
- When
endDateis omitted, today rolls over automatically at local midnight while the component is mounted. startDateandendDatecan define an explicit local calendar range.weeksmust be a positive integer, andstartDatecannot be afterendDate.entryEffectcontrols how active cells enter the heatmap.
Install tailwind variants
pnpm add tailwind-variantsUsage
TrainingHistory.tsximport { ActivityHeatmap } from "@animations/ui/components/data-visualization/activity-heatmap/uniwind";const trainingEntries = [ { date: new Date(2026, 6, 27), value: 42 }, { date: new Date(2026, 6, 29), value: 58 }, { date: new Date(2026, 6, 31), value: 36 },];export function TrainingHistory() { return ( <ActivityHeatmap className="rounded-2xl bg-white p-4" entries={trainingEntries} endDate={new Date(2026, 7, 2)} startDate={new Date(2026, 6, 1)} /> );}activity-heatmap/config.tsexport const ACTIVITY_HEATMAP_CONFIG = { cellSize: 10, entryEffect: "wave", gap: 3, layout: { labelGap: 6, labelWidth: 24, monthGap: 5, monthHeight: 12, }, locale: "en-US", motion: { delay: 520, entryDuration: 420, fadeDuration: 680, initialScale: 0.78, }, startDay: 1, weeks: 18,} as const;export const ACTIVITY_HEATMAP_COLORS = { active: "#22c55e", empty: "#edf0ed", label: "#858b85",} as const;activity-heatmap/model.tsimport type { ActivityHeatmapEntry, ActivityHeatmapLevel,} from "./types";export type ActivityHeatmapCell = { active: boolean; column: number; inRange: boolean; level: ActivityHeatmapLevel; key: string; row: number;};export type ActivityHeatmapWeek = { cells: readonly ActivityHeatmapCell[]; key: string;};export type ActivityHeatmapLabel = { key: string; label: string | null;};export type ActivityHeatmapModel = { activeDays: number; monthLabels: readonly ActivityHeatmapLabel[]; weekdayLabels: readonly ActivityHeatmapLabel[]; weeks: readonly ActivityHeatmapWeek[];};type BuildActivityHeatmapModelOptions = { entries: readonly ActivityHeatmapEntry[]; endDate: Date; locale: string; startDate?: Date; startDay: 0 | 1 | 2 | 3 | 4 | 5 | 6; weeks: number;};function startOfLocalDay(date: Date) { return new Date( date.getFullYear(), date.getMonth(), date.getDate(), );}function addLocalDays(date: Date, days: number) { return new Date( date.getFullYear(), date.getMonth(), date.getDate() + days, );}function padDatePart(value: number) { return String(value).padStart(2, "0");}/** Returns a stable local-day key without converting through UTC. */export function getActivityHeatmapDateKey(date: Date) { return [ date.getFullYear(), padDatePart(date.getMonth() + 1), padDatePart(date.getDate()), ].join("-");}export function resolveActivityHeatmapLayout( cellSize: number, gap: number,) { return { borderRadius: Math.max(2, Math.round(cellSize * 0.25)), cellSize, gap, };}export function getActivityHeatmapCellOpacity( level: ActivityHeatmapLevel,) { return level / 4;}function getQuartile(sortedValues: readonly number[], quartile: number) { if (sortedValues.length === 0) return 0; const index = (sortedValues.length - 1) * quartile; const lowerIndex = Math.floor(index); const upperIndex = Math.ceil(index); const lower = sortedValues[lowerIndex] ?? 0; const upper = sortedValues[upperIndex] ?? lower; return lower + (upper - lower) * (index - lowerIndex);}function getActivityHeatmapLevel( value: number, quartiles: readonly [number, number, number],): ActivityHeatmapLevel { if (value <= 0) return 0; if (value < quartiles[0]) return 1; if (value < quartiles[1]) return 2; if (value < quartiles[2]) return 3; return 4;}export function getActivityHeatmapAccessibilityLabel( activeDays: number, weeks: number,) { const daysLabel = activeDays === 1 ? "active day" : "active days"; const weeksLabel = weeks === 1 ? "week" : "weeks"; return `${activeDays} ${daysLabel} across ${weeks} ${weeksLabel}`;}export function buildActivityHeatmapModel({ entries, endDate, locale, startDate, startDay, weeks,}: BuildActivityHeatmapModelOptions): ActivityHeatmapModel { const normalizedEndDate = startOfLocalDay(endDate); const normalizedStartDate = startDate ? startOfLocalDay(startDate) : undefined; if (!Number.isInteger(weeks) || weeks < 1) { throw new Error( "ActivityHeatmap weeks must be a positive integer.", ); } if ( normalizedStartDate && normalizedStartDate.getTime() > normalizedEndDate.getTime() ) { throw new Error( "ActivityHeatmap startDate must be on or before endDate.", ); } const valueByDate = new Map<string, number>(); for (const entry of entries) { valueByDate.set( getActivityHeatmapDateKey(entry.date), entry.value, ); } const offsetFromWeekStart = (normalizedEndDate.getDay() - startDay + 7) % 7; const finalWeekStart = addLocalDays( normalizedEndDate, -offsetFromWeekStart, ); const fallbackHeatmapStart = addLocalDays( finalWeekStart, -(weeks - 1) * 7, ); const requestedStart = normalizedStartDate ?? fallbackHeatmapStart; const startTime = requestedStart.getTime(); const endTime = normalizedEndDate.getTime(); const startOffset = (requestedStart.getDay() - startDay + 7) % 7; const heatmapStart = addLocalDays(requestedStart, -startOffset); const weekCount = Math.max( 1, Math.round( (finalWeekStart.getTime() - heatmapStart.getTime()) / (7 * 24 * 60 * 60 * 1_000), ) + 1, ); const rawWeeks = Array.from({ length: weekCount }, (_, column) => { const weekStart = addLocalDays(heatmapStart, column * 7); const cells = Array.from({ length: 7 }, (_, row) => { const date = addLocalDays(weekStart, row); const key = getActivityHeatmapDateKey(date); const dateTime = date.getTime(); const inRange = dateTime >= startTime && dateTime <= endTime; const value = inRange ? (valueByDate.get(key) ?? 0) : 0; return { active: value > 0, column, date, inRange, key, row, value, }; }); return { cells, key: getActivityHeatmapDateKey(weekStart), }; }); const activeValues = rawWeeks .flatMap(({ cells }) => cells) .filter((cell) => cell.active) .map((cell) => cell.value) .sort((left, right) => left - right); const quartiles = [ getQuartile(activeValues, 0.25), getQuartile(activeValues, 0.5), getQuartile(activeValues, 0.75), ] as const; const activeDays = activeValues.length; const normalizedWeeks = rawWeeks.map((week) => ({ ...week, cells: week.cells.map((cell): ActivityHeatmapCell => { const { date: _date, value, ...normalizedCell } = cell; return { ...normalizedCell, level: cell.active ? getActivityHeatmapLevel(value, quartiles) : 0, }; }), })); const formatter = new Intl.DateTimeFormat(locale, { weekday: "short", }); const weekdayLabels = Array.from({ length: 7 }, (_, row) => { const date = addLocalDays(finalWeekStart, row); return { key: String(row), label: row % 2 === 0 && row < 6 ? formatter.format(date) : null, }; }); const monthFormatter = new Intl.DateTimeFormat(locale, { month: "short", }); const monthLabels = rawWeeks.map((week, column) => { const firstDayOfMonth = week.cells.find( (cell) => cell.inRange && cell.date.getDate() === 1, ); const firstVisibleDay = week.cells.find((cell) => cell.inRange); const labelDate = firstDayOfMonth ?? (column === 0 ? firstVisibleDay : undefined); return { key: week.key, label: labelDate ? monthFormatter.format(labelDate.date) : null, }; }); return { activeDays, monthLabels, weekdayLabels, weeks: normalizedWeeks, };}activity-heatmap/motion.tsimport { Easing, Keyframe, ReduceMotion, type EntryOrExitLayoutType,} from "react-native-reanimated";import { ACTIVITY_HEATMAP_CONFIG } from "./config";import type { ActivityHeatmapEntryEffect } from "./types";type ActivityHeatmapMotionOptions = { column: number; row: number; weeks: number;};type EntryFactory = ( options: ActivityHeatmapMotionOptions,) => EntryOrExitLayoutType | undefined;function getBoundedDelay(position: number, maximum: number) { return Math.round( (position / Math.max(1, maximum)) * ACTIVITY_HEATMAP_CONFIG.motion.delay, );}function getWaveDelay(column: number, row: number, weeks: number) { const horizontalProgress = column / Math.max(1, weeks - 1); const verticalProgress = (6 - row) / 6; return Math.round( (horizontalProgress * 0.7 + verticalProgress * 0.3) * ACTIVITY_HEATMAP_CONFIG.motion.delay, );}function createWaveEntry({ column, row, weeks,}: ActivityHeatmapMotionOptions) { const delay = getWaveDelay(column, row, weeks); return new Keyframe({ 0: { opacity: 0, transform: [ { scale: ACTIVITY_HEATMAP_CONFIG.motion.initialScale, }, ], }, 100: { easing: Easing.out(Easing.cubic), opacity: 1, transform: [{ scale: 1 }], }, }) .delay(delay) .duration( ACTIVITY_HEATMAP_CONFIG.motion.entryDuration, ) .reduceMotion(ReduceMotion.System);}function createCascadeEntry({ column, weeks,}: ActivityHeatmapMotionOptions) { const delay = getBoundedDelay(column, weeks - 1); return new Keyframe({ 0: { opacity: 0, transform: [ { scale: ACTIVITY_HEATMAP_CONFIG.motion.initialScale, }, ], }, 100: { easing: Easing.out(Easing.cubic), opacity: 1, transform: [{ scale: 1 }], }, }) .delay(delay) .duration( ACTIVITY_HEATMAP_CONFIG.motion.entryDuration, ) .reduceMotion(ReduceMotion.System);}function createFadeEntry() { return new Keyframe({ 0: { opacity: 0, }, 100: { easing: Easing.inOut(Easing.cubic), opacity: 1, }, }) .duration(ACTIVITY_HEATMAP_CONFIG.motion.fadeDuration) .reduceMotion(ReduceMotion.System);}const ENTRY_FACTORIES: Record< ActivityHeatmapEntryEffect, EntryFactory> = { wave: createWaveEntry, cascade: createCascadeEntry, fade: createFadeEntry, none: () => undefined,};export function getActivityHeatmapEntering( effect: ActivityHeatmapEntryEffect, options: ActivityHeatmapMotionOptions,) { return ENTRY_FACTORIES[effect](options);}activity-heatmap/types.tsexport const ACTIVITY_HEATMAP_ENTRY_EFFECTS = [ "wave", "cascade", "fade", "none",] as const;export type ActivityHeatmapEntryEffect = (typeof ACTIVITY_HEATMAP_ENTRY_EFFECTS)[number];export type ActivityHeatmapEntry = { /** Local calendar date represented by this value. */ date: Date; /** Daily amount; values at or below zero are inactive. */ value: number;};export type ActivityHeatmapLevel = 0 | 1 | 2 | 3 | 4;export type ActivityHeatmapBaseProps = { /** Accessible summary for the heatmap. */ accessibilityLabel?: string; /** Color used by active cells. */ activeColor?: string; /** Width and height of every square cell. */ cellSize?: number; /** Daily entries; the last value wins for duplicate dates. */ entries: readonly ActivityHeatmapEntry[]; /** Final local calendar day included in the heatmap. */ endDate?: Date; /** Entry treatment applied to active cells. */ entryEffect?: ActivityHeatmapEntryEffect; /** Color used by the persistent neutral grid. */ emptyColor?: string; /** Space between cells and week columns. */ gap?: number; /** Locale used for weekday labels. */ locale?: string; /** Shows compact month labels above the grid. */ showMonthLabels?: boolean; /** First local calendar day included in the heatmap. Overrides `weeks`. */ startDate?: Date; /** Shows compact weekday labels beside the grid. */ showWeekdayLabels?: boolean; /** First weekday in each column, from Sunday (0) to Saturday (6). */ startDay?: 0 | 1 | 2 | 3 | 4 | 5 | 6; /** Number of week columns to display. */ weeks?: number;};activity-heatmap/uniwind/index.tsximport { Text, View } from "react-native";import Animated from "react-native-reanimated";import type { SlotsToClasses } from "#shared/types/slots";import { ACTIVITY_HEATMAP_CONFIG } from "../config";import { getActivityHeatmapAccessibilityLabel, getActivityHeatmapCellOpacity, type ActivityHeatmapCell,} from "../model";import { getActivityHeatmapEntering,} from "../motion";import type { ActivityHeatmapBaseProps, ActivityHeatmapEntryEffect,} from "../types";import { useActivityHeatmap } from "../use-activity-heatmap";import { type ActivityHeatmapSlot, activityHeatmapVariants,} from "./variants";export type ActivityHeatmapProps = ActivityHeatmapBaseProps & { className?: string; classNames?: SlotsToClasses<ActivityHeatmapSlot>; };function ActivityHeatmapCellView({ activeColor, cell, classNames, emptyColor, entryEffect, layout, weeks,}: { activeColor: string; cell: ActivityHeatmapCell; classNames?: SlotsToClasses<ActivityHeatmapSlot>; emptyColor: string; entryEffect: ActivityHeatmapEntryEffect; layout: { borderRadius: number; cellSize: number; }; weeks: number;}) { const { activeFill, activeLayer, cell: cellSlot, } = activityHeatmapVariants(); return ( <View className={cellSlot({ className: classNames?.cell })} style={{ backgroundColor: cell.inRange ? emptyColor : "transparent", borderCurve: "continuous", borderRadius: layout.borderRadius, height: layout.cellSize, width: layout.cellSize, }} > {cell.active && ( <Animated.View className={activeLayer({ className: classNames?.activeLayer, })} entering={getActivityHeatmapEntering(entryEffect, { column: cell.column, row: cell.row, weeks, })} > <View className={activeFill({ className: classNames?.activeFill, })} style={{ backgroundColor: activeColor, borderCurve: "continuous", borderRadius: layout.borderRadius, opacity: getActivityHeatmapCellOpacity(cell.level), }} /> </Animated.View> )} </View> );}/** A non-interactive daily activity heatmap with bounded entry motion. */export function ActivityHeatmap({ accessibilityLabel, activeColor, cellSize, className, classNames, entries, emptyColor, endDate, entryEffect, gap, locale, showMonthLabels, showWeekdayLabels, startDate, startDay, weeks,}: ActivityHeatmapProps) { const heatmap = useActivityHeatmap({ activeColor, cellSize, entries, emptyColor, endDate, entryEffect, gap, locale, showMonthLabels, showWeekdayLabels, startDate, startDay, weeks, }); const { body, container, grid, label: labelSlot, labels, monthLabel, months, week: weekSlot, } = activityHeatmapVariants(); return ( <View accessibilityLabel={ accessibilityLabel ?? getActivityHeatmapAccessibilityLabel( heatmap.model.activeDays, heatmap.weeks, ) } accessibilityRole="image" accessible className={container({ className: [className, classNames?.container], })} style={{ gap: ACTIVITY_HEATMAP_CONFIG.layout.monthGap, }} > {heatmap.showMonthLabels && ( <View accessibilityElementsHidden className={months({ className: classNames?.months, })} importantForAccessibility="no-hide-descendants" style={{ gap: heatmap.layout.gap, marginLeft: heatmap.showWeekdayLabels ? ACTIVITY_HEATMAP_CONFIG.layout.labelWidth + ACTIVITY_HEATMAP_CONFIG.layout.labelGap : 0, }} > {heatmap.model.monthLabels.map(({ key, label }) => ( <View key={key} style={{ height: ACTIVITY_HEATMAP_CONFIG.layout.monthHeight, width: heatmap.layout.cellSize, }} > {label && ( <Text accessible={false} className={monthLabel({ className: classNames?.monthLabel, })} numberOfLines={1} style={{ width: heatmap.layout.cellSize * 4 + heatmap.layout.gap * 3, }} > {label} </Text> )} </View> ))} </View> )} <View className={body({ className: classNames?.body })} style={{ gap: ACTIVITY_HEATMAP_CONFIG.layout.labelGap, }} > {heatmap.showWeekdayLabels && ( <View accessibilityElementsHidden className={labels({ className: classNames?.labels, })} importantForAccessibility="no-hide-descendants" style={{ gap: heatmap.layout.gap, width: ACTIVITY_HEATMAP_CONFIG.layout.labelWidth, }} > {heatmap.model.weekdayLabels.map(({ key, label }) => ( <Text accessible={false} className={labelSlot({ className: classNames?.label, })} key={key} numberOfLines={1} style={{ height: heatmap.layout.cellSize, lineHeight: heatmap.layout.cellSize, }} > {label ?? ""} </Text> ))} </View> )} <View accessibilityElementsHidden className={grid({ className: classNames?.grid })} importantForAccessibility="no-hide-descendants" pointerEvents="none" style={{ gap: heatmap.layout.gap }} > {heatmap.model.weeks.map((week) => ( <View className={weekSlot({ className: classNames?.week, })} key={week.key} style={{ gap: heatmap.layout.gap }} > {week.cells.map((cell) => ( <ActivityHeatmapCellView activeColor={heatmap.activeColor} cell={cell} classNames={classNames} emptyColor={heatmap.emptyColor} entryEffect={heatmap.entryEffect} key={cell.key} layout={heatmap.layout} weeks={heatmap.weeks} /> ))} </View> ))} </View> </View> </View> );}activity-heatmap/uniwind/variants.tsimport { tv } from "tailwind-variants";export const activityHeatmapSlots = { activeFill: "absolute inset-0", activeLayer: "absolute inset-0", body: "flex-row items-start", cell: "relative", container: "flex-col", grid: "flex-row", label: "text-right text-[8px] font-bold text-[#858b85]", labels: "items-end", monthLabel: "absolute left-0 top-0 text-[8px] font-bold text-[#858b85]", months: "flex-row", week: "flex-col",} as const;export type ActivityHeatmapSlot = keyof typeof activityHeatmapSlots;export const activityHeatmapVariants = tv({ slots: activityHeatmapSlots,});activity-heatmap/use-activity-heatmap.tsimport { useEffect, useMemo, useState } from "react";import { ACTIVITY_HEATMAP_COLORS, ACTIVITY_HEATMAP_CONFIG,} from "./config";import { buildActivityHeatmapModel, resolveActivityHeatmapLayout,} from "./model";import type { ActivityHeatmapBaseProps } from "./types";type UseActivityHeatmapOptions = Pick< ActivityHeatmapBaseProps, | "activeColor" | "cellSize" | "entries" | "emptyColor" | "endDate" | "entryEffect" | "gap" | "locale" | "showMonthLabels" | "showWeekdayLabels" | "startDate" | "startDay" | "weeks">;function getLocalToday() { const now = new Date(); return new Date( now.getFullYear(), now.getMonth(), now.getDate(), );}export function useActivityHeatmap({ activeColor = ACTIVITY_HEATMAP_COLORS.active, cellSize = ACTIVITY_HEATMAP_CONFIG.cellSize, entries, emptyColor = ACTIVITY_HEATMAP_COLORS.empty, endDate, entryEffect = ACTIVITY_HEATMAP_CONFIG.entryEffect, gap = ACTIVITY_HEATMAP_CONFIG.gap, locale = ACTIVITY_HEATMAP_CONFIG.locale, showMonthLabels = true, showWeekdayLabels = true, startDate, startDay = ACTIVITY_HEATMAP_CONFIG.startDay, weeks = ACTIVITY_HEATMAP_CONFIG.weeks,}: UseActivityHeatmapOptions) { const [defaultEndDate, setDefaultEndDate] = useState( getLocalToday, ); useEffect(() => { if (endDate !== undefined) return; let timeoutId: ReturnType<typeof setTimeout>; const scheduleRollover = () => { setDefaultEndDate((currentDate) => { const today = getLocalToday(); return currentDate.getTime() === today.getTime() ? currentDate : today; }); const now = new Date(); const nextLocalDay = new Date( now.getFullYear(), now.getMonth(), now.getDate() + 1, ); timeoutId = setTimeout( scheduleRollover, Math.max(1, nextLocalDay.getTime() - now.getTime()), ); }; scheduleRollover(); return () => clearTimeout(timeoutId); }, [endDate]); const resolvedEndDate = endDate ?? defaultEndDate; const model = useMemo( () => buildActivityHeatmapModel({ entries, endDate: resolvedEndDate, locale, startDate, startDay, weeks, }), [ entries, locale, resolvedEndDate, startDate, startDay, weeks, ], ); const layout = useMemo( () => resolveActivityHeatmapLayout(cellSize, gap), [cellSize, gap], ); return { activeColor, emptyColor, entryEffect, layout, model, showMonthLabels, showWeekdayLabels, weeks: model.weeks.length, };}ActivityHeatmap
A non-interactive daily activity heatmap with bounded entry motion.
