Skip to contentVibraUI
Inputs & filters

Mini calendar

A compact month grid with a mark on the days that carry something: dots, counts or a three-state fill, and the arrow keys to walk it.

The small calendar beside a page, not a replacement for DatePicker or Calendar: no popover, no range, no field. Everything is UTC — Date.UTC in, getUTC* out, marks keyed yyyy-mm-dd (exactly what toISOString().slice(0, 10) gives) — so midnight on the 1st is the 1st for every reader, where a grid built from local parts puts it in the month before for anyone west of Greenwich. Two ownership rules, both by presence: pass onMonthChange and the month is yours, so the calendar renders exactly the month you hand back; leave it off and month is the month it opens on and the arrows move it themselves. value follows the picker rule the rest of the registry uses — the key being there at all, even as undefined, means you own the choice — so clearing a controlled calendar never hands it back its own state. The grid is a real role="grid": one day in the tab order at a time, arrows walking the month and stepping into the next or previous one when they run off the end, Home and End for its two ends. aria-selected sits on the cell, where a grid puts it, and each day button carries its full date as its name plus whatever its mark adds — "3 events", "limited" — because a dot is not a label. countLabel names what is being counted; the default reads "1 event", "3 events", and a page whose marks are something else — viewings, bookings — passes its own.

Install

npx shadcn@latest add @vibra/mini-calendar

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
monthDate—The month on show, read through its UTC parts. Owned by you when onMonthChange is given, otherwise the month it opens on.
valueDate—The chosen day, at midnight UTC. Passing the value key at all — even as undefined — makes the choice yours.
onValueChange(date: Date) => void—Called when the reader picks a day, at midnight UTC.
onMonthChange(month: Date) => void—The reader asked for another month — from the arrows, or from a key that stepped off the end of this one.
marksRecord<string, { tone?; count?: number; fill?: "available" | "partial" | "full" }>—Keyed yyyy-mm-dd in UTC. count draws up to three dots and is read out in full; fill washes the whole cell and names its state.
todayDate—Ringed in ink rather than in the accent — the accent is spent on the day the reader chose.
weekStartsOn0 | 11Sunday or Monday in the first column.
size"sm" | "default""default"Cell type scale and the size of the two month buttons.
countLabel(count: number) => string(count) => `${count} event${count === 1 ? "" : "s"}`What a mark's count is read out as, after the date — pass your own noun (e.g. "2 viewings") when the marks are not events.

Dependencies

Source

components/ui/mini-calendar/index.tsx
"use client"

import * as React from "react"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
  DAY_MS,
  FULL_DAY,
  MINI_CALENDAR_FILL,
  MINI_CALENDAR_FILL_LABELS,
  MONTH_YEAR,
  TONE_DOT,
  WEEKDAYS,
  dayKey,
  monthCells,
  moveMonth,
  sameMonth,
  startOfDay,
  startOfMonth,
  type MiniCalendarMark,
} from "./days"

export type MiniCalendarProps = React.ComponentProps<"div"> & {
  /**
   * The month on show. Pass `onMonthChange` and it is yours — the calendar
   * renders exactly what you hand it. Leave that off and this is the month it
   * opens on, and the arrows move it from there.
   */
  month: Date
  /** The chosen day. Present, even as `undefined`, means you own the choice. */
  value?: Date
  onValueChange?: (date: Date) => void
  onMonthChange?: (month: Date) => void
  /** Keyed `yyyy-mm-dd`, in UTC — the same key `toISOString().slice(0, 10)` gives. */
  marks?: Record<string, MiniCalendarMark>
  today?: Date
  weekStartsOn?: 0 | 1
  size?: "sm" | "default"
  /** What a mark's count is called, read out after the date: "3 events" by default. */
  countLabel?: (count: number) => string
}

/** "1 event", "3 events": the default noun a mark's count is read out with. */
const DEFAULT_COUNT_LABEL = (count: number) => `${count} event${count === 1 ? "" : "s"}`

/**
 * A compact month grid: one cell per day, marks on the days that have
 * something on them, and the arrow keys to walk it.
 *
 * Every date is a UTC instant at midnight — `Date.UTC` in, `getUTC*` out — so
 * the day under the cursor is the same day for a reader in Los Angeles and one
 * in Berlin, and a mark keyed `2026-09-08` lands on the eighth either way.
 * This is the small calendar beside a page, not a replacement for DatePicker
 * or Calendar: it has no popover, no range and no field.
 */
function MiniCalendar(props: MiniCalendarProps) {
  const {
    className,
    month,
    value,
    onValueChange,
    onMonthChange,
    marks,
    today,
    weekStartsOn = 1,
    size = "default",
    countLabel = DEFAULT_COUNT_LABEL,
    ...rest
  } = props

  // A picker whose value may legitimately be undefined takes the presence of
  // the key as the controlled signal, so `value={undefined}` still means "mine".
  const valueOwned = "value" in props
  const monthOwned = onMonthChange !== undefined

  const [ownValue, setOwnValue] = React.useState<Date | undefined>(value)
  const [ownMonth, setOwnMonth] = React.useState(() => startOfMonth(month))
  const [focusKey, setFocusKey] = React.useState<string | null>(null)
  const dayNodes = React.useRef(new Map<string, HTMLButtonElement | null>())
  const pendingFocus = React.useRef<string | null>(null)

  React.useEffect(() => {
    const key = pendingFocus.current
    if (!key) return
    pendingFocus.current = null
    dayNodes.current.get(key)?.focus()
  })

  const shown = monthOwned ? startOfMonth(month) : ownMonth
  const selected = valueOwned ? value : ownValue
  const selectedKey = selected ? dayKey(startOfDay(selected)) : null
  const todayKey = today ? dayKey(startOfDay(today)) : null

  const year = shown.getUTCFullYear()
  const monthIndex = shown.getUTCMonth()
  const { days, weeks } = monthCells(shown, weekStartsOn)

  // Exactly one day is in the tab order: wherever focus last was, else the
  // chosen day, else today, else the first of the month.
  const inMonth = (key: string | null) => key !== null && key.slice(0, 7) === dayKey(shown).slice(0, 7)
  const tabKey =
    (inMonth(focusKey) ? focusKey : null) ??
    (inMonth(selectedKey) ? selectedKey : null) ??
    (inMonth(todayKey) ? todayKey : null) ??
    dayKey(days[0])

  function changeMonth(next: Date, focusOn?: Date) {
    if (!monthOwned) setOwnMonth(next)
    onMonthChange?.(next)
    if (focusOn) {
      const key = dayKey(focusOn)
      setFocusKey(key)
      pendingFocus.current = key
    } else {
      setFocusKey(null)
    }
  }

  function pick(date: Date) {
    if (!valueOwned) setOwnValue(date)
    setFocusKey(dayKey(date))
    onValueChange?.(date)
  }

  /** Moves focus by `step` days, changing the month when it steps off the end. */
  function moveTo(target: Date) {
    if (sameMonth(target, shown)) {
      const key = dayKey(target)
      setFocusKey(key)
      dayNodes.current.get(key)?.focus()
      return
    }
    changeMonth(startOfMonth(target), target)
  }

  function handleKeyDown(event: React.KeyboardEvent<HTMLButtonElement>, date: Date) {
    const step: Record<string, number> = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 }
    if (event.key in step) {
      event.preventDefault()
      moveTo(new Date(date.getTime() + step[event.key] * DAY_MS))
      return
    }
    if (event.key === "Home") {
      event.preventDefault()
      moveTo(days[0])
      return
    }
    if (event.key === "End") {
      event.preventDefault()
      moveTo(days[days.length - 1])
      return
    }
    if (event.key === "PageUp" || event.key === "PageDown") {
      event.preventDefault()
      moveTo(moveMonth(date, event.key === "PageUp" ? -1 : 1))
    }
  }

  const heading = MONTH_YEAR.format(shown)

  return (
    <div
      data-slot="mini-calendar"
      data-size={size}
      data-week-start={weekStartsOn}
      className={cn("w-fit min-w-56 text-sm", className)}
      {...rest}
    >
      <div data-slot="mini-calendar-header" className="flex items-center justify-between gap-1 pb-1">
        <Button
          type="button"
          variant="ghost"
          size={size === "sm" ? "icon-xs" : "icon-sm"}
          aria-label="Previous month"
          onClick={() => changeMonth(new Date(Date.UTC(year, monthIndex - 1, 1)))}
        >
          <ChevronLeftIcon className="rtl:rotate-180" />
        </Button>
        <div data-slot="mini-calendar-month" className="type-label" aria-live="polite">
          {heading}
        </div>
        <Button
          type="button"
          variant="ghost"
          size={size === "sm" ? "icon-xs" : "icon-sm"}
          aria-label="Next month"
          onClick={() => changeMonth(new Date(Date.UTC(year, monthIndex + 1, 1)))}
        >
          <ChevronRightIcon className="rtl:rotate-180" />
        </Button>
      </div>

      <table role="grid" aria-label={heading} className="w-full border-collapse">
        <thead>
          <tr>
            {Array.from({ length: 7 }, (_, index) => {
              const [short, long] = WEEKDAYS[(index + weekStartsOn) % 7]
              return (
                <th key={short} scope="col" className="type-eyebrow pb-1 font-normal text-faint-foreground">
                  <span aria-hidden="true">{short}</span>
                  <span className="sr-only">{long}</span>
                </th>
              )
            })}
          </tr>
        </thead>
        <tbody>
          {weeks.map((week, index) => (
            <tr key={index}>
              {week.map((date, position) => {
                if (!date) return <td key={position} role="gridcell" className="p-0" />
                const key = dayKey(date)
                const mark = marks?.[key]
                const isSelected = key === selectedKey
                const isToday = key === todayKey
                const name = [
                  FULL_DAY.format(date),
                  mark?.count ? countLabel(mark.count) : null,
                  mark?.fill ? MINI_CALENDAR_FILL_LABELS[mark.fill] : null,
                ]
                  .filter(Boolean)
                  .join(", ")

                return (
                  <td key={position} role="gridcell" aria-selected={isSelected} className="p-0">
                    <button
                      type="button"
                      ref={(node) => {
                        dayNodes.current.set(key, node)
                      }}
                      data-slot="mini-calendar-day"
                      data-day={key}
                      data-today={isToday || undefined}
                      data-selected={isSelected || undefined}
                      data-mark={mark?.tone}
                      data-fill={mark?.fill}
                      tabIndex={key === tabKey ? 0 : -1}
                      aria-label={name}
                      aria-current={isToday ? "date" : undefined}
                      onClick={() => pick(date)}
                      onKeyDown={(event) => handleKeyDown(event, date)}
                      className={cn(
                        "relative flex aspect-square w-full items-center justify-center rounded-md tabular-nums focus-ring",
                        "transition-colors duration-(--duration-fast) ease-(--ease-standard) hover:bg-muted",
                        size === "sm" ? "text-2xs" : "text-xs",
                        mark?.fill && MINI_CALENDAR_FILL[mark.fill],
                        // Today is ink, never the accent: the accent is spent
                        // on the day the reader chose.
                        isToday && !isSelected && "font-medium ring-1 ring-inset ring-foreground/40",
                        isSelected && "bg-brand font-medium text-brand-foreground hover:bg-brand"
                      )}
                    >
                      {date.getUTCDate()}
                      {mark?.tone || mark?.count ? (
                        <span
                          aria-hidden="true"
                          className="pointer-events-none absolute inset-x-0 bottom-0.5 flex justify-center gap-px"
                        >
                          {Array.from({ length: Math.min(mark.count ?? 1, 3) }, (_, dot) => (
                            <span
                              key={dot}
                              className={cn(
                                "size-1 rounded-full",
                                isSelected ? "bg-brand-foreground" : TONE_DOT[mark.tone ?? "neutral"]
                              )}
                            />
                          ))}
                        </span>
                      ) : null}
                    </button>
                  </td>
                )
              })}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  )
}

export { MiniCalendar, MINI_CALENDAR_FILL, MINI_CALENDAR_FILL_LABELS }
export type { MiniCalendarFill, MiniCalendarMark, MiniCalendarTone } from "./days"
components/ui/mini-calendar/days.ts
/**
 * The day vocabulary the grid is built from: UTC-only date arithmetic, the
 * keys a mark is looked up by, the weekday names, and the paint for a tone or
 * a fill.
 *
 * Every function here reads `getUTC*` and writes `Date.UTC`, so a day is the
 * same day in Los Angeles and in Berlin — a calendar that reads local parts
 * puts midnight UTC on the 1st into the month before for half the planet.
 */

/** How a marked day reads: a hue, a count, or one of three fill states. */
export type MiniCalendarTone = "neutral" | "brand" | "success" | "warning" | "danger" | "info"

export type MiniCalendarFill = "available" | "partial" | "full"

export type MiniCalendarMark = {
  tone?: MiniCalendarTone
  /** How many things are on that day; dots up to three, and read out in full. */
  count?: number
  /** A whole-cell wash: how much of the day is taken. */
  fill?: MiniCalendarFill
}

export const WEEKDAYS = [
  ["Su", "Sunday"],
  ["Mo", "Monday"],
  ["Tu", "Tuesday"],
  ["We", "Wednesday"],
  ["Th", "Thursday"],
  ["Fr", "Friday"],
  ["Sa", "Saturday"],
] as const

export const TONE_DOT: Record<MiniCalendarTone, string> = {
  neutral: "bg-muted-foreground",
  brand: "bg-brand",
  success: "bg-success",
  warning: "bg-warning",
  danger: "bg-danger",
  info: "bg-info",
}

/**
 * The three fills: a tint with the tone's own ink on it, the pairing
 * StatusBadge uses — `-foreground` is the ink for a *solid* fill and reads at
 * 1.1:1 on the tint, which is a day number nobody can read.
 */
export const MINI_CALENDAR_FILL: Record<MiniCalendarFill, string> = {
  available: "bg-success-muted text-success",
  partial: "bg-warning-muted text-warning",
  full: "bg-danger-muted text-danger",
}

/** What each fill is called, in the day's own accessible name and in a legend. */
export const MINI_CALENDAR_FILL_LABELS: Record<MiniCalendarFill, string> = {
  available: "available",
  partial: "limited",
  full: "full",
}

export const MONTH_YEAR = new Intl.DateTimeFormat("en-US", {
  month: "long",
  year: "numeric",
  timeZone: "UTC",
})

export const FULL_DAY = new Intl.DateTimeFormat("en-US", {
  weekday: "long",
  month: "long",
  day: "numeric",
  year: "numeric",
  timeZone: "UTC",
})

export const DAY_MS = 86_400_000

/** Midnight UTC on the first of the month a date falls in. */
export function startOfMonth(date: Date): Date {
  return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1))
}

/** Midnight UTC of the day a date names. */
export function startOfDay(date: Date): Date {
  return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
}

/** The `yyyy-mm-dd` key a mark is looked up by. */
export function dayKey(date: Date): string {
  return date.toISOString().slice(0, 10)
}

export function sameMonth(a: Date, b: Date): boolean {
  return a.getUTCFullYear() === b.getUTCFullYear() && a.getUTCMonth() === b.getUTCMonth()
}

/**
 * The same day of the month, `months` away — clamped into whatever that
 * month actually has, so August 31 minus a month lands on July 31 but March
 * 31 minus a month lands on February 28 (or 29), never rolling into March.
 */
export function moveMonth(date: Date, months: number): Date {
  const year = date.getUTCFullYear()
  const month = date.getUTCMonth() + months
  const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate()
  return new Date(Date.UTC(year, month, Math.min(date.getUTCDate(), lastDay)))
}

/** The days of the month `shown` names, plus the blanks that lead into it. */
export function monthCells(shown: Date, weekStartsOn: 0 | 1): { days: Date[]; weeks: (Date | null)[][] } {
  const year = shown.getUTCFullYear()
  const month = shown.getUTCMonth()
  const length = new Date(Date.UTC(year, month + 1, 0)).getUTCDate()
  const lead = (shown.getUTCDay() - weekStartsOn + 7) % 7

  const days = Array.from({ length }, (_, index) => new Date(Date.UTC(year, month, index + 1)))
  const cells: (Date | null)[] = [...Array.from({ length: lead }, () => null), ...days]
  while (cells.length % 7 !== 0) cells.push(null)
  const weeks = Array.from({ length: cells.length / 7 }, (_, week) => cells.slice(week * 7, week * 7 + 7))
  return { days, weeks }
}