Veltrix

data visualization

Activity Heatmap

A calendar-aligned activity heatmap with intensity and bounded entry motion.

AppleiOSAndroidAndroidExpoExpo Go
reanimated
GitHubOpen in GitHub

Installation

Install the dependencies, then copy the files for your preferred renderer.

pnpm add react-native-reanimated

Data 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 endDate is omitted, today rolls over automatically at local midnight while the component is mounted.
  • startDate and endDate can define an explicit local calendar range.
  • weeks must be a positive integer, and startDate cannot be after endDate.
  • entryEffect controls how active cells enter the heatmap.

Install tailwind variants

pnpm add tailwind-variants

Usage

TrainingHistory.tsx
import { 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)}    />  );}

Component files

7 files

activity-heatmap/config.ts
export 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.ts
import 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.ts
import {  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.ts
export 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.tsx
import { 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.ts
import { 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.ts
import { 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,  };}

API reference

Generated directly from the exported component props.

ActivityHeatmap

A non-interactive daily activity heatmap with bounded entry motion.