Web InnoventixFreeCode

Calendar Mini

Original · free

compact mini calendar widget

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

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

type DateParts = { year: number; month: number; day: number };
type EventTag = "work" | "focus" | "personal";
type CalEvent = { time: string; title: string; tag: EventTag };
type DayCell = {
  key: string;
  year: number;
  month: number;
  day: number;
  inMonth: boolean;
  isToday: boolean;
};

const TODAY: DateParts = { year: 2026, month: 6, day: 16 };

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

const WEEKDAYS_LONG = [
  "Monday",
  "Tuesday",
  "Wednesday",
  "Thursday",
  "Friday",
  "Saturday",
  "Sunday",
];

const WEEKDAYS_SHORT = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];

const TAG_STYLES: Record<
  EventTag,
  { label: string; dot: string; chip: string }
> = {
  work: {
    label: "Work",
    dot: "bg-indigo-500",
    chip:
      "bg-indigo-50 text-indigo-700 ring-indigo-200 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-500/30",
  },
  focus: {
    label: "Focus",
    dot: "bg-amber-500",
    chip:
      "bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-500/30",
  },
  personal: {
    label: "Personal",
    dot: "bg-emerald-500",
    chip:
      "bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-500/30",
  },
};

const EVENTS: Record<string, CalEvent[]> = {
  "2026-06-29": [{ time: "10:00", title: "Retro — June cycle", tag: "work" }],
  "2026-07-02": [
    { time: "09:30", title: "Standup — Platform squad", tag: "work" },
  ],
  "2026-07-03": [
    { time: "10:00", title: "Design review: Aurora dashboard", tag: "work" },
    { time: "15:30", title: "Deep work: pricing model", tag: "focus" },
  ],
  "2026-07-08": [{ time: "14:30", title: "1:1 with Marcus Bell", tag: "work" }],
  "2026-07-10": [{ time: "18:00", title: "Yoga with Dana", tag: "personal" }],
  "2026-07-14": [
    { time: "09:15", title: "Sprint planning", tag: "work" },
    { time: "16:00", title: "Coffee with Priya Nair", tag: "personal" },
  ],
  "2026-07-16": [
    { time: "11:00", title: "Product sync — v2.4 scope", tag: "work" },
    { time: "15:00", title: "Ship v2.4 to production", tag: "focus" },
    { time: "19:30", title: "Dinner with Elena", tag: "personal" },
  ],
  "2026-07-20": [{ time: "08:45", title: "Investor update draft", tag: "focus" }],
  "2026-07-21": [
    { time: "13:00", title: "Client demo — Northwind Co.", tag: "work" },
  ],
  "2026-07-24": [
    { time: "12:00", title: "Lunch & learn: TypeScript 6", tag: "work" },
  ],
  "2026-07-27": [
    { time: "10:30", title: "Offsite logistics review", tag: "work" },
  ],
  "2026-07-31": [{ time: "17:00", title: "Payroll cutoff", tag: "focus" }],
  "2026-08-03": [{ time: "09:00", title: "Q3 kickoff", tag: "work" }],
};

function pad(n: number): string {
  return n < 10 ? `0${n}` : String(n);
}

function partsFromDate(dt: Date): DateParts {
  return { year: dt.getFullYear(), month: dt.getMonth(), day: dt.getDate() };
}

function toKey(p: DateParts): string {
  return `${p.year}-${pad(p.month + 1)}-${pad(p.day)}`;
}

function parseKey(key: string): DateParts {
  const seg = key.split("-");
  return { year: Number(seg[0]), month: Number(seg[1]) - 1, day: Number(seg[2]) };
}

function addDays(p: DateParts, delta: number): DateParts {
  return partsFromDate(new Date(p.year, p.month, p.day + delta));
}

function addMonths(p: DateParts, delta: number): DateParts {
  const target = new Date(p.year, p.month + delta, 1);
  const y = target.getFullYear();
  const m = target.getMonth();
  const maxDay = new Date(y, m + 1, 0).getDate();
  return { year: y, month: m, day: Math.min(p.day, maxDay) };
}

function startOfWeek(p: DateParts): DateParts {
  const dow = new Date(p.year, p.month, p.day).getDay();
  const diff = (dow + 6) % 7;
  return addDays(p, -diff);
}

function weekdayIndex(p: DateParts): number {
  return (new Date(p.year, p.month, p.day).getDay() + 6) % 7;
}

function buildMonth(view: DateParts): DayCell[] {
  const first = new Date(view.year, view.month, 1);
  const startOffset = (first.getDay() + 6) % 7;
  const start = new Date(view.year, view.month, 1 - startOffset);
  const cells: DayCell[] = [];
  for (let i = 0; i < 42; i += 1) {
    const dt = new Date(
      start.getFullYear(),
      start.getMonth(),
      start.getDate() + i,
    );
    const y = dt.getFullYear();
    const m = dt.getMonth();
    const d = dt.getDate();
    cells.push({
      key: toKey({ year: y, month: m, day: d }),
      year: y,
      month: m,
      day: d,
      inMonth: m === view.month,
      isToday: y === TODAY.year && m === TODAY.month && d === TODAY.day,
    });
  }
  return cells;
}

function longLabel(p: DateParts): string {
  return `${WEEKDAYS_LONG[weekdayIndex(p)]}, ${p.day} ${MONTHS_LONG[p.month]} ${p.year}`;
}

export default function CalendarMini() {
  const reduce = useReducedMotion();
  const captionId = useId();
  const helpId = useId();

  const todayKey = toKey(TODAY);
  const [view, setView] = useState<DateParts>({
    year: TODAY.year,
    month: TODAY.month,
  });
  const [selectedKey, setSelectedKey] = useState<string>(todayKey);
  const [focusedKey, setFocusedKey] = useState<string>(todayKey);

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

  useEffect(() => {
    if (!focusPendingRef.current) return;
    const btn = dayRefs.current[focusedKey];
    if (btn) btn.focus();
    focusPendingRef.current = false;
  }, [focusedKey, view]);

  const cells = useMemo(() => buildMonth(view), [view]);
  const weeks = useMemo(() => {
    const rows: DayCell[][] = [];
    for (let i = 0; i < cells.length; i += 7) rows.push(cells.slice(i, i + 7));
    return rows;
  }, [cells]);

  const selectedParts = parseKey(selectedKey);
  const selectedEvents = EVENTS[selectedKey] ?? [];

  function moveFocus(next: DateParts) {
    focusPendingRef.current = true;
    setFocusedKey(toKey(next));
    if (next.month !== view.month || next.year !== view.year) {
      setView({ year: next.year, month: next.month });
    }
  }

  function selectDay(p: DateParts) {
    const key = toKey(p);
    setSelectedKey(key);
    setFocusedKey(key);
    if (p.month !== view.month || p.year !== view.year) {
      setView({ year: p.year, month: p.month });
    }
  }

  function goToMonth(delta: number) {
    const next = addMonths(parseKey(focusedKey), delta);
    setView({ year: next.year, month: next.month });
    setFocusedKey(toKey(next));
  }

  function goToday() {
    setView({ year: TODAY.year, month: TODAY.month });
    setFocusedKey(todayKey);
    setSelectedKey(todayKey);
  }

  function handleKeyDown(e: KeyboardEvent<HTMLDivElement>) {
    const cur = parseKey(focusedKey);
    let next: DateParts | null = null;
    switch (e.key) {
      case "ArrowLeft":
        next = addDays(cur, -1);
        break;
      case "ArrowRight":
        next = addDays(cur, 1);
        break;
      case "ArrowUp":
        next = addDays(cur, -7);
        break;
      case "ArrowDown":
        next = addDays(cur, 7);
        break;
      case "Home":
        next = startOfWeek(cur);
        break;
      case "End":
        next = addDays(startOfWeek(cur), 6);
        break;
      case "PageUp":
        next = addMonths(cur, e.shiftKey ? -12 : -1);
        break;
      case "PageDown":
        next = addMonths(cur, e.shiftKey ? 12 : 1);
        break;
      case "Enter":
      case " ":
        e.preventDefault();
        selectDay(cur);
        return;
      default:
        return;
    }
    e.preventDefault();
    moveFocus(next);
  }

  const iconBtn =
    "inline-flex h-8 w-8 items-center justify-center rounded-lg text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-900";

  return (
    <section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-white px-4 py-16 sm:py-20 dark:from-slate-950 dark:to-slate-900">
      <style>{`
        @keyframes calmini-grid-in {
          from { opacity: 0; transform: translateY(6px) scale(0.99); }
          to { opacity: 1; transform: translateY(0) scale(1); }
        }
        .calmini-grid-in { animation: calmini-grid-in 320ms cubic-bezier(0.22, 1, 0.36, 1); }
        @media (prefers-reduced-motion: reduce) {
          .calmini-grid-in { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-3xl">
        <div className="mb-8">
          <p className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
            Schedule
          </p>
          <h2 className="mt-2 text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-white">
            Your week at a glance
          </h2>
        </div>

        <div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
          <div className="grid grid-cols-1 md:grid-cols-[300px_1fr]">
            {/* Calendar */}
            <div className="border-b border-slate-200 p-5 md:border-b-0 md:border-r dark:border-slate-800">
              <div className="mb-4 flex items-center justify-between gap-2">
                <h3
                  id={captionId}
                  aria-live="polite"
                  className="text-sm font-semibold text-slate-900 dark:text-white"
                >
                  {MONTHS_LONG[view.month]} {view.year}
                </h3>
                <div className="flex items-center gap-1">
                  <button
                    type="button"
                    onClick={() => goToMonth(-1)}
                    className={iconBtn}
                    aria-label="Previous month"
                  >
                    <svg
                      viewBox="0 0 20 20"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth={1.75}
                      className="h-4 w-4"
                      aria-hidden="true"
                    >
                      <path
                        d="M12.5 5 7.5 10l5 5"
                        strokeLinecap="round"
                        strokeLinejoin="round"
                      />
                    </svg>
                  </button>
                  <button
                    type="button"
                    onClick={goToday}
                    className="rounded-md px-2.5 py-1 text-xs font-medium text-slate-600 ring-1 ring-inset ring-slate-200 transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-300 dark:ring-slate-700 dark:hover:bg-slate-800 dark:hover:text-white dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-900"
                  >
                    Today
                  </button>
                  <button
                    type="button"
                    onClick={() => goToMonth(1)}
                    className={iconBtn}
                    aria-label="Next month"
                  >
                    <svg
                      viewBox="0 0 20 20"
                      fill="none"
                      stroke="currentColor"
                      strokeWidth={1.75}
                      className="h-4 w-4"
                      aria-hidden="true"
                    >
                      <path
                        d="M7.5 5 12.5 10l-5 5"
                        strokeLinecap="round"
                        strokeLinejoin="round"
                      />
                    </svg>
                  </button>
                </div>
              </div>

              <p id={helpId} className="sr-only">
                Use arrow keys to move between dates, Page Up and Page Down to
                change month, and Enter to select a date.
              </p>

              <div
                key={`${view.year}-${view.month}`}
                role="grid"
                aria-labelledby={captionId}
                aria-describedby={helpId}
                onKeyDown={handleKeyDown}
                className={reduce ? "" : "calmini-grid-in"}
              >
                <div role="row" className="mb-1 grid grid-cols-7">
                  {WEEKDAYS_SHORT.map((wd, i) => (
                    <div
                      key={wd}
                      role="columnheader"
                      aria-label={WEEKDAYS_LONG[i]}
                      className="py-1 text-center text-[0.65rem] font-semibold uppercase tracking-wide text-slate-400 dark:text-slate-500"
                    >
                      {wd}
                    </div>
                  ))}
                </div>

                {weeks.map((week) => (
                  <div
                    key={week[0].key}
                    role="row"
                    className="grid grid-cols-7 gap-0.5"
                  >
                    {week.map((cell) => {
                      const dayEvents = EVENTS[cell.key] ?? [];
                      const count = dayEvents.length;
                      const isSelected = cell.key === selectedKey;
                      const parts: DateParts = {
                        year: cell.year,
                        month: cell.month,
                        day: cell.day,
                      };
                      const classes = [
                        "relative flex h-9 w-full items-center justify-center rounded-lg text-sm font-medium transition-colors 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-indigo-400 dark:focus-visible:ring-offset-slate-900",
                      ];
                      if (isSelected) {
                        classes.push(
                          "bg-indigo-600 text-white shadow-sm hover:bg-indigo-600 dark:bg-indigo-500 dark:hover:bg-indigo-500",
                        );
                      } else if (cell.isToday) {
                        classes.push(
                          "text-indigo-600 ring-1 ring-inset ring-indigo-400 hover:bg-indigo-50 dark:text-indigo-300 dark:ring-indigo-500/60 dark:hover:bg-indigo-500/10",
                        );
                      } else if (cell.inMonth) {
                        classes.push(
                          "text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800",
                        );
                      } else {
                        classes.push(
                          "text-slate-300 hover:bg-slate-100 dark:text-slate-600 dark:hover:bg-slate-800",
                        );
                      }

                      const label = `${longLabel(parts)}${
                        count > 0
                          ? `, ${count} ${count === 1 ? "event" : "events"}`
                          : ""
                      }${cell.isToday ? ", today" : ""}`;

                      return (
                        <div
                          key={cell.key}
                          role="gridcell"
                          aria-selected={isSelected}
                        >
                          <button
                            type="button"
                            ref={(el) => {
                              dayRefs.current[cell.key] = el;
                            }}
                            tabIndex={cell.key === focusedKey ? 0 : -1}
                            aria-label={label}
                            aria-current={cell.isToday ? "date" : undefined}
                            onClick={() => selectDay(parts)}
                            className={classes.join(" ")}
                          >
                            {cell.day}
                            {count > 0 ? (
                              <span className="absolute bottom-1 flex gap-0.5">
                                {dayEvents.slice(0, 3).map((ev, i) => (
                                  <span
                                    key={i}
                                    className={`h-1 w-1 rounded-full ${
                                      isSelected
                                        ? "bg-white/80"
                                        : TAG_STYLES[ev.tag].dot
                                    }`}
                                  />
                                ))}
                              </span>
                            ) : null}
                          </button>
                        </div>
                      );
                    })}
                  </div>
                ))}
              </div>

              <div className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-slate-100 pt-3 dark:border-slate-800">
                {(Object.keys(TAG_STYLES) as EventTag[]).map((tag) => (
                  <span
                    key={tag}
                    className="inline-flex items-center gap-1.5 text-[0.7rem] font-medium text-slate-500 dark:text-slate-400"
                  >
                    <span
                      className={`h-1.5 w-1.5 rounded-full ${TAG_STYLES[tag].dot}`}
                    />
                    {TAG_STYLES[tag].label}
                  </span>
                ))}
              </div>
            </div>

            {/* Agenda */}
            <div className="p-5">
              <div className="mb-4" aria-live="polite">
                <p className="text-[0.7rem] font-semibold uppercase tracking-wide text-slate-400 dark:text-slate-500">
                  {selectedKey === todayKey ? "Today" : "Selected"}
                </p>
                <h3 className="mt-0.5 text-sm font-semibold text-slate-900 dark:text-white">
                  {longLabel(selectedParts)}
                </h3>
                <p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">
                  {selectedEvents.length === 0
                    ? "No events scheduled"
                    : `${selectedEvents.length} ${
                        selectedEvents.length === 1 ? "event" : "events"
                      }`}
                </p>
              </div>

              {selectedEvents.length === 0 ? (
                <div className="flex h-40 flex-col items-center justify-center rounded-xl border border-dashed border-slate-200 text-center dark:border-slate-800">
                  <svg
                    viewBox="0 0 24 24"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth={1.5}
                    className="h-6 w-6 text-slate-300 dark:text-slate-600"
                    aria-hidden="true"
                  >
                    <path
                      d="M5 8h14M5 8a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2M5 8v9a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8M9 4v3m6-3v3"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                    />
                  </svg>
                  <p className="mt-2 text-xs font-medium text-slate-500 dark:text-slate-400">
                    An open day. Enjoy it.
                  </p>
                </div>
              ) : (
                <ul key={selectedKey} className="space-y-2">
                  {selectedEvents.map((ev, i) => {
                    const style = TAG_STYLES[ev.tag];
                    return (
                      <motion.li
                        key={`${ev.time}-${ev.title}`}
                        initial={reduce ? false : { opacity: 0, y: 6 }}
                        animate={{ opacity: 1, y: 0 }}
                        transition={{
                          duration: reduce ? 0 : 0.25,
                          delay: reduce ? 0 : i * 0.05,
                          ease: [0.22, 1, 0.36, 1],
                        }}
                        className="flex items-start gap-3 rounded-xl border border-slate-100 bg-slate-50/60 p-3 dark:border-slate-800 dark:bg-slate-800/40"
                      >
                        <span
                          className={`mt-0.5 h-8 w-1 shrink-0 rounded-full ${style.dot}`}
                          aria-hidden="true"
                        />
                        <div className="min-w-0 flex-1">
                          <p className="truncate text-sm font-medium text-slate-900 dark:text-white">
                            {ev.title}
                          </p>
                          <div className="mt-1 flex items-center gap-2">
                            <span className="text-xs tabular-nums text-slate-500 dark:text-slate-400">
                              {ev.time}
                            </span>
                            <span
                              className={`inline-flex items-center rounded-full px-1.5 py-0.5 text-[0.65rem] font-medium ring-1 ring-inset ${style.chip}`}
                            >
                              {style.label}
                            </span>
                          </div>
                        </div>
                      </motion.li>
                    );
                  })}
                </ul>
              )}
            </div>
          </div>
        </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 →