Web InnoventixFreeCode

Calendar Week

Original · free

week view with time slots

byWeb InnoventixReact + Tailwind
calendarweekcalendars
Open

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.json
calendar-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 quote

Similar components

Browse all →