Veltrix

data visualization

Calendar Heatmarks

An activity calendar that layers heat intensity, date selection, and ranges without losing context.

AppleiOSAndroidAndroidExpoExpo Go
datepickerreanimated
GitHubOpen in GitHub

Installation

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

pnpm add @rehookify/datepicker react-native-reanimated

Anatomy

1 file

anatomy.tsx
<CalendarHeatmarks>  <CalendarHeatmarks.Day /></CalendarHeatmarks>

Install tailwind variants

pnpm add tailwind-variants

Usage

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

Component files

6 files

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

API reference

Generated directly from the exported component props.

CalendarHeatmarks

CalendarHeatmarks.Day