All patterns

Habit Calendar Fill

Today's square fills in the month grid and the run it belongs to draws itself underneath.

achievementcalmfriendlyautomatic · finite · starter · ~0.9s
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

237 lines · react + motion only
import { useEffect, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Habit Calendar Fill
 *
 * Today's square filling in a month of squares, and the run it belongs
 * to drawing itself underneath. Two small events, in order: the day is
 * logged, and then the consequence of logging it is shown.
 *
 * The month grid is otherwise completely still. Everything already on
 * the calendar happened before now and has no business moving, which is
 * what leaves the single filling cell somewhere to be noticed.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Empty cells are mixed from the inherited text color; the habit colour
 * is semantic and stays literal.
 * Works with zero props; tune via `variant`, `filled`, `todayIndex`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type HabitCalendarFillProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Flat indices of days already logged, in a 7-column grid. */
  filled?: number[];
  /** Flat index of today — the cell that fills. */
  todayIndex?: number;
  /** Flat index where the current run begins, on today's row. */
  runStart?: number;
  /** Column headings, left to right. */
  dayLetters?: string[];
  /** Habit colour. Semantic, so it stays literal. */
  accent?: string;
  /** Fires once the run underline has finished extending. */
  onLogged?: () => void;
};

type VariantConfig = {
  /** Beat before the cell fills. */
  delay: number;
  spring: { type: "spring"; stiffness: number; damping: number };
  /** How long the run underline takes to reach today. */
  extend: number;
};

// Calm by construction: damped at 0.87 and above, with the underline on
// a plain ease. A habit grid is looked at every day for months, so
// nothing here is allowed to be the least bit pleased with itself.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  subtle: {
    delay: 0.16,
    spring: { type: "spring", stiffness: 520, damping: 42 },
    extend: 0.3,
  },
  default: {
    delay: 0.28,
    spring: { type: "spring", stiffness: 400, damping: 35 },
    extend: 0.44,
  },
  playful: {
    delay: 0.4,
    spring: { type: "spring", stiffness: 320, damping: 30 },
    extend: 0.6,
  },
};

const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const CELL = 22;
const GAP = 6;
const COLS = 7;
const ROWS = 5;

const DEFAULT_FILLED = [
  1, 2, 3, 5, 6, 8, 9, 10, 12, 13, 15, 16, 17, 19, 20, 21, 22, 23,
];

export default function HabitCalendarFill({
  variant = "default",
  filled = DEFAULT_FILLED,
  todayIndex = 24,
  runStart = 21,
  dayLetters = ["M", "T", "W", "T", "F", "S", "S"],
  accent = "#3E7FB5",
  onLogged,
}: HabitCalendarFillProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const still = !!reduceMotion;

  // Reduced motion: today is simply already logged. Both that and the
  // reset a new run needs are render-time facts, so the run key lives in
  // state and is compared during render; the effect only owns the timer.
  const runKey = `${still}:${cfg.delay}`;
  const [run, setRun] = useState({ key: runKey, logged: still });
  if (run.key !== runKey) setRun({ key: runKey, logged: still });
  const logged = run.key === runKey ? run.logged : still;

  useEffect(() => {
    if (still) return;
    const timer = setTimeout(
      () => setRun({ key: runKey, logged: true }),
      cfg.delay * 1000
    );
    return () => clearTimeout(timer);
  }, [still, cfg.delay, runKey]);

  const done = new Set(filled);
  const todayRow = Math.floor(todayIndex / COLS);
  const todayCol = todayIndex % COLS;
  const startCol = Math.min(runStart % COLS, todayCol);
  const span = todayCol - startCol + 1;

  const runLeft = startCol * (CELL + GAP);
  const runWidth = span * CELL + (span - 1) * GAP;
  const runDays = span + filled.filter((day) => day < runStart).length * 0;

  return (
    <div style={{ width: COLS * CELL + (COLS - 1) * GAP }}>
      <div style={{ display: "flex", gap: GAP, marginBottom: 9 }}>
        {dayLetters.map((letter, index) => (
          <span
            key={index}
            style={{
              width: CELL,
              textAlign: "center",
              fontSize: 9.5,
              fontWeight: 600,
              color: tone(38),
            }}
          >
            {letter}
          </span>
        ))}
      </div>

      <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
        {Array.from({ length: ROWS }, (_, row) => (
          <div
            key={row}
            style={{
              position: "relative",
              display: "flex",
              gap: GAP,
              paddingBottom: 6,
            }}
          >
            {Array.from({ length: COLS }, (_, col) => {
              const index = row * COLS + col;
              const isToday = index === todayIndex;
              const on = done.has(index) || (isToday && logged);
              const future = index > todayIndex;
              return (
                <span
                  key={col}
                  style={{
                    position: "relative",
                    width: CELL,
                    height: CELL,
                    borderRadius: 7,
                    background: future ? tone(4) : tone(8),
                    boxShadow: isToday ? `inset 0 0 0 1.5px ${tone(22)}` : "none",
                  }}
                >
                  <motion.span
                    style={{
                      position: "absolute",
                      inset: 0,
                      borderRadius: 7,
                      background: accent,
                    }}
                    initial={{
                      scale: done.has(index) ? 1 : 0.5,
                      opacity: done.has(index) ? 1 : 0,
                    }}
                    animate={{ scale: on ? 1 : 0.5, opacity: on ? 1 : 0 }}
                    transition={
                      isToday && !still
                        ? cfg.spring
                        : { duration: 0, ease: "linear" }
                    }
                  />
                </span>
              );
            })}

            {row === todayRow && (
              <motion.span
                aria-hidden
                style={{
                  position: "absolute",
                  bottom: 0,
                  left: runLeft,
                  width: runWidth,
                  height: 2,
                  borderRadius: 2,
                  background: accent,
                  transformOrigin: "left center",
                }}
                initial={{ scaleX: still ? 1 : 0, opacity: still ? 0.85 : 0 }}
                animate={{ scaleX: logged ? 1 : 0, opacity: logged ? 0.85 : 0 }}
                transition={{
                  duration: still ? 0 : cfg.extend,
                  delay: still ? 0 : 0.12,
                  ease: [0.4, 0, 0.2, 1],
                }}
                onAnimationComplete={logged ? onLogged : undefined}
              />
            )}
          </div>
        ))}
      </div>

      <motion.div
        initial={{ opacity: 0 }}
        animate={{ opacity: logged ? 1 : 0 }}
        transition={{
          duration: still ? 0.2 : 0.3,
          delay: still ? 0 : 0.12 + cfg.extend * 0.6,
          ease: "easeOut",
        }}
        style={{
          marginTop: 6,
          display: "flex",
          alignItems: "center",
          gap: 7,
          fontSize: 11.5,
        }}
      >
        <span style={{ fontWeight: 620, color: accent }}>{`${runDays} day run`}</span>
        <span style={{ color: tone(44) }}>{`${filled.length + 1} this month`}</span>
      </motion.div>
    </div>
  );
}

About this pattern

Two small events in the right order: the day is logged, then the consequence of logging it is shown. Today's cell fills from just over half size on an over-damped spring, and a hairline underline extends beneath the current run to today's column. Everything else in the month is deliberately inert — those days happened before now and have no business moving — and that stillness is what leaves the one filling square somewhere to be noticed. The underline geometry is computed from the grid's own cell and gap sizes rather than positioned by hand, so it stays correct at any cell size. The whole thing is tuned calm: this is a surface someone looks at every day for months.

Habit trackerDaily check-in gridContribution calendarMeditation streak

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • 10:15
    Today
    412 kcalMove8,240Steps9 hrsStand
    HomeSearchActivityProfile
    Activity summary

    Month grid marking today and showing the run it continues.

Related patterns