Calendar Mini
Original · freecompact mini calendar widget
byWeb InnoventixReact + Tailwind
calendarminicalendars
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/calendar-mini.jsoncalendar-mini.tsx
"use client";
import {
useEffect,
useId,
useMemo,
useRef,
useState,
type KeyboardEvent,
} from "react";
import { motion, useReducedMotion } from "motion/react";
type DateParts = { year: number; month: number; day: number };
type EventTag = "work" | "focus" | "personal";
type CalEvent = { time: string; title: string; tag: EventTag };
type DayCell = {
key: string;
year: number;
month: number;
day: number;
inMonth: boolean;
isToday: boolean;
};
const TODAY: DateParts = { year: 2026, month: 6, day: 16 };
const MONTHS_LONG = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const WEEKDAYS_LONG = [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
];
const WEEKDAYS_SHORT = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const TAG_STYLES: Record<
EventTag,
{ label: string; dot: string; chip: string }
> = {
work: {
label: "Work",
dot: "bg-indigo-500",
chip:
"bg-indigo-50 text-indigo-700 ring-indigo-200 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-500/30",
},
focus: {
label: "Focus",
dot: "bg-amber-500",
chip:
"bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-500/30",
},
personal: {
label: "Personal",
dot: "bg-emerald-500",
chip:
"bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-500/30",
},
};
const EVENTS: Record<string, CalEvent[]> = {
"2026-06-29": [{ time: "10:00", title: "Retro — June cycle", tag: "work" }],
"2026-07-02": [
{ time: "09:30", title: "Standup — Platform squad", tag: "work" },
],
"2026-07-03": [
{ time: "10:00", title: "Design review: Aurora dashboard", tag: "work" },
{ time: "15:30", title: "Deep work: pricing model", tag: "focus" },
],
"2026-07-08": [{ time: "14:30", title: "1:1 with Marcus Bell", tag: "work" }],
"2026-07-10": [{ time: "18:00", title: "Yoga with Dana", tag: "personal" }],
"2026-07-14": [
{ time: "09:15", title: "Sprint planning", tag: "work" },
{ time: "16:00", title: "Coffee with Priya Nair", tag: "personal" },
],
"2026-07-16": [
{ time: "11:00", title: "Product sync — v2.4 scope", tag: "work" },
{ time: "15:00", title: "Ship v2.4 to production", tag: "focus" },
{ time: "19:30", title: "Dinner with Elena", tag: "personal" },
],
"2026-07-20": [{ time: "08:45", title: "Investor update draft", tag: "focus" }],
"2026-07-21": [
{ time: "13:00", title: "Client demo — Northwind Co.", tag: "work" },
],
"2026-07-24": [
{ time: "12:00", title: "Lunch & learn: TypeScript 6", tag: "work" },
],
"2026-07-27": [
{ time: "10:30", title: "Offsite logistics review", tag: "work" },
],
"2026-07-31": [{ time: "17:00", title: "Payroll cutoff", tag: "focus" }],
"2026-08-03": [{ time: "09:00", title: "Q3 kickoff", tag: "work" }],
};
function pad(n: number): string {
return n < 10 ? `0${n}` : String(n);
}
function partsFromDate(dt: Date): DateParts {
return { year: dt.getFullYear(), month: dt.getMonth(), day: dt.getDate() };
}
function toKey(p: DateParts): string {
return `${p.year}-${pad(p.month + 1)}-${pad(p.day)}`;
}
function parseKey(key: string): DateParts {
const seg = key.split("-");
return { year: Number(seg[0]), month: Number(seg[1]) - 1, day: Number(seg[2]) };
}
function addDays(p: DateParts, delta: number): DateParts {
return partsFromDate(new Date(p.year, p.month, p.day + delta));
}
function addMonths(p: DateParts, delta: number): DateParts {
const target = new Date(p.year, p.month + delta, 1);
const y = target.getFullYear();
const m = target.getMonth();
const maxDay = new Date(y, m + 1, 0).getDate();
return { year: y, month: m, day: Math.min(p.day, maxDay) };
}
function startOfWeek(p: DateParts): DateParts {
const dow = new Date(p.year, p.month, p.day).getDay();
const diff = (dow + 6) % 7;
return addDays(p, -diff);
}
function weekdayIndex(p: DateParts): number {
return (new Date(p.year, p.month, p.day).getDay() + 6) % 7;
}
function buildMonth(view: DateParts): DayCell[] {
const first = new Date(view.year, view.month, 1);
const startOffset = (first.getDay() + 6) % 7;
const start = new Date(view.year, view.month, 1 - startOffset);
const cells: DayCell[] = [];
for (let i = 0; i < 42; i += 1) {
const dt = new Date(
start.getFullYear(),
start.getMonth(),
start.getDate() + i,
);
const y = dt.getFullYear();
const m = dt.getMonth();
const d = dt.getDate();
cells.push({
key: toKey({ year: y, month: m, day: d }),
year: y,
month: m,
day: d,
inMonth: m === view.month,
isToday: y === TODAY.year && m === TODAY.month && d === TODAY.day,
});
}
return cells;
}
function longLabel(p: DateParts): string {
return `${WEEKDAYS_LONG[weekdayIndex(p)]}, ${p.day} ${MONTHS_LONG[p.month]} ${p.year}`;
}
export default function CalendarMini() {
const reduce = useReducedMotion();
const captionId = useId();
const helpId = useId();
const todayKey = toKey(TODAY);
const [view, setView] = useState<DateParts>({
year: TODAY.year,
month: TODAY.month,
});
const [selectedKey, setSelectedKey] = useState<string>(todayKey);
const [focusedKey, setFocusedKey] = useState<string>(todayKey);
const dayRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const focusPendingRef = useRef(false);
useEffect(() => {
if (!focusPendingRef.current) return;
const btn = dayRefs.current[focusedKey];
if (btn) btn.focus();
focusPendingRef.current = false;
}, [focusedKey, view]);
const cells = useMemo(() => buildMonth(view), [view]);
const weeks = useMemo(() => {
const rows: DayCell[][] = [];
for (let i = 0; i < cells.length; i += 7) rows.push(cells.slice(i, i + 7));
return rows;
}, [cells]);
const selectedParts = parseKey(selectedKey);
const selectedEvents = EVENTS[selectedKey] ?? [];
function moveFocus(next: DateParts) {
focusPendingRef.current = true;
setFocusedKey(toKey(next));
if (next.month !== view.month || next.year !== view.year) {
setView({ year: next.year, month: next.month });
}
}
function selectDay(p: DateParts) {
const key = toKey(p);
setSelectedKey(key);
setFocusedKey(key);
if (p.month !== view.month || p.year !== view.year) {
setView({ year: p.year, month: p.month });
}
}
function goToMonth(delta: number) {
const next = addMonths(parseKey(focusedKey), delta);
setView({ year: next.year, month: next.month });
setFocusedKey(toKey(next));
}
function goToday() {
setView({ year: TODAY.year, month: TODAY.month });
setFocusedKey(todayKey);
setSelectedKey(todayKey);
}
function handleKeyDown(e: KeyboardEvent<HTMLDivElement>) {
const cur = parseKey(focusedKey);
let next: DateParts | null = null;
switch (e.key) {
case "ArrowLeft":
next = addDays(cur, -1);
break;
case "ArrowRight":
next = addDays(cur, 1);
break;
case "ArrowUp":
next = addDays(cur, -7);
break;
case "ArrowDown":
next = addDays(cur, 7);
break;
case "Home":
next = startOfWeek(cur);
break;
case "End":
next = addDays(startOfWeek(cur), 6);
break;
case "PageUp":
next = addMonths(cur, e.shiftKey ? -12 : -1);
break;
case "PageDown":
next = addMonths(cur, e.shiftKey ? 12 : 1);
break;
case "Enter":
case " ":
e.preventDefault();
selectDay(cur);
return;
default:
return;
}
e.preventDefault();
moveFocus(next);
}
const iconBtn =
"inline-flex h-8 w-8 items-center justify-center rounded-lg text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-900";
return (
<section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-white px-4 py-16 sm:py-20 dark:from-slate-950 dark:to-slate-900">
<style>{`
@keyframes calmini-grid-in {
from { opacity: 0; transform: translateY(6px) scale(0.99); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.calmini-grid-in { animation: calmini-grid-in 320ms cubic-bezier(0.22, 1, 0.36, 1); }
@media (prefers-reduced-motion: reduce) {
.calmini-grid-in { animation: none !important; }
}
`}</style>
<div className="mx-auto max-w-3xl">
<div className="mb-8">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
Schedule
</p>
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-white">
Your week at a glance
</h2>
</div>
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="grid grid-cols-1 md:grid-cols-[300px_1fr]">
{/* Calendar */}
<div className="border-b border-slate-200 p-5 md:border-b-0 md:border-r dark:border-slate-800">
<div className="mb-4 flex items-center justify-between gap-2">
<h3
id={captionId}
aria-live="polite"
className="text-sm font-semibold text-slate-900 dark:text-white"
>
{MONTHS_LONG[view.month]} {view.year}
</h3>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => goToMonth(-1)}
className={iconBtn}
aria-label="Previous month"
>
<svg
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
className="h-4 w-4"
aria-hidden="true"
>
<path
d="M12.5 5 7.5 10l5 5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
<button
type="button"
onClick={goToday}
className="rounded-md px-2.5 py-1 text-xs font-medium text-slate-600 ring-1 ring-inset ring-slate-200 transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-300 dark:ring-slate-700 dark:hover:bg-slate-800 dark:hover:text-white dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-900"
>
Today
</button>
<button
type="button"
onClick={() => goToMonth(1)}
className={iconBtn}
aria-label="Next month"
>
<svg
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
className="h-4 w-4"
aria-hidden="true"
>
<path
d="M7.5 5 12.5 10l-5 5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
</div>
<p id={helpId} className="sr-only">
Use arrow keys to move between dates, Page Up and Page Down to
change month, and Enter to select a date.
</p>
<div
key={`${view.year}-${view.month}`}
role="grid"
aria-labelledby={captionId}
aria-describedby={helpId}
onKeyDown={handleKeyDown}
className={reduce ? "" : "calmini-grid-in"}
>
<div role="row" className="mb-1 grid grid-cols-7">
{WEEKDAYS_SHORT.map((wd, i) => (
<div
key={wd}
role="columnheader"
aria-label={WEEKDAYS_LONG[i]}
className="py-1 text-center text-[0.65rem] font-semibold uppercase tracking-wide text-slate-400 dark:text-slate-500"
>
{wd}
</div>
))}
</div>
{weeks.map((week) => (
<div
key={week[0].key}
role="row"
className="grid grid-cols-7 gap-0.5"
>
{week.map((cell) => {
const dayEvents = EVENTS[cell.key] ?? [];
const count = dayEvents.length;
const isSelected = cell.key === selectedKey;
const parts: DateParts = {
year: cell.year,
month: cell.month,
day: cell.day,
};
const classes = [
"relative flex h-9 w-full items-center justify-center rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-900",
];
if (isSelected) {
classes.push(
"bg-indigo-600 text-white shadow-sm hover:bg-indigo-600 dark:bg-indigo-500 dark:hover:bg-indigo-500",
);
} else if (cell.isToday) {
classes.push(
"text-indigo-600 ring-1 ring-inset ring-indigo-400 hover:bg-indigo-50 dark:text-indigo-300 dark:ring-indigo-500/60 dark:hover:bg-indigo-500/10",
);
} else if (cell.inMonth) {
classes.push(
"text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800",
);
} else {
classes.push(
"text-slate-300 hover:bg-slate-100 dark:text-slate-600 dark:hover:bg-slate-800",
);
}
const label = `${longLabel(parts)}${
count > 0
? `, ${count} ${count === 1 ? "event" : "events"}`
: ""
}${cell.isToday ? ", today" : ""}`;
return (
<div
key={cell.key}
role="gridcell"
aria-selected={isSelected}
>
<button
type="button"
ref={(el) => {
dayRefs.current[cell.key] = el;
}}
tabIndex={cell.key === focusedKey ? 0 : -1}
aria-label={label}
aria-current={cell.isToday ? "date" : undefined}
onClick={() => selectDay(parts)}
className={classes.join(" ")}
>
{cell.day}
{count > 0 ? (
<span className="absolute bottom-1 flex gap-0.5">
{dayEvents.slice(0, 3).map((ev, i) => (
<span
key={i}
className={`h-1 w-1 rounded-full ${
isSelected
? "bg-white/80"
: TAG_STYLES[ev.tag].dot
}`}
/>
))}
</span>
) : null}
</button>
</div>
);
})}
</div>
))}
</div>
<div className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-slate-100 pt-3 dark:border-slate-800">
{(Object.keys(TAG_STYLES) as EventTag[]).map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1.5 text-[0.7rem] font-medium text-slate-500 dark:text-slate-400"
>
<span
className={`h-1.5 w-1.5 rounded-full ${TAG_STYLES[tag].dot}`}
/>
{TAG_STYLES[tag].label}
</span>
))}
</div>
</div>
{/* Agenda */}
<div className="p-5">
<div className="mb-4" aria-live="polite">
<p className="text-[0.7rem] font-semibold uppercase tracking-wide text-slate-400 dark:text-slate-500">
{selectedKey === todayKey ? "Today" : "Selected"}
</p>
<h3 className="mt-0.5 text-sm font-semibold text-slate-900 dark:text-white">
{longLabel(selectedParts)}
</h3>
<p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">
{selectedEvents.length === 0
? "No events scheduled"
: `${selectedEvents.length} ${
selectedEvents.length === 1 ? "event" : "events"
}`}
</p>
</div>
{selectedEvents.length === 0 ? (
<div className="flex h-40 flex-col items-center justify-center rounded-xl border border-dashed border-slate-200 text-center dark:border-slate-800">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
className="h-6 w-6 text-slate-300 dark:text-slate-600"
aria-hidden="true"
>
<path
d="M5 8h14M5 8a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2M5 8v9a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8M9 4v3m6-3v3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<p className="mt-2 text-xs font-medium text-slate-500 dark:text-slate-400">
An open day. Enjoy it.
</p>
</div>
) : (
<ul key={selectedKey} className="space-y-2">
{selectedEvents.map((ev, i) => {
const style = TAG_STYLES[ev.tag];
return (
<motion.li
key={`${ev.time}-${ev.title}`}
initial={reduce ? false : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: reduce ? 0 : 0.25,
delay: reduce ? 0 : i * 0.05,
ease: [0.22, 1, 0.36, 1],
}}
className="flex items-start gap-3 rounded-xl border border-slate-100 bg-slate-50/60 p-3 dark:border-slate-800 dark:bg-slate-800/40"
>
<span
className={`mt-0.5 h-8 w-1 shrink-0 rounded-full ${style.dot}`}
aria-hidden="true"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-slate-900 dark:text-white">
{ev.title}
</p>
<div className="mt-1 flex items-center gap-2">
<span className="text-xs tabular-nums text-slate-500 dark:text-slate-400">
{ev.time}
</span>
<span
className={`inline-flex items-center rounded-full px-1.5 py-0.5 text-[0.65rem] font-medium ring-1 ring-inset ${style.chip}`}
>
{style.label}
</span>
</div>
</div>
</motion.li>
);
})}
</ul>
)}
</div>
</div>
</div>
</div>
</section>
);
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Calendar Date Picker
Originaldate picker with a dropdown calendar

Calendar Range Picker
Originaldate range picker with two months

Calendar Inline
Originalalways-visible inline calendar

Calendar Month Year
Originalmonth and year quick selector

Calendar Event
Originalmonth calendar with event dots

Calendar Time Picker
Originaltime picker with hour/minute

Calendar Datetime
Originalcombined date and time picker

Calendar Week
Originalweek view with time slots

Calendar Agenda
Originalagenda list of upcoming days

Calendar Availability
Originalavailability slot picker

Calendar Booking
Originalbooking calendar with time slots

Calendar Multi Month
Originaltwo-month scrolling calendar

