data visualization
Calendar Heatmarks
An activity calendar that layers heat intensity, date selection, and ranges without losing context.
iOSAndroidExpo Go
datepickerreanimated
Installation
Install the dependencies, then copy the files for your preferred renderer.
pnpm add @rehookify/datepicker react-native-reanimatedanatomy.tsx<CalendarHeatmarks> <CalendarHeatmarks.Day /></CalendarHeatmarks>Install tailwind variants
pnpm add tailwind-variantsUsage
ActivityCalendar.tsximport { useState } from "react";import { CalendarHeatmarks } from "@animations/ui/components/data-visualization/calendar-heatmarks/uniwind";const activities = [ { date: new Date(2026, 6, 4), intensity: 0.35 }, { date: new Date(2026, 6, 8), intensity: 0.9 },];export function ActivityCalendar() { const [selectedDates, setSelectedDates] = useState<Date[]>([]); return ( <CalendarHeatmarks activities={activities} className="rounded-3xl bg-white p-4" classNames={{ month: "text-violet-950", navigationButton: "border-violet-200", }} mode="range" onDatesChange={setSelectedDates} selectedDates={selectedDates} /> );}calendar-heatmarks/config.tsexport const CALENDAR_HEATMARKS_CONFIG = { accentColor: "#6d5dfc", heatColor: "#d9ff62", rangeColor: "#ddd8ff", transitionDuration: 240,} as const;calendar-heatmarks/swap-on-click.ts/** Rehookify internally returns `onClick`, which is incompatible with Native. */export function swapOnClick<D>(d: D) { // @ts-expect-error - onClick is not defined in the generic type d.onPress = d.onClick; return d;}calendar-heatmarks/types.tsimport type { ReactNode } from "react";export type CalendarHeatmark = { date: Date; intensity: number;};export type CalendarHeatmarksMode = "range" | "single";export type CalendarHeatmarksDayState = { date: Date; disabled: boolean; inCurrentMonth: boolean; intensity: number; isRangeEnd: boolean; isRangeMiddle: boolean; isRangeStart: boolean; now: boolean; selected: boolean;};export type CalendarHeatmarksDayProps = { children: (state: CalendarHeatmarksDayState) => ReactNode;};export type CalendarHeatmarksBaseProps = { accentColor?: string; activities?: readonly CalendarHeatmark[]; children?: ReactNode; disabled?: boolean; heatColor?: string; locale?: string; maxDate?: Date; minDate?: Date; mode?: CalendarHeatmarksMode; offsetDate?: Date; onDatesChange: (dates: Date[]) => void; onMonthChange?: (date: Date) => void; rangeColor?: string; selectedDates: Date[]; showOutsideDays?: boolean; startDay?: 0 | 1 | 2 | 3 | 4 | 5 | 6;};calendar-heatmarks/uniwind/index.tsximport { Feather } from "@expo/vector-icons";import { Children, createContext, isValidElement, useContext, type ReactElement, type ReactNode,} from "react";import type { DPDay, DPPropGetter,} from "@rehookify/datepicker";import { Pressable, Text, View } from "react-native";import Animated, { FadeIn, FadeInLeft, FadeInRight, FadeOut, FadeOutLeft, FadeOutRight, ReduceMotion, ZoomIn, ZoomOut, useAnimatedStyle, useDerivedValue, withTiming,} from "react-native-reanimated";import { useResolveClassNames } from "uniwind";import type { SlotsToClasses } from "#shared/types/slots";import { CALENDAR_HEATMARKS_CONFIG } from "../config";import { swapOnClick } from "../swap-on-click";import type { CalendarHeatmarksBaseProps, CalendarHeatmarksDayProps, CalendarHeatmarksDayState,} from "../types";import { useCalendarHeatmarks } from "../use-calendar-heatmarks";import { type CalendarHeatmarksSlot, calendarHeatmarksVariants,} from "./variants";type CalendarDayProps = { accentColor: string; classNames?: SlotsToClasses<CalendarHeatmarksSlot>; day: DPDay; dayProps: ReturnType< ReturnType<typeof useCalendarHeatmarks>["dayButton"] >; heatColor: string; rangeColor: string; daySlot?: ReactElement<CalendarHeatmarksDayProps>; showOutsideDays: boolean; state: CalendarHeatmarksDayState;};function CalendarDay({ accentColor, classNames, day, daySlot, dayProps, heatColor, rangeColor, showOutsideDays, state,}: CalendarDayProps) { const { day: dayClass, dayContent, dayLabel, heat, now, range, selection, } = calendarHeatmarksVariants(); const activityIntensity = state.intensity; const intensity = useDerivedValue(() => withTiming(activityIntensity, { duration: CALENDAR_HEATMARKS_CONFIG.transitionDuration, reduceMotion: ReduceMotion.System, }), ); const heatStyle = useAnimatedStyle(() => ({ opacity: 0.35 + intensity.get() * 0.65, transform: [ { scaleX: 0.25 + intensity.get() * 0.75, }, ], })); const inRange = state.isRangeMiddle || state.isRangeStart || state.isRangeEnd; const hidden = !state.inCurrentMonth && !showOutsideDays; const nativeDayProps = swapOnClick(dayProps); return ( <View className={dayClass({ className: classNames?.day, })} > {inRange && ( <Animated.View className={range({ className: [ state.isRangeStart && "left-1/2", state.isRangeEnd && "right-1/2", classNames?.range, ], })} entering={FadeIn.duration(180).reduceMotion( ReduceMotion.System, )} exiting={FadeOut.duration(120).reduceMotion( ReduceMotion.System, )} style={{ backgroundColor: rangeColor }} /> )} <Pressable accessibilityLabel={day.$date.toLocaleDateString()} accessibilityRole="button" accessibilityState={{ disabled: state.disabled, selected: state.selected, }} className={dayContent({ className: [ state.disabled && "opacity-30", classNames?.dayContent, ], })} disabled={state.disabled} // @ts-expect-error - swapOnClick adds the Native handler onPress={nativeDayProps.onPress} > {state.selected && ( <Animated.View className={selection({ className: classNames?.selection, })} entering={ZoomIn.duration(180).reduceMotion( ReduceMotion.System, )} exiting={ZoomOut.duration(120).reduceMotion( ReduceMotion.System, )} style={{ backgroundColor: accentColor }} /> )} {!state.selected && activityIntensity > 0 && ( <Animated.View className={heat({ className: classNames?.heat, })} style={[ { backgroundColor: heatColor, }, heatStyle, ]} /> )} {!hidden && (daySlot ? ( <CalendarHeatmarksDayContext.Provider value={state}> {daySlot} </CalendarHeatmarksDayContext.Provider> ) : ( <Text className={dayLabel({ className: [ !state.inCurrentMonth && "text-[#aaa9a1]", state.selected && "text-white", classNames?.dayLabel, ], })} > {day.day} </Text> ))} {state.now && ( <View className={now({ className: classNames?.now, })} style={{ backgroundColor: state.selected ? "#ffffff" : accentColor, }} /> )} </Pressable> </View> );}type PickerOptionProps = { accessibilityLabel?: string; active: boolean; classNames?: SlotsToClasses<CalendarHeatmarksSlot>; disabled: boolean; label: string; onSelect: () => void; optionProps: DPPropGetter;};function PickerOption({ accessibilityLabel, active, classNames, disabled, label, onSelect, optionProps,}: PickerOptionProps) { const { option, optionLabel } = calendarHeatmarksVariants(); const nativeOptionProps = swapOnClick(optionProps); function handlePress() { // @ts-expect-error - swapOnClick adds the Native handler nativeOptionProps.onPress?.(); onSelect(); } return ( <Pressable accessibilityLabel={accessibilityLabel} accessibilityRole="button" accessibilityState={{ disabled, selected: active }} className={option({ className: [ active && "bg-[#171713]", disabled && "opacity-30", classNames?.option, ], })} disabled={disabled} onPress={handlePress} > <Text className={optionLabel({ className: [ active && "text-white", classNames?.optionLabel, ], })} > {label} </Text> </Pressable> );}export type CalendarHeatmarksProps = CalendarHeatmarksBaseProps & { className?: string; classNames?: SlotsToClasses<CalendarHeatmarksSlot>; };const CalendarHeatmarksDayContext = createContext<CalendarHeatmarksDayState | null>(null);function CalendarHeatmarksDay({ children,}: CalendarHeatmarksDayProps) { const state = useContext(CalendarHeatmarksDayContext); if (!state) { throw new Error( "CalendarHeatmarks.Day must be used inside CalendarHeatmarks.", ); } return children(state);}function CalendarHeatmarksRoot({ accentColor = CALENDAR_HEATMARKS_CONFIG.accentColor, activities, children, className, classNames, disabled = false, heatColor = CALENDAR_HEATMARKS_CONFIG.heatColor, locale, maxDate, minDate, mode, offsetDate, onDatesChange, onMonthChange, rangeColor = CALENDAR_HEATMARKS_CONFIG.rangeColor, selectedDates, showOutsideDays = true, startDay,}: CalendarHeatmarksProps) { const daySlot = Children.toArray(children).find( (child): child is ReactElement<CalendarHeatmarksDayProps> => isValidElement<CalendarHeatmarksDayProps>(child) && child.type === CalendarHeatmarksDay, ); const { container, days, header, month, navigationButton, options, pickerTitle, weekDay, weekDays, } = calendarHeatmarksVariants({ disabled }); const { lineHeight: _monthLineHeight, ...monthStyle } = useResolveClassNames( month({ className: classNames?.month, }), ); const { addMonthProps, addYearProps, calendar, dayButton, getDayState, monthButton, months, navigationDirection, nextYearsProps, previousYearsProps, setView, setNavigationDirection, subtractMonthProps, subtractYearProps, view, weekDays: weekDayLabels, yearButton, years, } = useCalendarHeatmarks({ activities, locale, maxDate, minDate, mode, offsetDate, onDatesChange, onMonthChange, selectedDates, startDay, }); let previousProps = subtractMonthProps; let nextProps = addMonthProps; if (view === "months") { previousProps = subtractYearProps; nextProps = addYearProps; } if (view === "years") { previousProps = previousYearsProps; nextProps = nextYearsProps; } const previousNativeProps = swapOnClick(previousProps); const nextNativeProps = swapOnClick(nextProps); let title = `${calendar.month} ${calendar.year}`; if (view === "months") { title = calendar.year; } if (view === "years") { title = `${years[1]?.year ?? years[0].year} — ${ years.at(-2)?.year ?? years.at(-1)?.year }`; } function handleTitlePress() { if (view === "days") { setView("months"); return; } if (view === "months") { setView("years"); return; } setView("days"); } function handlePreviousPress() { setNavigationDirection(-1); // @ts-expect-error - swapOnClick adds the Native handler previousNativeProps.onPress?.(); } function handleNextPress() { setNavigationDirection(1); // @ts-expect-error - swapOnClick adds the Native handler nextNativeProps.onPress?.(); } let contentEntering = FadeInRight.duration( CALENDAR_HEATMARKS_CONFIG.transitionDuration, ).reduceMotion(ReduceMotion.System); let contentExiting = FadeOutLeft.duration( CALENDAR_HEATMARKS_CONFIG.transitionDuration / 2, ).reduceMotion(ReduceMotion.System); if (navigationDirection < 0) { contentEntering = FadeInLeft.duration( CALENDAR_HEATMARKS_CONFIG.transitionDuration, ).reduceMotion(ReduceMotion.System); contentExiting = FadeOutRight.duration( CALENDAR_HEATMARKS_CONFIG.transitionDuration / 2, ).reduceMotion(ReduceMotion.System); } return ( <View accessibilityLabel="Activity calendar" className={container({ className: [className, classNames?.container], })} > <View className={header({ className: classNames?.header, })} > <Pressable accessibilityLabel="Previous month" accessibilityRole="button" accessibilityState={{ disabled: disabled || previousProps.disabled, }} className={navigationButton({ className: [ previousProps.disabled && "opacity-30", classNames?.navigationButton, ], })} disabled={disabled || previousProps.disabled} hitSlop={8} onPress={handlePreviousPress} > <Feather color="#171713" name="chevron-left" size={20} /> </Pressable> <Pressable accessibilityHint="Choose month or year" accessibilityRole="button" className={pickerTitle({ className: classNames?.pickerTitle, })} onPress={handleTitlePress} > <Text adjustsFontSizeToFit minimumFontScale={0.72} numberOfLines={1} style={monthStyle} > {title} </Text> </Pressable> <Pressable accessibilityLabel="Next month" accessibilityRole="button" accessibilityState={{ disabled: disabled || nextProps.disabled, }} className={navigationButton({ className: [ nextProps.disabled && "opacity-30", classNames?.navigationButton, ], })} disabled={disabled || nextProps.disabled} hitSlop={8} onPress={handleNextPress} > <Feather color="#171713" name="chevron-right" size={20} /> </Pressable> </View> {view === "days" && ( <View className="gap-3"> <View className={weekDays({ className: classNames?.weekDays, })} > {weekDayLabels.map((label, index) => ( <Text className={weekDay({ className: classNames?.weekDay, })} key={`${label}-${index}`} > {label} </Text> ))} </View> <Animated.View className={days({ className: classNames?.days, })} entering={contentEntering} exiting={contentExiting} key={`${calendar.month}-${calendar.year}`} > {calendar.days.map((day) => ( <CalendarDay accentColor={accentColor} classNames={classNames} day={day} daySlot={daySlot} dayProps={dayButton(day)} heatColor={heatColor} key={day.$date.toISOString()} rangeColor={rangeColor} showOutsideDays={showOutsideDays} state={getDayState(day)} /> ))} </Animated.View> </View> )} {view === "months" && ( <Animated.View className={options({ className: classNames?.options, })} entering={FadeIn.duration( CALENDAR_HEATMARKS_CONFIG.transitionDuration, ).reduceMotion(ReduceMotion.System)} exiting={FadeOut.duration( CALENDAR_HEATMARKS_CONFIG.transitionDuration / 2, ).reduceMotion(ReduceMotion.System)} key={`months-${calendar.year}`} > {months.map((monthOption) => ( <PickerOption active={monthOption.active} accessibilityLabel={monthOption.month} classNames={classNames} disabled={monthOption.disabled} key={monthOption.month} label={monthOption.month.slice(0, 3)} onSelect={() => setView("days")} optionProps={monthButton(monthOption)} /> ))} </Animated.View> )} {view === "years" && ( <Animated.View className={options({ className: classNames?.options, })} entering={FadeIn.duration( CALENDAR_HEATMARKS_CONFIG.transitionDuration, ).reduceMotion(ReduceMotion.System)} exiting={FadeOut.duration( CALENDAR_HEATMARKS_CONFIG.transitionDuration / 2, ).reduceMotion(ReduceMotion.System)} key={`years-${title}`} > {years.map((yearOption) => ( <PickerOption active={yearOption.active} classNames={classNames} disabled={yearOption.disabled} key={yearOption.year} label={String(yearOption.year)} onSelect={() => setView("months")} optionProps={yearButton(yearOption)} /> ))} </Animated.View> )} </View> );}export const CalendarHeatmarks = Object.assign( CalendarHeatmarksRoot, { Day: CalendarHeatmarksDay, },);calendar-heatmarks/uniwind/variants.tsimport { tv } from "tailwind-variants";export const calendarHeatmarksSlots = { calendar: "gap-3", container: "gap-4.5", day: "relative aspect-square w-[14.285%] items-center justify-center", dayContent: "size-9 items-center justify-center overflow-hidden rounded-full", dayLabel: "text-[13px] font-bold text-[#171713]", days: "flex-row flex-wrap", heat: "absolute bottom-0.75 h-1 w-6.5 rounded-full", header: "relative flex-row items-center justify-between gap-2", month: "max-w-full text-center text-xl font-black text-[#171713]", navigationButton: "z-10 size-10.5 items-center justify-center rounded-full border border-[#dfded7]", now: "absolute top-0.75 size-0.75", option: "h-13 w-[31%] items-center justify-center rounded-2xl", optionLabel: "text-sm font-extrabold text-[#171713]", options: "flex-row flex-wrap gap-2", pickerTitle: "absolute inset-y-0 left-12.5 right-12.5 items-center justify-center rounded-[14px] px-3", range: "absolute inset-x-0 h-8", selection: "absolute inset-0 rounded-full", weekDay: "w-[14.285%] text-center text-[10px] font-black tracking-[1px] text-[#929188]", weekDays: "flex-row",} as const;export type CalendarHeatmarksSlot = keyof typeof calendarHeatmarksSlots;export const calendarHeatmarksVariants = tv({ slots: calendarHeatmarksSlots, variants: { disabled: { true: { container: "opacity-50", }, }, },});calendar-heatmarks/use-calendar-heatmarks.tsimport { useMemo, useState } from "react";import { type DPDay, useDatePicker,} from "@rehookify/datepicker";import type { CalendarHeatmarksBaseProps, CalendarHeatmarksDayState,} from "./types";type UseCalendarHeatmarksOptions = Pick< CalendarHeatmarksBaseProps, | "activities" | "locale" | "maxDate" | "minDate" | "mode" | "offsetDate" | "onDatesChange" | "onMonthChange" | "selectedDates" | "startDay">;function getDateKey(date: Date) { return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;}export function useCalendarHeatmarks({ activities = [], locale = "en-US", maxDate, minDate, mode = "range", offsetDate, onDatesChange, onMonthChange, selectedDates, startDay = 1,}: UseCalendarHeatmarksOptions) { const [view, setView] = useState< "days" | "months" | "years" >("days"); const [navigationDirection, setNavigationDirection] = useState<-1 | 1>(1); const [internalOffsetDate, setInternalOffsetDate] = useState( offsetDate ?? selectedDates[0] ?? new Date(), ); const resolvedOffsetDate = offsetDate ?? internalOffsetDate; const activityByDate = useMemo( () => new Map( activities.map((activity) => [ getDateKey(activity.date), Math.min(1, Math.max(0, activity.intensity)), ]), ), [activities], ); function handleOffsetChange(date: Date) { if (offsetDate === undefined) { setInternalOffsetDate(date); } onMonthChange?.(date); } const { data: { calendars, months, weekDays, years }, propGetters: { addOffset, dayButton, monthButton, nextYearsButton, previousYearsButton, subtractOffset, yearButton, }, } = useDatePicker({ calendar: { mode: "static", startDay, }, dates: { maxDate, minDate, mode, selectSameDate: true, }, locale: { locale, weekday: "narrow", }, offsetDate: resolvedOffsetDate, onDatesChange, onOffsetChange: handleOffsetChange, selectedDates, }); function getDayState(day: DPDay): CalendarHeatmarksDayState { return { date: day.$date, disabled: day.disabled, inCurrentMonth: day.inCurrentMonth, intensity: activityByDate.get(getDateKey(day.$date)) ?? 0, isRangeEnd: day.range.includes("range-end"), isRangeMiddle: day.range === "in-range", isRangeStart: day.range.includes("range-start"), now: day.now, selected: day.selected, }; } return { addMonthProps: addOffset({ months: 1 }), addYearProps: addOffset({ years: 1 }), calendar: calendars[0], dayButton, getDayState, monthButton, months, navigationDirection, nextYearsProps: nextYearsButton(), previousYearsProps: previousYearsButton(), setView, setNavigationDirection, subtractMonthProps: subtractOffset({ months: 1 }), subtractYearProps: subtractOffset({ years: 1 }), view, weekDays, yearButton, years, };}