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.jsonInstall dependencies:
$ pnpm add motion lucide-reactAdd the utility function for class merging:
lib/utils.ts
tsx
1import { ClassValue, clsx } from "clsx";2import { twMerge } from "tailwind-merge";3 4export function cn(...inputs: ClassValue[]) {5 return twMerge(clsx(inputs));6}
Copy the component code into your project:
tsx
1"use client";2 3import React, { useCallback, useId, useMemo, useState } from "react";4import { motion, useReducedMotion } from "motion/react";5import { ChevronLeft, ChevronRight } from "lucide-react";6import { cn } from "@/lib/utils";7 8export type CalendarMonthProps = {9 value?: Date;10 defaultValue?: Date;11 onChange?: (date: Date) => void;12 min?: Date;13 max?: Date;14 className?: string;15};16 17const SPRING = { type: "spring" as const, bounce: 0, duration: 0.28 };18const WEEKDAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];19 20function startOfDay(d: Date) {21 return new Date(d.getFullYear(), d.getMonth(), d.getDate());22}23 24function sameDay(a: Date, b: Date) {25 return (26 a.getFullYear() === b.getFullYear() &&27 a.getMonth() === b.getMonth() &&28 a.getDate() === b.getDate()29 );30}31 32function daysInMonth(year: number, month: number) {33 return new Date(year, month + 1, 0).getDate();34}35 36function monthLabel(year: number, month: number) {37 return new Date(year, month, 1).toLocaleString("en-US", {38 month: "long",39 year: "numeric",40 });41}42 43function buildCells(year: number, month: number): (Date | null)[] {44 const firstDow = new Date(year, month, 1).getDay();45 const count = daysInMonth(year, month);46 const out: (Date | null)[] = [];47 for (let i = 0; i < firstDow; i++) out.push(null);48 for (let d = 1; d <= count; d++) {49 out.push(new Date(year, month, d));50 }51 while (out.length % 7 !== 0) out.push(null);52 return out;53}54 55type View = { year: number; month: number };56 57function MonthGrid({58 view,59 selected,60 today,61 minD,62 maxD,63 selectedLayoutId,64 reduce,65 interactive,66 onSelect,67}: {68 view: View;69 selected?: Date;70 today: Date;71 minD: Date | null;72 maxD: Date | null;73 selectedLayoutId: string;74 reduce: boolean | null;75 interactive: boolean;76 onSelect: (d: Date) => void;77}) {78 const cells = useMemo(79 () => buildCells(view.year, view.month),80 [view.year, view.month],81 );82 83 const isDisabled = (d: Date) => {84 if (minD && d < minD) return true;85 if (maxD && d > maxD) return true;86 return false;87 };88 89 return (90 <div className="grid grid-cols-7 gap-0.5">91 {cells.map((d, i) => {92 if (!d) {93 return (94 <div95 key={`e-${view.year}-${view.month}-${i}`}96 className="size-10"97 />98 );99 }100 const selectedDay = selected && sameDay(d, selected);101 const isToday = sameDay(d, today);102 const disabled = isDisabled(d);103 return (104 <motion.button105 key={d.toISOString()}106 type="button"107 disabled={!interactive || disabled}108 tabIndex={interactive ? 0 : -1}109 aria-label={d.toDateString()}110 aria-pressed={!!selectedDay}111 whileTap={112 !interactive || disabled || reduce113 ? undefined114 : { scale: 0.96 }115 }116 onClick={() => interactive && onSelect(d)}117 className={cn(118 "relative flex size-10 items-center justify-center rounded-full text-sm outline-none",119 "focus-visible:ring-2 focus-visible:ring-[var(--color-focus)]",120 disabled && "cursor-not-allowed opacity-30",121 !disabled && interactive && "hover:bg-[var(--color-paper-2)]",122 selectedDay123 ? "font-semibold text-[var(--color-paper)]"124 : "text-[var(--color-ink)]",125 )}126 >127 {selectedDay && interactive ? (128 <motion.span129 layoutId={selectedLayoutId}130 transition={reduce ? { duration: 0.1 } : SPRING}131 className="absolute inset-0 rounded-full bg-[var(--color-ink)]"132 />133 ) : selectedDay ? (134 <span className="absolute inset-0 rounded-full bg-[var(--color-ink)]" />135 ) : null}136 <span className="relative z-[1] tabular-nums">{d.getDate()}</span>137 {isToday && !selectedDay ? (138 <span className="absolute bottom-1 left-1/2 size-1 -translate-x-1/2 rounded-full bg-[var(--color-ink)]" />139 ) : null}140 </motion.button>141 );142 })}143 </div>144 );145}146 147export function CalendarMonth({148 value: valueProp,149 defaultValue,150 onChange,151 min,152 max,153 className,154}: CalendarMonthProps) {155 const reduce = useReducedMotion();156 const selectedLayoutId = `cal-selected-${useId()}`;157 const isControlled = valueProp !== undefined;158 const [uncontrolled, setUncontrolled] = useState(159 defaultValue ? startOfDay(defaultValue) : undefined,160 );161 const selected = isControlled162 ? valueProp163 ? startOfDay(valueProp)164 : undefined165 : uncontrolled;166 167 const initial = selected ?? startOfDay(new Date());168 const [view, setView] = useState<View>({169 year: initial.getFullYear(),170 month: initial.getMonth(),171 });172 // Page side-by-side (08): page slots + data-page orchestration173 const [page, setPage] = useState<"1" | "2">("1");174 const [slot1, setSlot1] = useState<View>(view);175 const [slot2, setSlot2] = useState<View>(view);176 const [exitEnabled, setExitEnabled] = useState(false);177 178 const setSelected = useCallback(179 (d: Date) => {180 const next = startOfDay(d);181 if (!isControlled) setUncontrolled(next);182 onChange?.(next);183 },184 [isControlled, onChange],185 );186 187 const shiftMonth = (dir: -1 | 1) => {188 const nextView: View = (() => {189 const d = new Date(view.year, view.month + dir, 1);190 return { year: d.getFullYear(), month: d.getMonth() };191 })();192 193 const forward = dir === 1;194 const target: "1" | "2" = forward ? "2" : "1";195 196 const apply = () => {197 if (forward) {198 setSlot1(view);199 setSlot2(nextView);200 } else {201 setSlot1(nextView);202 setSlot2(view);203 }204 setExitEnabled(!reduce);205 setPage(target);206 setView(nextView);207 };208 209 // Consecutive same-direction: snap to opposite page first so data-page can flip210 if (page === target) {211 setExitEnabled(false);212 setSlot1(view);213 setSlot2(view);214 setPage(forward ? "1" : "2");215 requestAnimationFrame(() => {216 requestAnimationFrame(apply);217 });218 } else {219 apply();220 }221 };222 223 const today = startOfDay(new Date());224 const minD = min ? startOfDay(min) : null;225 const maxD = max ? startOfDay(max) : null;226 227 return (228 <div229 className={cn(230 "w-full max-w-sm rounded-[var(--radius-lg)] bg-[var(--color-paper)] p-3 ring-1 ring-[var(--color-rule)]",231 className,232 )}233 >234 <div className="mb-3 flex items-center justify-between gap-2">235 <motion.button236 type="button"237 aria-label="Previous month"238 whileTap={reduce ? undefined : { scale: 0.96 }}239 onClick={() => shiftMonth(-1)}240 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)]"241 >242 <ChevronLeft className="size-5" strokeWidth={1.5} aria-hidden />243 </motion.button>244 <span className="text-sm font-semibold text-[var(--color-ink)]">245 {monthLabel(view.year, view.month)}246 </span>247 <motion.button248 type="button"249 aria-label="Next month"250 whileTap={reduce ? undefined : { scale: 0.96 }}251 onClick={() => shiftMonth(1)}252 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)]"253 >254 <ChevronRight className="size-5" strokeWidth={1.5} aria-hidden />255 </motion.button>256 </div>257 258 <div className="mb-1 grid grid-cols-7 gap-0.5">259 {WEEKDAYS.map((d) => (260 <div261 key={d}262 className="py-1 text-center text-[10px] font-medium uppercase tracking-wide text-[var(--color-ink-muted)]"263 >264 {d}265 </div>266 ))}267 </div>268 269 <div270 className="t-page-slide relative overflow-hidden"271 data-page={page}272 style={273 {274 "--page-exit-enabled": exitEnabled ? "1" : "0",275 } as React.CSSProperties276 }277 >278 <section className="t-page" data-page-id="1">279 <MonthGrid280 view={slot1}281 selected={selected}282 today={today}283 minD={minD}284 maxD={maxD}285 selectedLayoutId={selectedLayoutId}286 reduce={reduce}287 interactive={page === "1"}288 onSelect={setSelected}289 />290 </section>291 <section className="t-page" data-page-id="2">292 <MonthGrid293 view={slot2}294 selected={selected}295 today={today}296 minD={minD}297 maxD={maxD}298 selectedLayoutId={selectedLayoutId}299 reduce={reduce}300 interactive={page === "2"}301 onSelect={setSelected}302 />303 </section>304 <div className="invisible pointer-events-none" aria-hidden>305 <MonthGrid306 view={view}307 selected={selected}308 today={today}309 minD={minD}310 maxD={maxD}311 selectedLayoutId={selectedLayoutId}312 reduce={true}313 interactive={false}314 onSelect={setSelected}315 />316 </div>317 </div>318 </div>319 );320}
Adjust import paths according to your folder structure.
Basic Usage
tsx
1import { CalendarMonth } from "@/components/ui/calendar-month";2 3<CalendarMonth value={date} onChange={setDate} min={today} />
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| value | Date | No | — | Controlled selected day. |
| onChange | (date: Date) => void | No | — | Fires when a day is picked. |
| min | Date | No | — | Disable days before this date. |
| max | Date | No | — | Disable days after this date. |
Best Practices
- Normalize to start-of-day before comparing with your data layer.
- Pair with a text field for typed dates; this is the visual pick surface.
- Don’t animate month changes under reduced motion — opacity only.