Overview
A usable month surface — prev/next slides direction-aware like Step Wizard, and the month label swaps on the same axis. 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, {4 useCallback,5 useId,6 useLayoutEffect,7 useMemo,8 useRef,9 useState,10} from "react";11import { motion, useReducedMotion } from "motion/react";12import { ChevronLeft, ChevronRight } from "lucide-react";13import { cn } from "@/lib/utils";14 15export type CalendarMonthProps = {16 value?: Date;17 defaultValue?: Date;18 onChange?: (date: Date) => void;19 min?: Date;20 max?: Date;21 className?: string;22};23 24const SPRING = { type: "spring" as const, bounce: 0, duration: 0.28 };25const WEEKDAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];26 27function readTextSwapDur() {28 const v = parseFloat(29 getComputedStyle(document.documentElement).getPropertyValue(30 "--text-swap-dur",31 ),32 );33 return Number.isFinite(v) ? v : 150;34}35 36function startOfDay(d: Date) {37 return new Date(d.getFullYear(), d.getMonth(), d.getDate());38}39 40function sameDay(a: Date, b: Date) {41 return (42 a.getFullYear() === b.getFullYear() &&43 a.getMonth() === b.getMonth() &&44 a.getDate() === b.getDate()45 );46}47 48function daysInMonth(year: number, month: number) {49 return new Date(year, month + 1, 0).getDate();50}51 52function monthLabel(year: number, month: number) {53 return new Date(year, month, 1).toLocaleString("en-US", {54 month: "long",55 year: "numeric",56 });57}58 59function buildCells(year: number, month: number): (Date | null)[] {60 const firstDow = new Date(year, month, 1).getDay();61 const count = daysInMonth(year, month);62 const out: (Date | null)[] = [];63 for (let i = 0; i < firstDow; i++) out.push(null);64 for (let d = 1; d <= count; d++) {65 out.push(new Date(year, month, d));66 }67 while (out.length % 7 !== 0) out.push(null);68 return out;69}70 71type View = { year: number; month: number };72 73const MonthLabelText = React.memo(function MonthLabelText({74 seed,75 labelRef,76}: {77 seed: string;78 labelRef: React.RefObject<HTMLSpanElement | null>;79}) {80 return (81 <span82 ref={labelRef}83 className="cal-month-label t-text-swap text-sm font-semibold text-[var(--color-ink)]"84 aria-live="polite"85 >86 {seed}87 </span>88 );89});90 91function MonthGrid({92 view,93 selected,94 today,95 minD,96 maxD,97 selectedLayoutId,98 reduce,99 interactive,100 onSelect,101}: {102 view: View;103 selected?: Date;104 today: Date;105 minD: Date | null;106 maxD: Date | null;107 selectedLayoutId: string;108 reduce: boolean | null;109 interactive: boolean;110 onSelect: (d: Date) => void;111}) {112 const cells = useMemo(113 () => buildCells(view.year, view.month),114 [view.year, view.month],115 );116 117 const isDisabled = (d: Date) => {118 if (minD && d < minD) return true;119 if (maxD && d > maxD) return true;120 return false;121 };122 123 return (124 <div className="grid grid-cols-7 gap-0.5">125 {cells.map((d, i) => {126 if (!d) {127 return (128 <div129 key={`e-${view.year}-${view.month}-${i}`}130 className="size-10"131 />132 );133 }134 const selectedDay = selected && sameDay(d, selected);135 const isToday = sameDay(d, today);136 const disabled = isDisabled(d);137 return (138 <motion.button139 key={d.toISOString()}140 type="button"141 disabled={!interactive || disabled}142 tabIndex={interactive ? 0 : -1}143 aria-label={d.toDateString()}144 aria-pressed={!!selectedDay}145 whileTap={146 !interactive || disabled || reduce147 ? undefined148 : { scale: 0.96 }149 }150 onClick={() => interactive && onSelect(d)}151 className={cn(152 "relative flex size-10 items-center justify-center rounded-full text-sm outline-none",153 "focus-visible:ring-2 focus-visible:ring-[var(--color-focus)]",154 disabled && "cursor-not-allowed opacity-30",155 !disabled && interactive && "hover:bg-[var(--color-paper-2)]",156 selectedDay157 ? "font-semibold text-[var(--color-paper)]"158 : "text-[var(--color-ink)]",159 )}160 >161 {selectedDay && interactive ? (162 <motion.span163 layoutId={selectedLayoutId}164 transition={reduce ? { duration: 0.1 } : SPRING}165 className="absolute inset-0 rounded-full bg-[var(--color-ink)]"166 />167 ) : selectedDay ? (168 <span className="absolute inset-0 rounded-full bg-[var(--color-ink)]" />169 ) : null}170 <span className="relative z-[1] tabular-nums">{d.getDate()}</span>171 {isToday && !selectedDay ? (172 <span className="absolute bottom-1 left-1/2 size-1 -translate-x-1/2 rounded-full bg-[var(--color-ink)]" />173 ) : null}174 </motion.button>175 );176 })}177 </div>178 );179}180 181export function CalendarMonth({182 value: valueProp,183 defaultValue,184 onChange,185 min,186 max,187 className,188}: CalendarMonthProps) {189 const reduce = useReducedMotion();190 const selectedLayoutId = `cal-selected-${useId()}`;191 const isControlled = valueProp !== undefined;192 const [uncontrolled, setUncontrolled] = useState(193 defaultValue ? startOfDay(defaultValue) : undefined,194 );195 const selected = isControlled196 ? valueProp197 ? startOfDay(valueProp)198 : undefined199 : uncontrolled;200 201 const initial = selected ?? startOfDay(new Date());202 const [view, setView] = useState<View>({203 year: initial.getFullYear(),204 month: initial.getMonth(),205 });206 // Page side-by-side (08): page slots + data-page orchestration207 const [page, setPage] = useState<"1" | "2">("1");208 const [slot1, setSlot1] = useState<View>(view);209 const [slot2, setSlot2] = useState<View>(view);210 const [exitEnabled, setExitEnabled] = useState(false);211 const labelSeed = useRef(monthLabel(view.year, view.month)).current;212 const labelRef = useRef<HTMLSpanElement>(null);213 const prevLabel = useRef(monthLabel(view.year, view.month));214 const labelDirRef = useRef<1 | -1>(1);215 const swapTimer = useRef<number | null>(null);216 217 useLayoutEffect(() => {218 const el = labelRef.current;219 if (!el) return;220 const next = monthLabel(view.year, view.month);221 if (prevLabel.current === next) {222 if (!el.textContent) el.textContent = next;223 return;224 }225 prevLabel.current = next;226 el.setAttribute("data-dir", String(labelDirRef.current));227 228 if (reduce) {229 el.textContent = next;230 return;231 }232 233 if (swapTimer.current) window.clearTimeout(swapTimer.current);234 const dur = readTextSwapDur();235 el.classList.add("is-exit");236 swapTimer.current = window.setTimeout(() => {237 el.textContent = next;238 el.classList.remove("is-exit");239 el.classList.add("is-enter-start");240 void el.offsetHeight;241 el.classList.remove("is-enter-start");242 swapTimer.current = null;243 }, dur);244 245 return () => {246 if (swapTimer.current) window.clearTimeout(swapTimer.current);247 };248 }, [view.year, view.month, reduce]);249 250 const setSelected = useCallback(251 (d: Date) => {252 const next = startOfDay(d);253 if (!isControlled) setUncontrolled(next);254 onChange?.(next);255 },256 [isControlled, onChange],257 );258 259 const shiftMonth = (dir: -1 | 1) => {260 labelDirRef.current = dir;261 const nextView: View = (() => {262 const d = new Date(view.year, view.month + dir, 1);263 return { year: d.getFullYear(), month: d.getMonth() };264 })();265 266 const forward = dir === 1;267 const target: "1" | "2" = forward ? "2" : "1";268 269 const apply = () => {270 if (forward) {271 setSlot1(view);272 setSlot2(nextView);273 } else {274 setSlot1(nextView);275 setSlot2(view);276 }277 setExitEnabled(!reduce);278 setPage(target);279 setView(nextView);280 };281 282 // Consecutive same-direction: snap to opposite page first so data-page can flip283 if (page === target) {284 setExitEnabled(false);285 setSlot1(view);286 setSlot2(view);287 setPage(forward ? "1" : "2");288 requestAnimationFrame(() => {289 requestAnimationFrame(apply);290 });291 } else {292 apply();293 }294 };295 296 const today = startOfDay(new Date());297 const minD = min ? startOfDay(min) : null;298 const maxD = max ? startOfDay(max) : null;299 300 return (301 <div302 className={cn(303 "w-full max-w-sm rounded-[var(--radius-lg)] bg-[var(--color-paper)] p-3 ring-1 ring-[var(--color-rule)]",304 className,305 )}306 >307 <div className="mb-3 flex items-center justify-between gap-2">308 <motion.button309 type="button"310 aria-label="Previous month"311 whileTap={reduce ? undefined : { scale: 0.96 }}312 onClick={() => shiftMonth(-1)}313 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)]"314 >315 <ChevronLeft className="size-5" strokeWidth={1.5} aria-hidden />316 </motion.button>317 <MonthLabelText seed={labelSeed} labelRef={labelRef} />318 <motion.button319 type="button"320 aria-label="Next month"321 whileTap={reduce ? undefined : { scale: 0.96 }}322 onClick={() => shiftMonth(1)}323 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)]"324 >325 <ChevronRight className="size-5" strokeWidth={1.5} aria-hidden />326 </motion.button>327 </div>328 329 <div className="mb-1 grid grid-cols-7 gap-0.5">330 {WEEKDAYS.map((d) => (331 <div332 key={d}333 className="py-1 text-center text-[10px] font-medium uppercase tracking-wide text-[var(--color-ink-muted)]"334 >335 {d}336 </div>337 ))}338 </div>339 340 <div341 className="t-page-slide relative overflow-hidden"342 data-page={page}343 style={344 {345 "--page-exit-enabled": exitEnabled ? "1" : "0",346 } as React.CSSProperties347 }348 >349 <section className="t-page" data-page-id="1">350 <MonthGrid351 view={slot1}352 selected={selected}353 today={today}354 minD={minD}355 maxD={maxD}356 selectedLayoutId={selectedLayoutId}357 reduce={reduce}358 interactive={page === "1"}359 onSelect={setSelected}360 />361 </section>362 <section className="t-page" data-page-id="2">363 <MonthGrid364 view={slot2}365 selected={selected}366 today={today}367 minD={minD}368 maxD={maxD}369 selectedLayoutId={selectedLayoutId}370 reduce={reduce}371 interactive={page === "2"}372 onSelect={setSelected}373 />374 </section>375 <div className="invisible pointer-events-none" aria-hidden>376 <MonthGrid377 view={view}378 selected={selected}379 today={today}380 minD={minD}381 maxD={maxD}382 selectedLayoutId={selectedLayoutId}383 reduce={true}384 interactive={false}385 onSelect={setSelected}386 />387 </div>388 </div>389 </div>390 );391}
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.