Calendar Week
Original · freeweek view with time slots
byWeb InnoventixReact + Tailwind
calendarweekcalendars
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-week.jsoncalendar-week.tsx
"use client";
import { useState, useRef, useMemo, useEffect } from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
import { motion, AnimatePresence, useReducedMotion } from "motion/react";
type CalId = "design" | "eng" | "personal" | "external";
interface CalMeta {
id: CalId;
label: string;
dot: string;
block: string;
}
interface Ev {
id: string;
cal: CalId;
title: string;
day: number; // 0 = Monday
start: number; // minutes from midnight
dur: number; // minutes
week: number; // week offset the event belongs to
who?: string;
where?: string;
recurring?: boolean;
}
type Selection =
| { kind: "event"; id: string }
| { kind: "slot"; day: number; hour: number }
| null;
const FIRST_HOUR = 8;
const LAST_HOUR = 20; // exclusive -> rows 8..19
const ROW_H = 56;
const HOURS: number[] = Array.from(
{ length: LAST_HOUR - FIRST_HOUR },
(_, i) => FIRST_HOUR + i,
);
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const DAYS_FULL = [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
];
const MONTHS = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
const BASE_MONDAY = new Date(2026, 6, 13); // Mon Jul 13 2026
const TODAY = new Date(2026, 6, 16); // Thu Jul 16 2026
const NOW_MIN = 14 * 60 + 20; // 2:20 PM
const CAL_LIST: CalMeta[] = [
{
id: "design",
label: "Design",
dot: "bg-violet-500",
block:
"border-violet-300 bg-violet-50 text-violet-900 dark:border-violet-400/30 dark:bg-violet-500/15 dark:text-violet-100",
},
{
id: "eng",
label: "Engineering",
dot: "bg-sky-500",
block:
"border-sky-300 bg-sky-50 text-sky-900 dark:border-sky-400/30 dark:bg-sky-500/15 dark:text-sky-100",
},
{
id: "personal",
label: "Personal",
dot: "bg-emerald-500",
block:
"border-emerald-300 bg-emerald-50 text-emerald-900 dark:border-emerald-400/30 dark:bg-emerald-500/15 dark:text-emerald-100",
},
{
id: "external",
label: "External",
dot: "bg-amber-500",
block:
"border-amber-300 bg-amber-50 text-amber-900 dark:border-amber-400/30 dark:bg-amber-500/15 dark:text-amber-100",
},
];
const CAL_MAP: Record<CalId, CalMeta> = CAL_LIST.reduce(
(acc, c) => {
acc[c.id] = c;
return acc;
},
{} as Record<CalId, CalMeta>,
);
const SEED_EVENTS: Ev[] = [
{
id: "e1",
cal: "design",
title: "Design review — Checkout flow",
day: 0,
start: 10 * 60 + 30,
dur: 60,
week: 0,
who: "Priya Nair",
where: "Zoom",
},
{
id: "e2",
cal: "personal",
title: "1:1 with Marcus Bell",
day: 0,
start: 14 * 60,
dur: 45,
week: 0,
who: "Marcus Bell",
where: "Room 4B",
},
{
id: "e3",
cal: "design",
title: "User interviews — Cohort B",
day: 1,
start: 11 * 60,
dur: 90,
week: 0,
who: "Research guild",
where: "Lab 2",
},
{
id: "e4",
cal: "eng",
title: "Sprint planning",
day: 1,
start: 15 * 60 + 30,
dur: 60,
week: 0,
who: "Platform team",
where: "Room 2A",
},
{
id: "e5",
cal: "personal",
title: "Lunch & learn — Accessibility",
day: 2,
start: 13 * 60,
dur: 60,
week: 0,
who: "Dana Okafor",
where: "Cafeteria",
},
{
id: "e6",
cal: "eng",
title: "Roadmap sync",
day: 2,
start: 16 * 60,
dur: 45,
week: 0,
who: "Elena Fischer",
where: "Zoom",
},
{
id: "e7",
cal: "personal",
title: "Deep work — Q3 metrics",
day: 3,
start: 10 * 60,
dur: 120,
week: 0,
},
{
id: "e8",
cal: "external",
title: "Client demo — Northwind",
day: 3,
start: 14 * 60 + 30,
dur: 60,
week: 0,
who: "Northwind Ltd.",
where: "Boardroom",
},
{
id: "e9",
cal: "design",
title: "Retro",
day: 4,
start: 11 * 60 + 30,
dur: 45,
week: 0,
who: "Whole squad",
where: "Room 4B",
},
{
id: "e10",
cal: "eng",
title: "Release cut — v2.4",
day: 4,
start: 15 * 60,
dur: 60,
week: 0,
who: "Release captain",
where: "#deploys",
},
{
id: "e11",
cal: "personal",
title: "Trail run — Marin",
day: 5,
start: 9 * 60,
dur: 90,
week: 0,
},
];
function addDays(d: Date, n: number): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
}
function sameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
function fmtTime(min: number): string {
const h24 = Math.floor(min / 60);
const m = min % 60;
const ampm = h24 < 12 ? "AM" : "PM";
let h = h24 % 12;
if (h === 0) h = 12;
const mm = m === 0 ? "" : `:${String(m).padStart(2, "0")}`;
return `${h}${mm} ${ampm}`;
}
const RING =
"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-offset-slate-900";
export default function CalendarWeek() {
const reduce = useReducedMotion();
const [weekOffset, setWeekOffset] = useState<number>(0);
const [dir, setDir] = useState<number>(0);
const [events, setEvents] = useState<Ev[]>(SEED_EVENTS);
const [selected, setSelected] = useState<Selection>(null);
const [focusedCell, setFocusedCell] = useState<{ day: number; hour: number }>(
{ day: 3, hour: 9 },
);
const [activeCal, setActiveCal] = useState<Record<CalId, boolean>>({
design: true,
eng: true,
personal: true,
external: true,
});
const [composeTitle, setComposeTitle] = useState<string>("");
const [composeCal, setComposeCal] = useState<CalId>("personal");
const cellRefs = useRef<Map<string, HTMLButtonElement | null>>(new Map());
const composeRef = useRef<HTMLInputElement | null>(null);
const nextId = useRef<number>(1);
const weekDates = useMemo(
() => Array.from({ length: 7 }, (_, d) => addDays(BASE_MONDAY, weekOffset * 7 + d)),
[weekOffset],
);
const allWeek = useMemo<Ev[]>(() => {
const base = events.filter((e) => e.week === weekOffset);
const standups: Ev[] = [0, 1, 2, 3, 4].map((d) => ({
id: `standup-${weekOffset}-${d}`,
cal: "eng",
title: "Daily standup",
day: d,
start: 9 * 60,
dur: 30,
week: weekOffset,
who: "Whole team",
where: "#standup",
recurring: true,
}));
return [...standups, ...base];
}, [events, weekOffset]);
const weekEvents = useMemo(
() => allWeek.filter((e) => activeCal[e.cal]),
[allWeek, activeCal],
);
const selectedEvent =
selected?.kind === "event"
? weekEvents.find((e) => e.id === selected.id) ?? null
: null;
useEffect(() => {
if (selected?.kind === "slot") composeRef.current?.focus();
}, [selected]);
const firstD = weekDates[0];
const lastD = weekDates[6];
const weekLabel =
firstD.getMonth() === lastD.getMonth()
? `${MONTHS[firstD.getMonth()]} ${firstD.getDate()} – ${lastD.getDate()}, ${lastD.getFullYear()}`
: `${MONTHS[firstD.getMonth()]} ${firstD.getDate()} – ${MONTHS[lastD.getMonth()]} ${lastD.getDate()}, ${lastD.getFullYear()}`;
const goWeek = (delta: number) => {
setDir(delta);
setWeekOffset((w) => w + delta);
setSelected(null);
};
const goToday = () => {
setDir(weekOffset > 0 ? -1 : 1);
setWeekOffset(0);
setSelected(null);
};
function handleKey(
e: ReactKeyboardEvent<HTMLButtonElement>,
day: number,
hour: number,
) {
let nd = day;
let nh = hour;
switch (e.key) {
case "ArrowRight":
nd = Math.min(6, day + 1);
break;
case "ArrowLeft":
nd = Math.max(0, day - 1);
break;
case "ArrowDown":
nh = Math.min(LAST_HOUR - 1, hour + 1);
break;
case "ArrowUp":
nh = Math.max(FIRST_HOUR, hour - 1);
break;
case "Home":
nh = FIRST_HOUR;
break;
case "End":
nh = LAST_HOUR - 1;
break;
default:
return;
}
e.preventDefault();
setFocusedCell({ day: nd, hour: nh });
cellRefs.current.get(`${nd}-${nh}`)?.focus();
}
const addEvent = () => {
if (selected?.kind !== "slot") return;
const id = `u-${nextId.current++}`;
const title = composeTitle.trim() || "New event";
const ev: Ev = {
id,
cal: composeCal,
title,
day: selected.day,
start: selected.hour * 60,
dur: 60,
week: weekOffset,
};
setEvents((prev) => [...prev, ev]);
setComposeTitle("");
setActiveCal((prev) => ({ ...prev, [composeCal]: true }));
setSelected({ kind: "event", id });
};
const removeEvent = (id: string) => {
setEvents((prev) => prev.filter((e) => e.id !== id));
setSelected(null);
};
const nowTop = ((NOW_MIN - FIRST_HOUR * 60) / 60) * ROW_H;
const nowVisible = NOW_MIN >= FIRST_HOUR * 60 && NOW_MIN < LAST_HOUR * 60;
const panelMotion = reduce
? {}
: {
initial: { opacity: 0, y: 8 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -8 },
transition: { duration: 0.2 },
};
return (
<section className="relative w-full bg-slate-50 py-14 text-slate-900 dark:bg-slate-950 dark:text-slate-100 sm:py-20">
<style>{`
@keyframes calw-nowdot {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.7); opacity: 0.35; }
}
@keyframes calw-rise {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.calw-nowdot { animation: calw-nowdot 2.4s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.calw-nowdot { animation: none !important; }
}
`}</style>
<div className="mx-auto max-w-6xl px-4 sm:px-6">
{/* Heading */}
<div className="mb-6 flex flex-wrap items-end justify-between gap-4">
<div>
<p className="mb-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-indigo-500 dark:text-indigo-400">
Product squad
</p>
<h2 className="text-2xl font-semibold tracking-tight sm:text-3xl">
Weekly schedule
</h2>
</div>
<div className="text-right text-sm text-slate-500 dark:text-slate-400">
<div className="font-medium text-slate-700 dark:text-slate-200">
{weekLabel}
</div>
<div>{weekEvents.length} events this week</div>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-[1fr_18rem]">
{/* Calendar card */}
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
{/* Toolbar */}
<div className="flex items-center justify-between gap-3 border-b border-slate-200 px-4 py-3 dark:border-slate-800">
<div className="inline-flex items-center gap-1">
<button
type="button"
onClick={() => goWeek(-1)}
aria-label="Previous week"
className={`inline-flex h-9 w-9 items-center justify-center rounded-lg text-slate-600 transition-colors hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800 ${RING}`}
>
<Chevron dir="left" />
</button>
<button
type="button"
onClick={goToday}
className={`rounded-lg border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800 ${RING}`}
>
Today
</button>
<button
type="button"
onClick={() => goWeek(1)}
aria-label="Next week"
className={`inline-flex h-9 w-9 items-center justify-center rounded-lg text-slate-600 transition-colors hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800 ${RING}`}
>
<Chevron dir="right" />
</button>
</div>
<span className="inline-flex items-center gap-1.5 rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-medium text-slate-500 dark:bg-slate-800 dark:text-slate-400">
<CalIcon />
Week view
</span>
</div>
{/* Grid */}
<div className="overflow-x-auto">
<div className="min-w-[720px]">
{/* Day headers */}
<div className="flex border-b border-slate-200 dark:border-slate-800">
<div className="w-14 shrink-0" aria-hidden="true" />
{weekDates.map((date, d) => {
const isToday = sameDay(date, TODAY);
return (
<div
key={d}
className={`flex-1 border-l border-slate-200 py-2 text-center dark:border-slate-800 ${
isToday ? "bg-indigo-50/60 dark:bg-indigo-500/10" : ""
}`}
>
<div className="text-[11px] font-medium uppercase tracking-wide text-slate-400 dark:text-slate-500">
{DAYS[d]}
</div>
<div
className={`mx-auto mt-0.5 flex h-7 w-7 items-center justify-center rounded-full text-sm font-semibold ${
isToday
? "bg-indigo-600 text-white"
: "text-slate-700 dark:text-slate-200"
}`}
>
{date.getDate()}
</div>
</div>
);
})}
</div>
{/* Time grid */}
<motion.div
key={weekOffset}
initial={reduce ? false : { opacity: 0, x: dir * 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: reduce ? 0 : 0.28, ease: "easeOut" }}
className="flex pt-2"
>
{/* Hour gutter */}
<div className="w-14 shrink-0">
{HOURS.map((h) => (
<div
key={h}
style={{ height: ROW_H }}
className="relative"
>
<span className="absolute right-2 top-0 -translate-y-1/2 text-[11px] tabular-nums text-slate-400 dark:text-slate-500">
{fmtTime(h * 60)}
</span>
</div>
))}
</div>
{/* Day columns */}
<div
className="flex flex-1"
role="group"
aria-label="Time slots. Use arrow keys to move between slots, Enter to add an event."
>
{weekDates.map((date, day) => {
const isToday = sameDay(date, TODAY);
const dayEvents = weekEvents.filter((e) => e.day === day);
return (
<div
key={day}
className={`relative flex-1 border-l border-slate-200 dark:border-slate-800 ${
isToday ? "bg-indigo-50/40 dark:bg-indigo-500/[0.06]" : ""
}`}
>
{/* Slot buttons (background) */}
{HOURS.map((hour) => {
const isSel =
selected?.kind === "slot" &&
selected.day === day &&
selected.hour === hour;
const isFoc =
focusedCell.day === day && focusedCell.hour === hour;
return (
<button
key={hour}
type="button"
ref={(el) => {
cellRefs.current.set(`${day}-${hour}`, el);
}}
tabIndex={isFoc ? 0 : -1}
aria-label={`Add event on ${DAYS_FULL[day]}, ${MONTHS[date.getMonth()]} ${date.getDate()}, ${fmtTime(hour * 60)}`}
aria-pressed={isSel}
onClick={() => {
setSelected({ kind: "slot", day, hour });
setFocusedCell({ day, hour });
}}
onKeyDown={(e) => handleKey(e, day, hour)}
style={{ height: ROW_H }}
className={`group block w-full border-t border-slate-100 transition-colors dark:border-slate-800/70 ${
isSel
? "bg-indigo-100/70 dark:bg-indigo-500/20"
: "hover:bg-slate-100/80 dark:hover:bg-slate-800/60"
} ${RING} focus-visible:ring-offset-0`}
>
<span className="pointer-events-none flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100">
<Plus />
</span>
</button>
);
})}
{/* Event overlay */}
<div className="pointer-events-none absolute inset-0 z-10">
{dayEvents.map((ev) => {
const cal = CAL_MAP[ev.cal];
const top =
((ev.start - FIRST_HOUR * 60) / 60) * ROW_H;
const height = Math.max(
(ev.dur / 60) * ROW_H - 3,
22,
);
const isSel =
selected?.kind === "event" &&
selected.id === ev.id;
return (
<button
key={ev.id}
type="button"
onClick={() =>
setSelected({ kind: "event", id: ev.id })
}
aria-pressed={isSel}
aria-label={`${ev.title}, ${fmtTime(ev.start)} to ${fmtTime(ev.start + ev.dur)}, ${cal.label}${ev.who ? `, with ${ev.who}` : ""}`}
style={{ top, height }}
className={`pointer-events-auto absolute inset-x-1 overflow-hidden rounded-md border px-1.5 py-1 text-left transition-shadow ${cal.block} ${
isSel
? "ring-2 ring-slate-900/70 ring-offset-1 ring-offset-white dark:ring-white/70 dark:ring-offset-slate-900"
: "hover:shadow-md"
} ${RING} focus-visible:ring-offset-1`}
>
<span className="block truncate text-[11px] font-semibold leading-tight">
{ev.title}
</span>
{height > 32 && (
<span className="mt-0.5 block truncate text-[10px] opacity-70">
{fmtTime(ev.start)}
</span>
)}
</button>
);
})}
</div>
{/* Now indicator */}
{isToday && nowVisible && (
<div
className="pointer-events-none absolute inset-x-0 z-20"
style={{ top: nowTop }}
aria-hidden="true"
>
<div className="relative">
<span className="calw-nowdot absolute -left-0.5 -top-[3px] h-1.5 w-1.5 rounded-full bg-rose-500" />
<div className="h-px bg-rose-500" />
</div>
</div>
)}
</div>
);
})}
</div>
</motion.div>
</div>
</div>
</div>
{/* Side panel */}
<aside className="flex flex-col gap-4">
{/* Filters */}
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<h3 className="mb-3 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400 dark:text-slate-500">
Calendars
</h3>
<ul className="space-y-1">
{CAL_LIST.map((c) => {
const count = allWeek.filter((e) => e.cal === c.id).length;
return (
<li key={c.id}>
<label
className={`flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/60 ${
activeCal[c.id]
? "text-slate-700 dark:text-slate-200"
: "text-slate-400 dark:text-slate-500"
}`}
>
<input
type="checkbox"
checked={activeCal[c.id]}
onChange={() =>
setActiveCal((prev) => ({
...prev,
[c.id]: !prev[c.id],
}))
}
className={`h-4 w-4 rounded border-slate-300 accent-indigo-600 dark:border-slate-600 ${RING} focus-visible:ring-offset-1`}
/>
<span
className={`h-2.5 w-2.5 rounded-full ${c.dot} ${
activeCal[c.id] ? "" : "opacity-40"
}`}
aria-hidden="true"
/>
<span className="flex-1">{c.label}</span>
<span className="tabular-nums text-xs text-slate-400 dark:text-slate-500">
{count}
</span>
</label>
</li>
);
})}
</ul>
</div>
{/* Contextual card */}
<div className="min-h-[13rem] rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<AnimatePresence mode="wait" initial={false}>
{selectedEvent ? (
<motion.div key={`ev-${selectedEvent.id}`} {...panelMotion}>
<div className="mb-3 flex items-center justify-between">
<span className="inline-flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400 dark:text-slate-500">
<span
className={`h-2.5 w-2.5 rounded-full ${CAL_MAP[selectedEvent.cal].dot}`}
/>
{CAL_MAP[selectedEvent.cal].label}
</span>
<button
type="button"
onClick={() => setSelected(null)}
aria-label="Close details"
className={`inline-flex h-7 w-7 items-center justify-center rounded-md text-slate-400 hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-slate-800 dark:hover:text-slate-300 ${RING} focus-visible:ring-offset-1`}
>
<XIcon />
</button>
</div>
<h4 className="text-base font-semibold leading-snug">
{selectedEvent.title}
</h4>
<dl className="mt-3 space-y-2 text-sm text-slate-600 dark:text-slate-300">
<div className="flex items-center gap-2.5">
<ClockIcon />
<span>
{DAYS_FULL[selectedEvent.day]},{" "}
{MONTHS[weekDates[selectedEvent.day].getMonth()]}{" "}
{weekDates[selectedEvent.day].getDate()}
</span>
</div>
<div className="flex items-center gap-2.5">
<span className="w-4" aria-hidden="true" />
<span className="tabular-nums">
{fmtTime(selectedEvent.start)} –{" "}
{fmtTime(selectedEvent.start + selectedEvent.dur)}
</span>
</div>
{selectedEvent.who && (
<div className="flex items-center gap-2.5">
<UserIcon />
<span>{selectedEvent.who}</span>
</div>
)}
{selectedEvent.where && (
<div className="flex items-center gap-2.5">
<PinIcon />
<span>{selectedEvent.where}</span>
</div>
)}
</dl>
<div className="mt-4">
{selectedEvent.recurring ? (
<p className="inline-flex items-center gap-1.5 rounded-md bg-slate-100 px-2.5 py-1.5 text-xs text-slate-500 dark:bg-slate-800 dark:text-slate-400">
<RepeatIcon />
Repeats every weekday
</p>
) : (
<button
type="button"
onClick={() => removeEvent(selectedEvent.id)}
className={`inline-flex items-center gap-1.5 rounded-lg border border-rose-200 bg-rose-50 px-3 py-1.5 text-sm font-medium text-rose-700 transition-colors hover:bg-rose-100 dark:border-rose-400/30 dark:bg-rose-500/15 dark:text-rose-200 dark:hover:bg-rose-500/25 ${RING} focus-visible:ring-offset-1`}
>
<TrashIcon />
Remove event
</button>
)}
</div>
</motion.div>
) : selected?.kind === "slot" ? (
<motion.div
key={`slot-${selected.day}-${selected.hour}`}
{...panelMotion}
>
<h3 className="mb-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400 dark:text-slate-500">
New event
</h3>
<p className="mb-3 text-sm text-slate-600 dark:text-slate-300">
{DAYS_FULL[selected.day]} at {fmtTime(selected.hour * 60)}
</p>
<label
htmlFor="calw-title"
className="mb-1 block text-xs font-medium text-slate-500 dark:text-slate-400"
>
Title
</label>
<input
id="calw-title"
ref={composeRef}
value={composeTitle}
onChange={(e) => setComposeTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addEvent();
}
}}
placeholder="e.g. Roadmap sync"
className={`mb-3 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 dark:placeholder:text-slate-500 ${RING} focus-visible:ring-offset-1`}
/>
<label
htmlFor="calw-cal"
className="mb-1 block text-xs font-medium text-slate-500 dark:text-slate-400"
>
Calendar
</label>
<select
id="calw-cal"
value={composeCal}
onChange={(e) => setComposeCal(e.target.value as CalId)}
className={`mb-4 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 ${RING} focus-visible:ring-offset-1`}
>
{CAL_LIST.map((c) => (
<option key={c.id} value={c.id}>
{c.label}
</option>
))}
</select>
<div className="flex gap-2">
<button
type="button"
onClick={addEvent}
className={`inline-flex flex-1 items-center justify-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-500 ${RING} focus-visible:ring-offset-1`}
>
<Plus />
Add
</button>
<button
type="button"
onClick={() => setSelected(null)}
className={`rounded-lg border border-slate-200 px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800 ${RING} focus-visible:ring-offset-1`}
>
Cancel
</button>
</div>
</motion.div>
) : (
<motion.div key="hint" {...panelMotion}>
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400 dark:text-slate-500">
Getting around
</h3>
<ul className="space-y-2.5 text-sm text-slate-600 dark:text-slate-300">
<li className="flex gap-2.5">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-indigo-500" />
Click any empty slot to schedule a new event.
</li>
<li className="flex gap-2.5">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-violet-500" />
Select an event block to see who and where.
</li>
<li className="flex gap-2.5">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-emerald-500" />
Use the arrow keys to move between time slots.
</li>
</ul>
<div className="mt-4 rounded-lg bg-slate-50 px-3 py-2.5 text-xs text-slate-500 dark:bg-slate-800/60 dark:text-slate-400">
Showing{" "}
<span className="font-semibold text-slate-700 dark:text-slate-200">
{weekEvents.length}
</span>{" "}
events across {weekLabel}.
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</aside>
</div>
</div>
</section>
);
}
function Chevron({ dir }: { dir: "left" | "right" }) {
return (
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d={dir === "left" ? "M15 6l-6 6 6 6" : "M9 6l6 6-6 6"} />
</svg>
);
}
function Plus() {
return (
<svg
viewBox="0 0 24 24"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 5v14M5 12h14" />
</svg>
);
}
function CalIcon() {
return (
<svg
viewBox="0 0 24 24"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" />
</svg>
);
}
function ClockIcon() {
return (
<svg
viewBox="0 0 24 24"
className="h-4 w-4 shrink-0 text-slate-400 dark:text-slate-500"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</svg>
);
}
function UserIcon() {
return (
<svg
viewBox="0 0 24 24"
className="h-4 w-4 shrink-0 text-slate-400 dark:text-slate-500"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
);
}
function PinIcon() {
return (
<svg
viewBox="0 0 24 24"
className="h-4 w-4 shrink-0 text-slate-400 dark:text-slate-500"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
<circle cx="12" cy="10" r="3" />
</svg>
);
}
function RepeatIcon() {
return (
<svg
viewBox="0 0 24 24"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M17 2l4 4-4 4" />
<path d="M3 11v-1a4 4 0 0 1 4-4h14" />
<path d="M7 22l-4-4 4-4" />
<path d="M21 13v1a4 4 0 0 1-4 4H3" />
</svg>
);
}
function TrashIcon() {
return (
<svg
viewBox="0 0 24 24"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m2 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
<path d="M10 11v6M14 11v6" />
</svg>
);
}
function XIcon() {
return (
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
);
}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 Mini
Originalcompact mini calendar widget

Calendar Time Picker
Originaltime picker with hour/minute

Calendar Datetime
Originalcombined date and time picker

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

