Skip to content
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.