All Taste components
Productivity
Calendar week view
A working week laid out by hour, with events, today highlighted and a current-time line.
5-day week · hour grid · 3 event tones · today · now line · week navigation
12 – 16 October 2026
Mon12
Tue13
Wed14
Thu15
Fri16
Weekly planning10:00 – 11:00
Client call · Kaveri Foods14:00 – 15:30
Stand-up
Lunch with Arjun12:00 – 13:00
Menu photo shoot15:00 – 17:00
Stand-up
Design review11:00 – 12:30
Stand-up
Invoice run13:00 – 14:00
1:1 with Ritika
Pricing workshop10:00 – 12:00
Payroll15:00 – 16:00
import { ChevronLeft, ChevronRight, Plus } from "lucide-react";
import { cn } from "@/lib/utils";
export interface CalEvent { id: string; day: number; start: number; end: number; title: string; tone?: "primary" | "info" | "muted" }
const HOUR = 40;
const FIRST = 9;
const LAST = 17;
const sample: CalEvent[] = [
{ id: "1", day: 0, start: 10, end: 11, title: "Weekly planning" },
{ id: "2", day: 0, start: 14, end: 15.5, title: "Client call · Kaveri Foods", tone: "info" },
{ id: "3", day: 1, start: 15, end: 17, title: "Menu photo shoot" },
{ id: "4", day: 2, start: 11, end: 12.5, title: "Design review" },
{ id: "5", day: 3, start: 13, end: 14, title: "Invoice run", tone: "info" },
{ id: "6", day: 4, start: 10, end: 12, title: "Pricing workshop" },
...[0, 1, 2, 3].map((d) => ({ id: "s" + d, day: d, start: 9.5, end: 10, title: "Stand-up", tone: "muted" as const })),
];
const tones = {
primary: "border-primary bg-primary-soft text-primary-soft-fg",
info: "border-info bg-info-soft text-info-fg",
muted: "border-border-strong bg-surface-2 text-fg-muted",
};
const fmt = (h: number) => Math.floor(h) + ":" + (h % 1 ? "30" : "00");
const hourLabel = (h: number) => (h > 12 ? h - 12 : h) + (h >= 12 ? " PM" : " AM");
export function WeekCalendar({ weekStart = new Date(2026, 9, 12), events = sample, now = new Date(2026, 9, 15, 11, 20), onNew, onPrev, onNext, onToday }: {
weekStart?: Date; events?: CalEvent[]; now?: Date;
onNew?: () => void; onPrev?: () => void; onNext?: () => void; onToday?: () => void;
}) {
const days = Array.from({ length: 5 }, (_, i) => new Date(weekStart.getFullYear(), weekStart.getMonth(), weekStart.getDate() + i));
const sameDay = (a: Date, b: Date) => a.toDateString() === b.toDateString();
const nowH = now.getHours() + now.getMinutes() / 60;
const range = days[0].getDate() + " – " + days[4].toLocaleDateString("en-IN", { day: "numeric", month: "long", year: "numeric" });
return (
<section className="w-full overflow-hidden rounded-lg border border-border bg-bg">
<header className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
<h3 className="text-[15px] font-medium">{range}</h3>
<div className="flex items-center gap-1.5">
<button onClick={onToday} className="h-[30px] rounded border border-border-strong px-2.5 text-[13px] font-medium hover:bg-surface">Today</button>
<button onClick={onPrev} aria-label="Previous week" className="grid size-[30px] place-items-center rounded border border-border-strong hover:bg-surface"><ChevronLeft className="size-3.5" /></button>
<button onClick={onNext} aria-label="Next week" className="grid size-[30px] place-items-center rounded border border-border-strong hover:bg-surface"><ChevronRight className="size-3.5" /></button>
<button onClick={onNew} className="inline-flex h-[30px] items-center gap-1.5 rounded bg-primary px-2.5 text-[13px] font-medium text-primary-fg hover:bg-primary-hover">
<Plus className="size-3.5" aria-hidden /> New event
</button>
</div>
</header>
<div className="overflow-x-auto border-t border-border">
<div className="grid min-w-[560px] grid-cols-[52px_repeat(5,minmax(100px,1fr))]">
<div className="border-b border-border" />
{days.map((d) => (
<div key={d.toISOString()} className={cn("border-b border-l border-border px-2 pb-2.5 pt-2 text-xs text-fg-subtle", sameDay(d, now) && "text-primary")}>
{d.toLocaleDateString("en-IN", { weekday: "short" })}
<span className={cn("mt-0.5 block text-lg font-medium tabular-nums", sameDay(d, now) ? "text-primary" : "text-fg")}>{d.getDate()}</span>
</div>
))}
<div className="relative" style={{ height: (LAST - FIRST) * HOUR }} aria-hidden>
{Array.from({ length: LAST - FIRST - 1 }, (_, i) => FIRST + 1 + i).map((h) => (
<span key={h} className="absolute right-2 -translate-y-1/2 whitespace-nowrap text-[11px] tabular-nums text-fg-subtle" style={{ top: (h - FIRST) * HOUR }}>{hourLabel(h)}</span>
))}
</div>
{days.map((d, di) => (
<div key={d.toISOString()} className={cn("relative border-l border-border bg-[repeating-linear-gradient(to_bottom,var(--tst-border)_0_1px,transparent_1px_40px)]", sameDay(d, now) && "bg-surface")}
style={{ height: (LAST - FIRST) * HOUR }}>
{events.filter((e) => e.day === di).map((e) => {
const long = e.end - e.start >= 1;
return (
<div key={e.id} className={cn("absolute inset-x-1 overflow-hidden rounded-sm border-l-[3px] px-1.5 text-[11.5px] leading-tight", long ? "py-1" : "py-px", tones[e.tone ?? "primary"])}
style={{ top: (e.start - FIRST) * HOUR + 1, height: (e.end - e.start) * HOUR - 2 }}>
<p className="truncate font-medium">{e.title}</p>
{long && <p className="tabular-nums">{fmt(e.start)} – {fmt(e.end)}</p>}
</div>
);
})}
{sameDay(d, now) && nowH >= FIRST && nowH <= LAST && (
<span className="absolute inset-x-0 z-10 h-0.5 bg-danger before:absolute before:-left-1 before:-top-[3px] before:size-2 before:rounded-full before:bg-danger"
style={{ top: (nowH - FIRST) * HOUR }} aria-label="Current time" />
)}
</div>
))}
</div>
</div>
</section>
);
}
The code uses the Taste Tailwind preset. Make yours on the Taste page with Export my system.