Web InnoventixFreeCode

Calendar Event

Original · free

month calendar with event dots

byWeb InnoventixReact + Tailwind
calendareventcalendars
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-event.json
calendar-event.tsx
"use client";

import {
  useEffect,
  useId,
  useMemo,
  useRef,
  useState,
  type FormEvent,
  type KeyboardEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type CategoryKey = "work" | "personal" | "health" | "social" | "travel";

type EventItem = {
  id: string;
  title: string;
  time: string; // "HH:MM" 24h
  category: CategoryKey;
};

type CategoryMeta = {
  key: CategoryKey;
  label: string;
  dot: string;
  badge: string;
};

const CATEGORIES: readonly CategoryMeta[] = [
  {
    key: "work",
    label: "Work",
    dot: "bg-indigo-500",
    badge:
      "bg-indigo-50 text-indigo-700 ring-indigo-200 dark:bg-indigo-400/10 dark:text-indigo-300 dark:ring-indigo-400/30",
  },
  {
    key: "personal",
    label: "Personal",
    dot: "bg-emerald-500",
    badge:
      "bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-400/10 dark:text-emerald-300 dark:ring-emerald-400/30",
  },
  {
    key: "health",
    label: "Health",
    dot: "bg-rose-500",
    badge:
      "bg-rose-50 text-rose-700 ring-rose-200 dark:bg-rose-400/10 dark:text-rose-300 dark:ring-rose-400/30",
  },
  {
    key: "social",
    label: "Social",
    dot: "bg-amber-500",
    badge:
      "bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-400/10 dark:text-amber-300 dark:ring-amber-400/30",
  },
  {
    key: "travel",
    label: "Travel",
    dot: "bg-sky-500",
    badge:
      "bg-sky-50 text-sky-700 ring-sky-200 dark:bg-sky-400/10 dark:text-sky-300 dark:ring-sky-400/30",
  },
] as const;

const META_BY_KEY: Record<CategoryKey, CategoryMeta> = CATEGORIES.reduce(
  (acc, c) => {
    acc[c.key] = c;
    return acc;
  },
  {} as Record<CategoryKey, CategoryMeta>,
);

const WEEKDAYS = [
  { short: "Sun", long: "Sunday" },
  { short: "Mon", long: "Monday" },
  { short: "Tue", long: "Tuesday" },
  { short: "Wed", long: "Wednesday" },
  { short: "Thu", long: "Thursday" },
  { short: "Fri", long: "Friday" },
  { short: "Sat", long: "Saturday" },
] as const;

const MONTHS = [
  "January",
  "February",
  "March",
  "April",
  "May",
  "June",
  "July",
  "August",
  "September",
  "October",
  "November",
  "December",
] as const;

const TODAY_ISO = "2026-07-16";

const pad = (n: number): string => String(n).padStart(2, "0");

const isoOf = (d: Date): string =>
  `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;

const parseISO = (s: string): Date => {
  const [y, m, d] = s.split("-").map(Number);
  return new Date(y, m - 1, d);
};

const daysInMonth = (y: number, m: number): number =>
  new Date(y, m + 1, 0).getDate();

const formatFull = (d: Date): string =>
  `${WEEKDAYS[d.getDay()].long}, ${MONTHS[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;

const formatTime = (t: string): string => {
  const [h, m] = t.split(":").map(Number);
  const ap = h >= 12 ? "PM" : "AM";
  const hr = h % 12 === 0 ? 12 : h % 12;
  return `${hr}:${pad(m)} ${ap}`;
};

function buildMonth(year: number, month: number): Date[][] {
  const first = new Date(year, month, 1);
  const cursor = new Date(year, month, 1 - first.getDay());
  const weeks: Date[][] = [];
  for (let w = 0; w < 6; w++) {
    const days: Date[] = [];
    for (let d = 0; d < 7; d++) {
      days.push(new Date(cursor));
      cursor.setDate(cursor.getDate() + 1);
    }
    weeks.push(days);
  }
  return weeks;
}

function seedEvents(): Record<string, EventItem[]> {
  const raw: Record<string, Omit<EventItem, "id">[]> = {
    "2026-06-30": [{ title: "Q2 invoices due", time: "17:00", category: "work" }],
    "2026-07-02": [
      { title: "Design sync with Priya", time: "10:00", category: "work" },
    ],
    "2026-07-03": [
      { title: "Dentist — Dr. Okafor", time: "15:30", category: "health" },
    ],
    "2026-07-04": [
      { title: "Rooftop BBQ at Marco's", time: "18:00", category: "social" },
    ],
    "2026-07-06": [
      { title: "Q3 roadmap review", time: "09:00", category: "work" },
      { title: "Vinyasa yoga", time: "19:00", category: "health" },
    ],
    "2026-07-09": [
      { title: "Flight to Lisbon · TP204", time: "13:15", category: "travel" },
    ],
    "2026-07-10": [
      { title: "Belém pastéis tour", time: "11:00", category: "travel" },
    ],
    "2026-07-13": [
      { title: "Alfama sunset walk", time: "19:30", category: "travel" },
    ],
    "2026-07-14": [
      { title: "Return flight · TP205", time: "08:40", category: "travel" },
    ],
    "2026-07-16": [
      { title: "1:1 with Salman", time: "11:00", category: "work" },
      { title: "Grocery run", time: "18:30", category: "personal" },
    ],
    "2026-07-17": [
      { title: "Mom's birthday dinner", time: "19:30", category: "personal" },
    ],
    "2026-07-20": [
      { title: "Sprint planning", time: "09:30", category: "work" },
    ],
    "2026-07-22": [
      { title: "5K morning run", time: "06:30", category: "health" },
    ],
    "2026-07-24": [
      { title: "Ship Web Innoventix v2", time: "16:00", category: "work" },
      { title: "Team happy hour", time: "17:30", category: "social" },
    ],
    "2026-07-25": [
      { title: "Farmers market", time: "09:00", category: "personal" },
    ],
    "2026-07-27": [
      { title: "Client demo — Acme Corp", time: "14:00", category: "work" },
    ],
    "2026-07-29": [
      { title: "Vinyasa yoga", time: "19:00", category: "health" },
    ],
    "2026-07-31": [
      { title: "Month-end review", time: "16:00", category: "work" },
      { title: "Movie night · Dune 3", time: "20:00", category: "social" },
    ],
    "2026-08-01": [
      { title: "Camping — Big Sur", time: "08:00", category: "travel" },
    ],
  };
  const out: Record<string, EventItem[]> = {};
  for (const iso of Object.keys(raw)) {
    out[iso] = raw[iso].map((e, i) => ({ ...e, id: `${iso}#s${i}` }));
  }
  return out;
}

export default function CalendarEvent() {
  const reduce = useReducedMotion();
  const uid = useId();
  const headingId = `${uid}-heading`;
  const panelId = `${uid}-panel`;

  const today = useMemo(() => parseISO(TODAY_ISO), []);

  const [view, setView] = useState<{ y: number; m: number }>({
    y: today.getFullYear(),
    m: today.getMonth(),
  });
  const [selected, setSelected] = useState<string>(TODAY_ISO);
  const [focused, setFocused] = useState<string>(TODAY_ISO);
  const [events, setEvents] = useState<Record<string, EventItem[]>>(seedEvents);
  const [activeCats, setActiveCats] = useState<Set<CategoryKey>>(
    () => new Set(CATEGORIES.map((c) => c.key)),
  );

  const [showForm, setShowForm] = useState(false);
  const [formTitle, setFormTitle] = useState("");
  const [formTime, setFormTime] = useState("09:00");
  const [formCat, setFormCat] = useState<CategoryKey>("work");

  const dayRefs = useRef<Record<string, HTMLButtonElement | null>>({});
  const shouldFocusRef = useRef(false);
  const idRef = useRef(0);

  useEffect(() => {
    if (shouldFocusRef.current) {
      const el = dayRefs.current[focused];
      if (el) el.focus();
      shouldFocusRef.current = false;
    }
  }, [focused, view]);

  const weeks = useMemo(() => buildMonth(view.y, view.m), [view]);

  const visibleOf = useMemo(
    () =>
      (iso: string): EventItem[] => {
        const list = events[iso];
        if (!list) return [];
        return list
          .filter((e) => activeCats.has(e.category))
          .slice()
          .sort((a, b) => a.time.localeCompare(b.time));
      },
    [events, activeCats],
  );

  const monthCount = useMemo(() => {
    let c = 0;
    for (const iso of Object.keys(events)) {
      const d = parseISO(iso);
      if (d.getFullYear() === view.y && d.getMonth() === view.m) {
        c += visibleOf(iso).length;
      }
    }
    return c;
  }, [events, view, visibleOf]);

  const goMonth = (delta: number) => {
    const base = new Date(view.y, view.m + delta, 1);
    const ny = base.getFullYear();
    const nm = base.getMonth();
    const fd = parseISO(focused);
    const day = Math.min(fd.getDate(), daysInMonth(ny, nm));
    setView({ y: ny, m: nm });
    setFocused(isoOf(new Date(ny, nm, day)));
  };

  const goToday = () => {
    setView({ y: today.getFullYear(), m: today.getMonth() });
    setSelected(TODAY_ISO);
    setFocused(TODAY_ISO);
    shouldFocusRef.current = true;
  };

  const pickDate = (d: Date) => {
    const iso = isoOf(d);
    setView({ y: d.getFullYear(), m: d.getMonth() });
    setSelected(iso);
    setFocused(iso);
    shouldFocusRef.current = true;
  };

  const moveFocus = (d: Date) => {
    setView({ y: d.getFullYear(), m: d.getMonth() });
    setFocused(isoOf(d));
    shouldFocusRef.current = true;
  };

  const onDayKeyDown = (e: KeyboardEvent<HTMLButtonElement>, iso: string) => {
    const d = parseISO(iso);
    const y = d.getFullYear();
    const m = d.getMonth();
    const day = d.getDate();
    let next: Date | null = null;
    switch (e.key) {
      case "ArrowLeft":
        next = new Date(y, m, day - 1);
        break;
      case "ArrowRight":
        next = new Date(y, m, day + 1);
        break;
      case "ArrowUp":
        next = new Date(y, m, day - 7);
        break;
      case "ArrowDown":
        next = new Date(y, m, day + 7);
        break;
      case "Home":
        next = new Date(y, m, day - d.getDay());
        break;
      case "End":
        next = new Date(y, m, day + (6 - d.getDay()));
        break;
      case "PageUp":
        next = new Date(y, m - 1, day);
        break;
      case "PageDown":
        next = new Date(y, m + 1, day);
        break;
      case "Enter":
      case " ":
        e.preventDefault();
        setSelected(iso);
        return;
      default:
        return;
    }
    e.preventDefault();
    if (next) moveFocus(next);
  };

  const toggleCat = (key: CategoryKey) => {
    setActiveCats((prev) => {
      const nextSet = new Set(prev);
      if (nextSet.has(key)) nextSet.delete(key);
      else nextSet.add(key);
      return nextSet;
    });
  };

  const removeEvent = (iso: string, id: string) => {
    setEvents((prev) => {
      const list = prev[iso];
      if (!list) return prev;
      const filtered = list.filter((e) => e.id !== id);
      const nextEvents = { ...prev };
      if (filtered.length) nextEvents[iso] = filtered;
      else delete nextEvents[iso];
      return nextEvents;
    });
  };

  const submitEvent = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const title = formTitle.trim();
    if (!title) return;
    const item: EventItem = {
      id: `${selected}#n${idRef.current++}`,
      title,
      time: formTime || "09:00",
      category: formCat,
    };
    setEvents((prev) => {
      const list = prev[selected] ? [...prev[selected]] : [];
      return { ...prev, [selected]: [...list, item] };
    });
    setFormTitle("");
    setFormTime("09:00");
    setFormCat("work");
    setShowForm(false);
  };

  const selectedDate = parseISO(selected);
  const selectedEvents = visibleOf(selected);

  return (
    <section className="relative w-full bg-slate-50 px-4 py-12 dark:bg-slate-950 sm:py-16">
      <style>{`
        @keyframes cev-pop {
          from { opacity: 0; transform: translateY(4px) scale(.995); }
          to { opacity: 1; transform: none; }
        }
        @keyframes cev-pulse {
          0%, 100% { opacity: 1; transform: scale(1); }
          50% { opacity: .45; transform: scale(.7); }
        }
        .cev-grid { animation: cev-pop .28s cubic-bezier(0.22,1,0.36,1) both; }
        .cev-pulse-dot { animation: cev-pulse 2.4s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .cev-grid, .cev-pulse-dot { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-6xl">
        <div className="mb-6 flex flex-col gap-1">
          <span className="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
            <svg
              viewBox="0 0 24 24"
              className="h-4 w-4"
              fill="none"
              stroke="currentColor"
              strokeWidth="2"
              aria-hidden="true"
            >
              <rect x="3" y="4" width="18" height="17" rx="2" />
              <path d="M3 9h18M8 2v4M16 2v4" strokeLinecap="round" />
            </svg>
            Schedule
          </span>
          <h2 className="text-2xl font-bold tracking-tight text-slate-900 dark:text-slate-50 sm:text-3xl">
            Team calendar
          </h2>
        </div>

        <div className="grid gap-6 lg:grid-cols-[1.65fr_1fr]">
          {/* Calendar card */}
          <div className="rounded-3xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-6">
            {/* Toolbar */}
            <div className="mb-4 flex flex-wrap items-center justify-between gap-3">
              <div>
                <h3
                  id={headingId}
                  aria-live="polite"
                  className="text-lg font-semibold text-slate-900 dark:text-slate-50"
                >
                  {MONTHS[view.m]} {view.y}
                </h3>
                <p className="text-sm text-slate-500 dark:text-slate-400">
                  {monthCount} {monthCount === 1 ? "event" : "events"} this month
                </p>
              </div>

              <div className="flex items-center gap-2">
                <button
                  type="button"
                  onClick={goToday}
                  className="rounded-full border border-slate-200 px-3.5 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
                >
                  Today
                </button>
                <div className="flex items-center gap-1">
                  <button
                    type="button"
                    onClick={() => goMonth(-1)}
                    aria-label="Previous month"
                    className="grid h-9 w-9 place-items-center rounded-full border border-slate-200 text-slate-600 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
                  >
                    <svg
                      viewBox="0 0 24 24"
                      className="h-5 w-5"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth="2"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      aria-hidden="true"
                    >
                      <path d="M15 18l-6-6 6-6" />
                    </svg>
                  </button>
                  <button
                    type="button"
                    onClick={() => goMonth(1)}
                    aria-label="Next month"
                    className="grid h-9 w-9 place-items-center rounded-full border border-slate-200 text-slate-600 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
                  >
                    <svg
                      viewBox="0 0 24 24"
                      className="h-5 w-5"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth="2"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      aria-hidden="true"
                    >
                      <path d="M9 18l6-6-6-6" />
                    </svg>
                  </button>
                </div>
              </div>
            </div>

            {/* Grid */}
            <div role="grid" aria-labelledby={headingId} className="select-none">
              <div role="row" className="grid grid-cols-7">
                {WEEKDAYS.map((w) => (
                  <div
                    key={w.short}
                    role="columnheader"
                    className="pb-2 text-center text-[0.7rem] font-semibold uppercase tracking-wide text-slate-400 dark:text-slate-500"
                  >
                    <abbr title={w.long} className="no-underline">
                      {w.short}
                    </abbr>
                  </div>
                ))}
              </div>

              <div
                key={`${view.y}-${view.m}`}
                className="cev-grid grid grid-rows-6 gap-1"
              >
                {weeks.map((week, wi) => (
                  <div role="row" key={wi} className="grid grid-cols-7 gap-1">
                    {week.map((d) => {
                      const iso = isoOf(d);
                      const inMonth = d.getMonth() === view.m;
                      const isToday = iso === TODAY_ISO;
                      const isSelected = iso === selected;
                      const isFocusTarget = iso === focused && inMonth;
                      const dayEvents = visibleOf(iso);
                      const count = dayEvents.length;
                      const label = `${formatFull(d)}${
                        isToday ? ", Today" : ""
                      }${
                        count > 0
                          ? `, ${count} ${count === 1 ? "event" : "events"}`
                          : ", no events"
                      }`;

                      return (
                        <div
                          role="gridcell"
                          key={iso}
                          aria-selected={isSelected}
                          className="min-w-0"
                        >
                          <button
                            type="button"
                            ref={(el) => {
                              dayRefs.current[iso] = el;
                            }}
                            tabIndex={isFocusTarget ? 0 : -1}
                            onClick={() => pickDate(d)}
                            onKeyDown={(e) => onDayKeyDown(e, iso)}
                            aria-label={label}
                            aria-current={isToday ? "date" : undefined}
                            className={[
                              "group relative flex h-14 w-full flex-col items-center justify-start rounded-xl px-1 pt-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 sm:h-16",
                              isSelected
                                ? "bg-indigo-600 text-white shadow-sm dark:bg-indigo-500"
                                : inMonth
                                  ? "text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800"
                                  : "text-slate-300 hover:bg-slate-50 dark:text-slate-600 dark:hover:bg-slate-800/50",
                              !isSelected && isToday
                                ? "ring-1 ring-inset ring-indigo-300 dark:ring-indigo-500/50"
                                : "",
                            ].join(" ")}
                          >
                            <span
                              className={[
                                "grid h-6 w-6 place-items-center rounded-full text-[0.8rem] font-medium tabular-nums",
                                isToday && !isSelected
                                  ? "text-indigo-600 dark:text-indigo-300"
                                  : "",
                              ].join(" ")}
                            >
                              {d.getDate()}
                            </span>

                            <span
                              aria-hidden="true"
                              className="mt-0.5 flex h-2 items-center justify-center gap-[3px]"
                            >
                              {dayEvents.slice(0, 3).map((ev) => (
                                <span
                                  key={ev.id}
                                  className={[
                                    "h-1.5 w-1.5 rounded-full",
                                    isSelected
                                      ? "bg-white/90"
                                      : META_BY_KEY[ev.category].dot,
                                    isToday && !isSelected
                                      ? "cev-pulse-dot"
                                      : "",
                                  ].join(" ")}
                                />
                              ))}
                              {count > 3 && (
                                <span
                                  className={[
                                    "ml-0.5 text-[0.6rem] font-semibold leading-none",
                                    isSelected
                                      ? "text-white/90"
                                      : "text-slate-400 dark:text-slate-500",
                                  ].join(" ")}
                                >
                                  +{count - 3}
                                </span>
                              )}
                            </span>
                          </button>
                        </div>
                      );
                    })}
                  </div>
                ))}
              </div>
            </div>

            {/* Legend / filters */}
            <div className="mt-5 flex flex-wrap items-center gap-2 border-t border-slate-100 pt-4 dark:border-slate-800">
              <span className="mr-1 text-xs font-medium text-slate-400 dark:text-slate-500">
                Filter
              </span>
              {CATEGORIES.map((c) => {
                const on = activeCats.has(c.key);
                return (
                  <button
                    key={c.key}
                    type="button"
                    aria-pressed={on}
                    onClick={() => toggleCat(c.key)}
                    className={[
                      "inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium transition-all 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",
                      on
                        ? "border-slate-200 bg-white text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200"
                        : "border-transparent bg-slate-100 text-slate-400 line-through dark:bg-slate-800/50 dark:text-slate-600",
                    ].join(" ")}
                  >
                    <span
                      className={[
                        "h-2 w-2 rounded-full",
                        on ? c.dot : "bg-slate-300 dark:bg-slate-600",
                      ].join(" ")}
                      aria-hidden="true"
                    />
                    {c.label}
                  </button>
                );
              })}
            </div>
          </div>

          {/* Side panel */}
          <aside
            id={panelId}
            aria-label="Events for selected day"
            className="flex flex-col rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-6"
          >
            <div className="flex items-baseline justify-between gap-3">
              <div>
                <p className="text-xs font-semibold uppercase tracking-wide text-indigo-600 dark:text-indigo-400">
                  {WEEKDAYS[selectedDate.getDay()].long}
                </p>
                <p className="text-xl font-bold text-slate-900 dark:text-slate-50">
                  {MONTHS[selectedDate.getMonth()]} {selectedDate.getDate()}
                </p>
              </div>
              <span className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-300">
                {selectedEvents.length}{" "}
                {selectedEvents.length === 1 ? "event" : "events"}
              </span>
            </div>

            <div className="mt-4 min-h-[8rem] flex-1">
              <AnimatePresence mode="wait">
                <motion.ul
                  key={selected}
                  initial={reduce ? false : { opacity: 0, y: 8 }}
                  animate={reduce ? {} : { opacity: 1, y: 0 }}
                  exit={reduce ? {} : { opacity: 0, y: -8 }}
                  transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
                  className="space-y-2"
                >
                  {selectedEvents.length === 0 ? (
                    <li className="flex flex-col items-center justify-center rounded-2xl border border-dashed border-slate-200 py-10 text-center dark:border-slate-700">
                      <svg
                        viewBox="0 0 24 24"
                        className="mb-2 h-8 w-8 text-slate-300 dark:text-slate-600"
                        fill="none"
                        stroke="currentColor"
                        strokeWidth="1.6"
                        aria-hidden="true"
                      >
                        <rect x="3" y="4" width="18" height="17" rx="2" />
                        <path d="M3 9h18M8 2v4M16 2v4" strokeLinecap="round" />
                      </svg>
                      <p className="text-sm text-slate-500 dark:text-slate-400">
                        Nothing scheduled.
                      </p>
                    </li>
                  ) : (
                    selectedEvents.map((ev) => {
                      const meta = META_BY_KEY[ev.category];
                      return (
                        <li
                          key={ev.id}
                          className="flex items-start gap-3 rounded-2xl border border-slate-200 bg-slate-50/70 p-3 dark:border-slate-800 dark:bg-slate-800/40"
                        >
                          <span
                            className={`mt-1 h-10 w-1 shrink-0 rounded-full ${meta.dot}`}
                            aria-hidden="true"
                          />
                          <div className="min-w-0 flex-1">
                            <div className="flex items-start justify-between gap-2">
                              <p className="truncate font-medium text-slate-900 dark:text-slate-100">
                                {ev.title}
                              </p>
                              <button
                                type="button"
                                onClick={() => removeEvent(selected, ev.id)}
                                aria-label={`Remove ${ev.title}`}
                                className="-mr-1 -mt-1 grid h-7 w-7 shrink-0 place-items-center rounded-lg text-slate-400 transition-colors hover:bg-rose-50 hover:text-rose-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-500 dark:hover:bg-rose-500/10 dark:hover:text-rose-400"
                              >
                                <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="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6" />
                                </svg>
                              </button>
                            </div>
                            <div className="mt-1.5 flex items-center gap-2">
                              <span className="inline-flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400">
                                <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"
                                >
                                  <circle cx="12" cy="12" r="9" />
                                  <path d="M12 7v5l3 2" />
                                </svg>
                                {formatTime(ev.time)}
                              </span>
                              <span
                                className={`inline-flex items-center rounded-full px-2 py-0.5 text-[0.65rem] font-semibold uppercase tracking-wide ring-1 ring-inset ${meta.badge}`}
                              >
                                {meta.label}
                              </span>
                            </div>
                          </div>
                        </li>
                      );
                    })
                  )}
                </motion.ul>
              </AnimatePresence>
            </div>

            {/* Add event */}
            <div className="mt-4 border-t border-slate-100 pt-4 dark:border-slate-800">
              {!showForm ? (
                <button
                  type="button"
                  onClick={() => setShowForm(true)}
                  aria-expanded={showForm}
                  className="flex w-full items-center justify-center gap-2 rounded-2xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-indigo-500 dark:hover:bg-indigo-400 dark:focus-visible:ring-offset-slate-900"
                >
                  <svg
                    viewBox="0 0 24 24"
                    className="h-4 w-4"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth="2.5"
                    strokeLinecap="round"
                    aria-hidden="true"
                  >
                    <path d="M12 5v14M5 12h14" />
                  </svg>
                  New event
                </button>
              ) : (
                <form onSubmit={submitEvent} className="space-y-3">
                  <p className="text-sm font-medium text-slate-700 dark:text-slate-300">
                    Add to {MONTHS[selectedDate.getMonth()].slice(0, 3)}{" "}
                    {selectedDate.getDate()}
                  </p>
                  <div>
                    <label
                      htmlFor={`${uid}-title`}
                      className="mb-1 block text-xs font-medium text-slate-500 dark:text-slate-400"
                    >
                      Title
                    </label>
                    <input
                      id={`${uid}-title`}
                      type="text"
                      required
                      value={formTitle}
                      onChange={(e) => setFormTitle(e.target.value)}
                      placeholder="Coffee with Dana"
                      className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 dark:placeholder:text-slate-500"
                    />
                  </div>
                  <div className="grid grid-cols-2 gap-3">
                    <div>
                      <label
                        htmlFor={`${uid}-time`}
                        className="mb-1 block text-xs font-medium text-slate-500 dark:text-slate-400"
                      >
                        Time
                      </label>
                      <input
                        id={`${uid}-time`}
                        type="time"
                        value={formTime}
                        onChange={(e) => setFormTime(e.target.value)}
                        className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100 [color-scheme:light] dark:[color-scheme:dark]"
                      />
                    </div>
                    <div>
                      <label
                        htmlFor={`${uid}-cat`}
                        className="mb-1 block text-xs font-medium text-slate-500 dark:text-slate-400"
                      >
                        Category
                      </label>
                      <select
                        id={`${uid}-cat`}
                        value={formCat}
                        onChange={(e) =>
                          setFormCat(e.target.value as CategoryKey)
                        }
                        className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
                      >
                        {CATEGORIES.map((c) => (
                          <option key={c.key} value={c.key}>
                            {c.label}
                          </option>
                        ))}
                      </select>
                    </div>
                  </div>
                  <div className="flex gap-2 pt-1">
                    <button
                      type="submit"
                      className="flex-1 rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-indigo-500 dark:hover:bg-indigo-400 dark:focus-visible:ring-offset-slate-900"
                    >
                      Save
                    </button>
                    <button
                      type="button"
                      onClick={() => {
                        setShowForm(false);
                        setFormTitle("");
                      }}
                      className="rounded-xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
                    >
                      Cancel
                    </button>
                  </div>
                </form>
              )}
            </div>
          </aside>
        </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 quote

Similar components

Browse all →