Event Calendar
A month view with event dots per day: pick a day to list its events below, jump months, and add a quick event.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/event-calendar.json'use client'
import { useState } from 'react'
import './event-calendar.css'
export interface CalEvent { date: string; time: string; title: string }
export interface EventCalendarProps {
events: CalEvent[]
defaultDate?: string
today?: string
weekStart?: 0 | 1
markers?: 'dots' | 'count'
onAdd?: (e: CalEvent) => void
fill?: 'dark' | 'light'
className?: string
}
const iso = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
const parse = (s: string) => { const [y, m, d] = s.split('-').map(Number); return new Date(y, m - 1, d) }
/** Month grid with event markers and a day agenda. */
export function EventCalendar({ events: e0, defaultDate, today = iso(new Date()), weekStart = 1, markers = 'dots', onAdd, fill = 'dark', className }: EventCalendarProps) {
const [events, setEvents] = useState(e0)
const [sel, setSel] = useState(defaultDate ?? today)
const [view, setView] = useState(() => { const d = parse(defaultDate ?? today); return new Date(d.getFullYear(), d.getMonth(), 1) })
const [draft, setDraft] = useState('')
const first = new Date(view.getFullYear(), view.getMonth(), 1)
const lead = (first.getDay() - weekStart + 7) % 7
const cells = Array.from({ length: 42 }, (_, i) => new Date(view.getFullYear(), view.getMonth(), 1 - lead + i))
const rows = cells.slice(35).every((c) => c.getMonth() !== view.getMonth()) ? cells.slice(0, 35) : cells
const on = (d: string) => events.filter((e) => e.date === d).sort((a, b) => a.time.localeCompare(b.time))
const pick = (d: Date) => { setSel(iso(d)); if (d.getMonth() !== view.getMonth()) setView(new Date(d.getFullYear(), d.getMonth(), 1)) }
const shift = (m: number) => setView(new Date(view.getFullYear(), view.getMonth() + m, 1))
const key = (e: React.KeyboardEvent) => {
const d = parse(sel)
const m = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 }[e.key]
if (m) { e.preventDefault(); d.setDate(d.getDate() + m); pick(d) }
if (e.key === 'PageUp' || e.key === 'PageDown') { e.preventDefault(); d.setMonth(d.getMonth() + (e.key === 'PageUp' ? -1 : 1)); pick(d) }
if (e.key === 't') { pick(parse(today)) }
}
const wd = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
const list = on(sel)
const label = parse(sel).toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })
return (
<div className={['sd-evc', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-evc__h">
<b aria-live="polite">{view.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}</b>
<button type="button" className="sd-evc__today" onClick={() => pick(parse(today))}>Today</button>
<button type="button" className="sd-evc__nav" aria-label="Previous month" onClick={() => shift(-1)}><svg viewBox="0 0 14 14" aria-hidden><path d="m9 2.5-4.5 4.5L9 11.5" /></svg></button>
<button type="button" className="sd-evc__nav" aria-label="Next month" onClick={() => shift(1)}><svg viewBox="0 0 14 14" aria-hidden><path d="m5 2.5 4.5 4.5L5 11.5" /></svg></button>
</div>
<div className="sd-evc__wd" aria-hidden>{Array.from({ length: 7 }, (_, i) => <span key={i}>{wd[(i + weekStart) % 7]}</span>)}</div>
<div className="sd-evc__grid" role="grid" aria-label="Month" onKeyDown={key}>
{rows.map((d) => {
const id = iso(d), n = on(id).length
return (
<button key={id} type="button" role="gridcell" className="sd-evc__d" aria-selected={sel === id} tabIndex={sel === id ? 0 : -1} data-out={d.getMonth() !== view.getMonth() || undefined} data-today={id === today || undefined}
aria-label={`${d.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}${n ? `, ${n} event${n > 1 ? 's' : ''}` : ''}`} onClick={() => pick(d)}>
{d.getDate()}
{markers === 'dots' ? <span className="sd-evc__dots" aria-hidden>{Array.from({ length: Math.min(3, n) }, (_, i) => <i key={i} />)}</span> : <span className="sd-evc__n" aria-hidden>{n || '\u00a0'}</span>}
</button>
)
})}
</div>
<div className="sd-evc__list" aria-live="polite">
<p className="sd-evc__lh">{label}</p>
{list.map((e, i) => <div key={i} className="sd-evc__ev"><span>{e.time}</span><b>{e.title}</b></div>)}
{!list.length && (
<form className="sd-evc__add" onSubmit={(ev) => { ev.preventDefault(); if (!draft.trim()) return; const n = { date: sel, time: 'All day', title: draft.trim() }; setEvents([...events, n]); onAdd?.(n); setDraft('') }}>
<input aria-label={`Add event on ${label}`} placeholder="Nothing yet. Add an event" value={draft} onChange={(e) => setDraft(e.target.value)} />
<button type="submit">Add</button>
</form>
)}
</div>
</div>
)
}.sd-evc { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-evc[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-evc :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-evc :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-evc .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-evc { width: min(100%, 320px); padding: 14px; border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-evc__h { display: flex; align-items: center; gap: 4px; margin-bottom: 10px; }
.sd-evc__h b { flex: 1; font-size: 15px; }
.sd-evc__nav { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 9px; }
.sd-evc__nav:hover { background: var(--soft); }
.sd-evc__nav svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.sd-evc__today { padding: 5px 10px; border-radius: 99px; font-size: 12px; box-shadow: inset 0 0 0 1px var(--line); margin-right: 4px; }
.sd-evc__wd { display: grid; grid-template-columns: repeat(7, 1fr); font-size: 11px; color: var(--mute); text-align: center; margin-bottom: 4px; }
.sd-evc__grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; outline: 0; }
.sd-evc__d { position: relative; height: 38px; border-radius: 10px; font-size: 13.5px; font-variant-numeric: tabular-nums; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; transition: background-color 120ms; }
.sd-evc__d:hover { background: var(--soft); }
.sd-evc__d[data-out] { color: var(--mute); opacity: 0.5; }
.sd-evc__d[data-today] { font-weight: 700; box-shadow: inset 0 0 0 1.5px var(--line); }
.sd-evc__d[aria-selected='true'] { background: var(--strong) !important; color: var(--on) !important; opacity: 1; }
.sd-evc__dots { display: flex; gap: 2px; height: 4px; }
.sd-evc__dots i { width: 4px; height: 4px; border-radius: 99px; background: currentColor; opacity: 0.75; }
.sd-evc__n { font-size: 9.5px; font-weight: 700; line-height: 1; opacity: 0.7; }
.sd-evc__list { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--line); min-height: 120px; }
.sd-evc__lh { margin: 0 0 8px; font-size: 12.5px; font-weight: 600; color: var(--mute); }
.sd-evc__ev { display: flex; gap: 10px; padding: 7px 0; font-size: 13.5px; animation: sd-evc-in 200ms ease-out; }
.sd-evc__ev span { width: 52px; flex: none; color: var(--mute); font-variant-numeric: tabular-nums; font-size: 12.5px; padding-top: 1px; }
.sd-evc__ev b { font-weight: 500; flex: 1; padding-left: 10px; border-left: 2px solid var(--fg); }
.sd-evc__add { display: flex; gap: 6px; margin-top: 6px; }
.sd-evc__add input { flex: 1; min-width: 0; height: 34px; padding: 0 10px; border: 0; border-radius: 9px; background: var(--soft); color: var(--fg); font: inherit; font-size: 13px; outline: 0; }
.sd-evc__add input:focus { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-evc__add button { padding: 0 12px; border-radius: 9px; background: var(--strong) !important; color: var(--on) !important; font-size: 13px; font-weight: 600; }
@keyframes sd-evc-in { from { opacity: 0; transform: translateY(-3px); } }
@media (prefers-reduced-motion: reduce) { .sd-evc *, .sd-evc *::before, .sd-evc *::after { animation: none !important; transition: none !important; } }Overview
Days with events show up to three dots. Selecting a day lists its events with times; an empty day offers a one-line quick add.
Arrow keys move through days, Page Up/Down change month, and “Today” jumps back.
When to use
- Personal planners, booking overviews, team calendars on mobile.
When not to use
- Dense scheduling across hours: use a day or week view.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/event-calendar.json$
npx shadcn@latest add https://sundaeui.dev/r/event-calendar.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/event-calendar.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/event-calendar.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy event-calendar.tsx and event-calendar.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { EventCalendar } from '@/components/sundae/event-calendar'
<EventCalendar />Props
| Prop | Type | Default |
|---|---|---|
events*Events with ISO dates. | { date: string; time: string; title: string }[] | — |
defaultDateSelected ISO date. | string | — |
weekStartSunday or Monday. | 0 | 1 | 1 |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- A grid of day buttons; each says the date and how many events it has.
- The selected day’s list is a live region.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-evc; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- Arrow keys
- Move a day / week
- Page Up / Down
- Previous / next month
- T
- Today
FAQ
- Is Event Calendar free?
- Yes. Event Calendar is free for personal and commercial projects, with full source on this page.
- How do I install Event Calendar?
- Run npx shadcn@latest add https://sundaeui.dev/r/event-calendar.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Event Calendar support keyboard and screen readers?
- A grid of day buttons; each says the date and how many events it has. The selected day’s list is a live region.
- Does Event Calendar respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →- Pro
Day Agenda
Components · Refined RSVP Card
Components · RefinedNext Meeting Card
Components · RefinedToast Stack
Components · Refined- Pro
Sheet Drawer
Components · Refined Avatar Stack
Components · Refined