# Calendar 8

> A plain month grid with a selected-day list — no shell, no filters, no dialogs

A shadcn/ui block from the [@shadcnstore](https://www.shadcnstore.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/shadcnstore/calendar-8).

- Page, with a live preview: https://shadcn.exchange/r/shadcnstore/calendar-8
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/shadcnstore/calendar-8-light-fea6891879a4a45d.png
- Registry item: https://shadcnstore.com/r/calendar-8.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://shadcnstore.com/r/calendar-8.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `button`, `card`, `empty`, `item`

## Files

### `storage/app/registry-sources/application/calendar/calendar-8/page.tsx`

```tsx
import { Calendar8 } from '@/components/blocks/application/calendar/calendar-8/components/calendar-8'

export default function Page() {
  return (
    <Calendar8 />
  )
}
```

### `storage/app/registry-sources/application/calendar/calendar-8/calendar-8.tsx`

```tsx
'use client'

import { useMemo, useState } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'

import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { cn } from '@/lib/utils'

import { compactTime, simpleEvents, toDateString, type SimpleEvent } from "@/components/data/calendar-8-data"
import { Calendar8DayPanel } from './calendar-8-day-panel'

const monthFormat = new Intl.DateTimeFormat('en-US', { month: 'long', year: 'numeric' })

const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']

/** Two chips is what a cell this size holds; the rest roll into a "+n more" line. */
const MAX_CHIPS = 2

const startOfDay = (date: Date) => new Date(date.getFullYear(), date.getMonth(), date.getDate())

const addDays = (date: Date, days: number) => {
  const next = new Date(date)
  next.setDate(next.getDate() + days)
  return next
}

/**
 * Weeks start on Monday, which is what most of the world outside the US expects. The row count is
 * derived rather than fixed at six, so a short month never renders a whole empty trailing week.
 */
function monthGrid(anchor: Date) {
  const first = new Date(anchor.getFullYear(), anchor.getMonth(), 1)
  const offset = (first.getDay() + 6) % 7
  const dayCount = new Date(anchor.getFullYear(), anchor.getMonth() + 1, 0).getDate()
  const weeks = Math.ceil((offset + dayCount) / 7)
  const start = addDays(first, -offset)
  return Array.from({ length: weeks * 7 }, (_, index) => addDays(start, index))
}

/**
 * The plain month view: a grid, a day panel, and nothing else.
 *
 * Deliberately not an application shell. No sidebar, no mini month, no calendar filters, no search,
 * no attendees, no create dialog — those are what the paid calendar blocks are for. This one is the
 * piece you drop into a page that just needs to show a month.
 */
export function Calendar8() {
  const today = useMemo(() => startOfDay(new Date()), [])
  const [anchor, setAnchor] = useState(() => new Date(today.getFullYear(), today.getMonth(), 1))
  const [selected, setSelected] = useState(today)

  const events = useMemo(() => {
    const map = new Map<string, SimpleEvent[]>()

    for (const event of simpleEvents) {
      const date = toDateString(addDays(today, event.dayOffset))
      const list = map.get(date) ?? []
      list.push(event)
      map.set(date, list)
    }

    for (const list of map.values()) {
      list.sort((left, right) => left.start - right.start)
    }

    return map
  }, [today])

  const days = useMemo(() => monthGrid(anchor), [anchor])
  const todayStr = toDateString(today)
  const selectedStr = toDateString(selected)

  const shiftMonth = (delta: number) => setAnchor(new Date(anchor.getFullYear(), anchor.getMonth() + delta, 1))

  const goToToday = () => {
    setAnchor(new Date(today.getFullYear(), today.getMonth(), 1))
    setSelected(today)
  }

  return (
    <div className="bg-background w-full">
      <div className="w-full p-4 sm:p-6 lg:p-8">
        <Card>
          <CardHeader className="flex flex-row flex-wrap items-center justify-between gap-3">
            <CardTitle className="text-xl tracking-tight">{monthFormat.format(anchor)}</CardTitle>

            <div className="flex items-center gap-1">
              <Button variant="outline" size="icon" aria-label="Previous month" onClick={() => shiftMonth(-1)}>
                <ChevronLeft />
              </Button>
              <Button variant="outline" onClick={goToToday}>
                Today
              </Button>
              <Button variant="outline" size="icon" aria-label="Next month" onClick={() => shiftMonth(1)}>
                <ChevronRight />
              </Button>
            </div>
          </CardHeader>

          <CardContent className="flex flex-col gap-4">
            <div className="bg-border overflow-hidden rounded-xl border">
              <div className="bg-card text-muted-foreground grid grid-cols-7 text-xs font-medium">
                {dayNames.map(name => (
                  <div key={name} className="px-2 py-2.5 text-center">
                    <span className="hidden sm:inline">{name}</span>
                    <span className="sm:hidden">{name.slice(0, 1)}</span>
                  </div>
                ))}
              </div>

              <div className="grid grid-cols-7 gap-px border-t pt-px">
                {days.map(day => {
                  const dateStr = toDateString(day)
                  const inMonth = day.getMonth() === anchor.getMonth()
                  const isToday = dateStr === todayStr
                  const isSelected = dateStr === selectedStr
                  const dayEvents = events.get(dateStr) ?? []
                  const shown = dayEvents.slice(0, MAX_CHIPS)
                  const overflow = dayEvents.length - shown.length

                  return (
                    <button
                      key={dateStr}
                      type="button"
                      onClick={() => setSelected(day)}
                      aria-pressed={isSelected}
                      aria-label={`${day.toDateString()}, ${dayEvents.length} events`}
                      className={cn(
                        'focus-visible:ring-ring/50 relative flex min-h-16 flex-col gap-1 p-1.5 text-left transition-colors focus-visible:z-10 focus-visible:ring-2 focus-visible:outline-none sm:min-h-24',
                        inMonth ? 'bg-card hover:bg-accent/50' : 'bg-muted/40 hover:bg-accent/40',
                        isSelected && 'ring-ring/60 z-10 ring-2 ring-inset',
                      )}
                    >
                      <span
                        className={cn(
                          'flex size-6 shrink-0 items-center justify-center rounded-full text-xs tabular-nums',
                          isToday
                            ? 'bg-primary text-primary-foreground font-semibold'
                            : inMonth
                              ? 'font-medium'
                              : 'text-muted-foreground',
                        )}
                      >
                        {day.getDate()}
                      </span>

                      {/* A seven-column month at 390px gives each cell about 50px — too narrow for a
                          chip, so phones get a dot per event and the panel below carries the detail. */}
                      <span className="flex min-h-2 flex-wrap items-start gap-1 sm:hidden">
                        {dayEvents.slice(0, 4).map(event => (
                          <span
                            key={event.id}
                            aria-hidden="true"
                            className={cn('bg-foreground size-1.5 rounded-full', inMonth ? undefined : 'opacity-50')}
                          />
                        ))}
                      </span>

                      <span className="hidden min-w-0 flex-col gap-0.5 sm:flex">
                        {shown.map(event => (
                          <span
                            key={event.id}
                            className={cn(
                              'bg-muted flex w-full min-w-0 items-center gap-1.5 rounded px-1.5 py-0.5 text-xs leading-5',
                              inMonth ? undefined : 'opacity-60',
                            )}
                          >
                            {event.allDay ? null : (
                              <span className="shrink-0 text-[0.6875rem] tabular-nums opacity-70">
                                {compactTime(event.start)}
                              </span>
                            )}
                            <span className="min-w-0 flex-1 truncate font-medium">{event.title}</span>
                          </span>
                        ))}

                        {overflow > 0 ? (
                          <span className="text-muted-foreground px-1.5 text-xs">{`+${overflow} more`}</span>
                        ) : null}
                      </span>
                    </button>
                  )
                })}
              </div>
            </div>

            <Calendar8DayPanel date={selected} events={events.get(selectedStr) ?? []} />
          </CardContent>
        </Card>
      </div>
    </div>
  )
}

export default Calendar8
```

### `storage/app/registry-sources/application/calendar/calendar-8/calendar-8-day-panel.tsx`

```tsx
'use client'

import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty'
import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@/components/ui/item'

import { formatRange, type SimpleEvent } from "@/components/data/calendar-8-data"

const longDate = new Intl.DateTimeFormat('en-US', { weekday: 'long', month: 'long', day: 'numeric' })

/** The whole detail surface of the free block: the selected day, listed. No sheet, no actions. */
export function Calendar8DayPanel({ date, events }: { date: Date; events: SimpleEvent[] }) {
  return (
    <section aria-label={`Events on ${longDate.format(date)}`} className="flex flex-col gap-2">
      <h3 className="flex items-baseline gap-2 text-sm font-semibold">
        {longDate.format(date)}
        <span className="text-muted-foreground ml-auto text-xs font-normal tabular-nums">
          {events.length === 0 ? 'Clear' : `${events.length} event${events.length === 1 ? '' : 's'}`}
        </span>
      </h3>

      {events.length === 0 ? (
        <Empty className="border border-dashed py-8">
          <EmptyHeader>
            <EmptyTitle>Nothing scheduled</EmptyTitle>
            <EmptyDescription>Pick another day to see what is on.</EmptyDescription>
          </EmptyHeader>
        </Empty>
      ) : (
        events.map(event => (
          <Item key={event.id} variant="outline" size="sm">
            {/*
              `ItemMedia` self-starts and nudges down half a step once a description is present —
              tuned for a 40px avatar, which left this 8px dot pinned to the top of the row. A box
              the height of the title's line puts the dot on the title instead.
            */}
            <ItemMedia className="h-5 translate-y-0 items-center self-start">
              <span aria-hidden="true" className="bg-foreground size-2 rounded-full" />
            </ItemMedia>
            <ItemContent>
              <ItemTitle>{event.title}</ItemTitle>
              <ItemDescription className="tabular-nums">{formatRange(event)}</ItemDescription>
            </ItemContent>
          </Item>
        ))
      )}
    </section>
  )
}

export default Calendar8DayPanel
```

### `storage/app/registry-sources/application/calendar/calendar-8/calendar-8-data.ts`

```ts
/**
 * The free block's fixture. Deliberately small: one list of events, one label per event, no
 * calendars to filter, no attendees, no rooms. Everything the paid blocks add lives there, not here.
 */
export interface SimpleEvent {
  id: string
  title: string
  /** Day offset from today, so the month always has something in it whenever it is opened. */
  dayOffset: number
  /** Decimal hours, so 9.5 is 09:30. Keeps the formatting maths readable. */
  start: number
  end: number
  allDay?: boolean
}

export const simpleEvents: SimpleEvent[] = [
  { id: 'v1', title: 'Release review', dayOffset: 0, start: 10, end: 11 },
  { id: 'v2', title: 'ShadcnStore call', dayOffset: 0, start: 15, end: 16 },
  { id: 'v3', title: 'Design critique', dayOffset: 1, start: 11, end: 12 },
  { id: 'v4', title: 'Deep work', dayOffset: 1, start: 13, end: 16 },
  { id: 'v5', title: 'Roadmap planning', dayOffset: 2, start: 10, end: 12 },
  { id: 'v6', title: 'Onboarding: Priya', dayOffset: 3, start: 14, end: 14.75 },
  { id: 'v7', title: 'All hands', dayOffset: 4, start: 11, end: 12 },
  { id: 'v8', title: 'Support handover', dayOffset: 4, start: 16.5, end: 17 },
  { id: 'v9', title: 'Parkrun', dayOffset: 5, start: 8, end: 9 },
  { id: 'v10', title: 'Quarterly review', dayOffset: 8, start: 13, end: 15 },
  { id: 'v11', title: 'Security review', dayOffset: 9, start: 16.5, end: 17.5 },
  { id: 'v12', title: 'Offsite', dayOffset: 11, start: 0, end: 24, allDay: true },
  { id: 'v13', title: 'Pricing workshop', dayOffset: 14, start: 14, end: 16 },
  { id: 'v14', title: 'Renewal: Halcyon', dayOffset: 16, start: 11, end: 11.75 },
  { id: 'v15', title: 'Incident retro', dayOffset: 18, start: 15, end: 16 },
  { id: 'v16', title: 'Board update', dayOffset: -3, start: 10, end: 11.5 },
  { id: 'v17', title: 'Vendor review', dayOffset: -5, start: 15, end: 16 },
  { id: 'v18', title: 'Dentist', dayOffset: -8, start: 8, end: 8.75 },
  { id: 'v19', title: 'Team lunch', dayOffset: -11, start: 12.5, end: 13.5 },
]

export const toDateString = (date: Date) =>
  `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`

export const formatRange = (event: SimpleEvent) => {
  if (event.allDay) {
    return 'All day'
  }

  const format = (value: number) => {
    const hours = Math.floor(value)
    const minutes = Math.round((value - hours) * 60)
    return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
  }

  return `${format(event.start)} – ${format(event.end)}`
}

/** The compact time a month cell has room for: `9a`, `9:15a`, `4:30p`. */
export const compactTime = (value: number) => {
  const hours = Math.floor(value)
  const minutes = Math.round((value - hours) * 60)
  const suffix = hours >= 12 ? 'p' : 'a'
  const display = hours % 12 === 0 ? 12 : hours % 12
  return minutes === 0 ? `${display}${suffix}` : `${display}:${String(minutes).padStart(2, '0')}${suffix}`
}
```
