Overview

A usable month surface — prev/next slides direction-aware like Step Wizard. Selected day gets a spring ring; today has a subtle mark. Reduced motion skips the slide.


Installation

Use the CLI to install the component automatically:

$ pnpm dlx shadcn@latest add https://ui.sanjid.in/r/calendar-month.json

Install dependencies:

$ pnpm add motion lucide-react

Add the utility function for class merging:

lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs));}

Copy the component code into your project:

components/ui/calendar-month.tsx
tsx
"use client";
import React, { useCallback, useId, useMemo, useState } from "react";import { motion, useReducedMotion } from "motion/react";import { ChevronLeft, ChevronRight } from "lucide-react";import { cn } from "@/lib/utils";
export type CalendarMonthProps = {  value?: Date;  defaultValue?: Date;  onChange?: (date: Date) => void;  min?: Date;  max?: Date;  className?: string;};
const SPRING = { type: "spring" as const, bounce: 0, duration: 0.28 };const WEEKDAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
function startOfDay(d: Date) {  return new Date(d.getFullYear(), d.getMonth(), d.getDate());}
function sameDay(a: Date, b: Date) {  return (    a.getFullYear() === b.getFullYear() &&    a.getMonth() === b.getMonth() &&    a.getDate() === b.getDate()  );}
function daysInMonth(year: number, month: number) {  return new Date(year, month + 1, 0).getDate();}
function monthLabel(year: number, month: number) {  return new Date(year, month, 1).toLocaleString("en-US", {    month: "long",    year: "numeric",  });}
function buildCells(year: number, month: number): (Date | null)[] {  const firstDow = new Date(year, month, 1).getDay();  const count = daysInMonth(year, month);  const out: (Date | null)[] = [];  for (let i = 0; i < firstDow; i++) out.push(null);  for (let d = 1; d <= count; d++) {    out.push(new Date(year, month, d));  }  while (out.length % 7 !== 0) out.push(null);  return out;}
type View = { year: number; month: number };
function MonthGrid({  view,  selected,  today,  minD,  maxD,  selectedLayoutId,  reduce,  interactive,  onSelect,}: {  view: View;  selected?: Date;  today: Date;  minD: Date | null;  maxD: Date | null;  selectedLayoutId: string;  reduce: boolean | null;  interactive: boolean;  onSelect: (d: Date) => void;}) {  const cells = useMemo(    () => buildCells(view.year, view.month),    [view.year, view.month],  );
  const isDisabled = (d: Date) => {    if (minD && d < minD) return true;    if (maxD && d > maxD) return true;    return false;  };
  return (    <div className="grid grid-cols-7 gap-0.5">      {cells.map((d, i) => {        if (!d) {          return (            <div              key={`e-${view.year}-${view.month}-${i}`}              className="size-10"            />          );        }        const selectedDay = selected && sameDay(d, selected);        const isToday = sameDay(d, today);        const disabled = isDisabled(d);        return (          <motion.button            key={d.toISOString()}            type="button"            disabled={!interactive || disabled}            tabIndex={interactive ? 0 : -1}            aria-label={d.toDateString()}            aria-pressed={!!selectedDay}            whileTap={              !interactive || disabled || reduce                ? undefined                : { scale: 0.96 }            }            onClick={() => interactive && onSelect(d)}            className={cn(              "relative flex size-10 items-center justify-center rounded-full text-sm outline-none",              "focus-visible:ring-2 focus-visible:ring-[var(--color-focus)]",              disabled && "cursor-not-allowed opacity-30",              !disabled && interactive && "hover:bg-[var(--color-paper-2)]",              selectedDay                ? "font-semibold text-[var(--color-paper)]"                : "text-[var(--color-ink)]",            )}          >            {selectedDay && interactive ? (              <motion.span                layoutId={selectedLayoutId}                transition={reduce ? { duration: 0.1 } : SPRING}                className="absolute inset-0 rounded-full bg-[var(--color-ink)]"              />            ) : selectedDay ? (              <span className="absolute inset-0 rounded-full bg-[var(--color-ink)]" />            ) : null}            <span className="relative z-[1] tabular-nums">{d.getDate()}</span>            {isToday && !selectedDay ? (              <span className="absolute bottom-1 left-1/2 size-1 -translate-x-1/2 rounded-full bg-[var(--color-ink)]" />            ) : null}          </motion.button>        );      })}    </div>  );}
export function CalendarMonth({  value: valueProp,  defaultValue,  onChange,  min,  max,  className,}: CalendarMonthProps) {  const reduce = useReducedMotion();  const selectedLayoutId = `cal-selected-${useId()}`;  const isControlled = valueProp !== undefined;  const [uncontrolled, setUncontrolled] = useState(    defaultValue ? startOfDay(defaultValue) : undefined,  );  const selected = isControlled    ? valueProp      ? startOfDay(valueProp)      : undefined    : uncontrolled;
  const initial = selected ?? startOfDay(new Date());  const [view, setView] = useState<View>({    year: initial.getFullYear(),    month: initial.getMonth(),  });  // Page side-by-side (08): page slots + data-page orchestration  const [page, setPage] = useState<"1" | "2">("1");  const [slot1, setSlot1] = useState<View>(view);  const [slot2, setSlot2] = useState<View>(view);  const [exitEnabled, setExitEnabled] = useState(false);
  const setSelected = useCallback(    (d: Date) => {      const next = startOfDay(d);      if (!isControlled) setUncontrolled(next);      onChange?.(next);    },    [isControlled, onChange],  );
  const shiftMonth = (dir: -1 | 1) => {    const nextView: View = (() => {      const d = new Date(view.year, view.month + dir, 1);      return { year: d.getFullYear(), month: d.getMonth() };    })();
    const forward = dir === 1;    const target: "1" | "2" = forward ? "2" : "1";
    const apply = () => {      if (forward) {        setSlot1(view);        setSlot2(nextView);      } else {        setSlot1(nextView);        setSlot2(view);      }      setExitEnabled(!reduce);      setPage(target);      setView(nextView);    };
    // Consecutive same-direction: snap to opposite page first so data-page can flip    if (page === target) {      setExitEnabled(false);      setSlot1(view);      setSlot2(view);      setPage(forward ? "1" : "2");      requestAnimationFrame(() => {        requestAnimationFrame(apply);      });    } else {      apply();    }  };
  const today = startOfDay(new Date());  const minD = min ? startOfDay(min) : null;  const maxD = max ? startOfDay(max) : null;
  return (    <div      className={cn(        "w-full max-w-sm rounded-[var(--radius-lg)] bg-[var(--color-paper)] p-3 ring-1 ring-[var(--color-rule)]",        className,      )}    >      <div className="mb-3 flex items-center justify-between gap-2">        <motion.button          type="button"          aria-label="Previous month"          whileTap={reduce ? undefined : { scale: 0.96 }}          onClick={() => shiftMonth(-1)}          className="flex size-10 items-center justify-center rounded-[var(--radius-sm)] outline-none hover:bg-[var(--color-paper-2)] focus-visible:ring-2 focus-visible:ring-[var(--color-focus)]"        >          <ChevronLeft className="size-5" strokeWidth={1.5} aria-hidden />        </motion.button>        <span className="text-sm font-semibold text-[var(--color-ink)]">          {monthLabel(view.year, view.month)}        </span>        <motion.button          type="button"          aria-label="Next month"          whileTap={reduce ? undefined : { scale: 0.96 }}          onClick={() => shiftMonth(1)}          className="flex size-10 items-center justify-center rounded-[var(--radius-sm)] outline-none hover:bg-[var(--color-paper-2)] focus-visible:ring-2 focus-visible:ring-[var(--color-focus)]"        >          <ChevronRight className="size-5" strokeWidth={1.5} aria-hidden />        </motion.button>      </div>
      <div className="mb-1 grid grid-cols-7 gap-0.5">        {WEEKDAYS.map((d) => (          <div            key={d}            className="py-1 text-center text-[10px] font-medium uppercase tracking-wide text-[var(--color-ink-muted)]"          >            {d}          </div>        ))}      </div>
      <div        className="t-page-slide relative overflow-hidden"        data-page={page}        style={          {            "--page-exit-enabled": exitEnabled ? "1" : "0",          } as React.CSSProperties        }      >        <section className="t-page" data-page-id="1">          <MonthGrid            view={slot1}            selected={selected}            today={today}            minD={minD}            maxD={maxD}            selectedLayoutId={selectedLayoutId}            reduce={reduce}            interactive={page === "1"}            onSelect={setSelected}          />        </section>        <section className="t-page" data-page-id="2">          <MonthGrid            view={slot2}            selected={selected}            today={today}            minD={minD}            maxD={maxD}            selectedLayoutId={selectedLayoutId}            reduce={reduce}            interactive={page === "2"}            onSelect={setSelected}          />        </section>        <div className="invisible pointer-events-none" aria-hidden>          <MonthGrid            view={view}            selected={selected}            today={today}            minD={minD}            maxD={maxD}            selectedLayoutId={selectedLayoutId}            reduce={true}            interactive={false}            onSelect={setSelected}          />        </div>      </div>    </div>  );}

Adjust import paths according to your folder structure.


Basic Usage

tsx
import { CalendarMonth } from "@/components/ui/calendar-month";
<CalendarMonth value={date} onChange={setDate} min={today} />

Props

PropTypeRequiredDefaultDescription
valueDateNoControlled selected day.
onChange(date: Date) => voidNoFires when a day is picked.
minDateNoDisable days before this date.
maxDateNoDisable days after this date.

Best Practices

  1. Normalize to start-of-day before comparing with your data layer.
  2. Pair with a text field for typed dates; this is the visual pick surface.
  3. Don’t animate month changes under reduced motion — opacity only.