Zum Inhalt springen

Kalender

Ein Datumswähler im Monatsraster für Buchungen, Terminplanung und Bereichsauswahl – mit dem neobrutalistischen Design aus Rahmen und Schatten.

"use client"

import * as React from "react"

Der Kalender rendert ein Monatsraster zur Auswahl eines einzelnen Datums, mehrerer Daten oder eines Zeitraums. Er basiert auf React DayPicker – beide Backend-Varianten umhüllen dieselbe Bibliothek – und folgt dem neobrutalistischen Rezept: dicke Rahmen, harte Schatten und kräftige Typografie.

Die richtige Wahl für:

  • Datumsfelder in Formularen – kombiniere ihn mit einem Popover zu einem Date Picker für Geburtstage, Deadlines und Liefertermine.
  • Buchungsstreckenmode="range" für Check-in/Check-out, disabled für bereits belegte Tage.
  • Dashboard-Filter – Berichtszeiträume, Verfügbarkeitsansichten und Schnellauswahlen wie „letzte 7 Tage“.

Installation

pnpm dlx shadcn@latest add https://neobrutalism.com/r/base/calendar.json
npx shadcn@latest add https://neobrutalism.com/r/base/calendar.json
yarn dlx shadcn@latest add https://neobrutalism.com/r/base/calendar.json
bunx --bun shadcn@latest add https://neobrutalism.com/r/base/calendar.json

Installiere die folgenden Abhängigkeiten:

pnpm add react-day-picker date-fns
npm install react-day-picker date-fns
yarn add react-day-picker date-fns
bun add react-day-picker date-fns

Füge die Button-Komponente zu deinem Projekt hinzu.

Die Calendar-Komponente verwendet die Button-Komponente. Stelle sicher, dass sie in deinem Projekt installiert ist.

Kopiere den folgenden Code und füge ihn in dein Projekt ein.

components/ui/calendar.tsx
"use client"

import * as React from "react"
import {
  ChevronDownIcon,
  ChevronLeftIcon,
  ChevronRightIcon,
} from "lucide-react"
import {
  DayPicker,
  getDefaultClassNames,
  type DayButton,
  type Locale,
} from "react-day-picker"

import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"

function Calendar({
  className,
  classNames,
  showOutsideDays = true,
  fixedWeeks = true,
  captionLayout = "label",
  buttonVariant = "ghost",
  locale,
  formatters,
  components,
  ...props
}: React.ComponentProps<typeof DayPicker> & {
  buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
  const defaultClassNames = getDefaultClassNames()

  return (
    <DayPicker
      showOutsideDays={showOutsideDays}
      fixedWeeks={fixedWeeks}
      className={cn(
        "group/calendar bg-card p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(8)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
        String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
        String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
        className
      )}
      captionLayout={captionLayout}
      locale={locale}
      formatters={{
        formatMonthDropdown: (date, dateLib) =>
          dateLib
            ? dateLib.format(date, "LLL")
            : date.toLocaleString(locale?.code, { month: "short" }),
        ...formatters,
      }}
      classNames={{
        root: cn("w-fit", defaultClassNames.root),
        months: cn(
          "relative flex flex-col gap-4 md:flex-row",
          defaultClassNames.months
        ),
        month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
        nav: cn(
          "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
          defaultClassNames.nav
        ),
        button_previous: cn(
          buttonVariants({ variant: buttonVariant }),
          "size-(--cell-size) rounded border-2 p-0 shadow-sm select-none aria-disabled:opacity-50",
          defaultClassNames.button_previous
        ),
        button_next: cn(
          buttonVariants({ variant: buttonVariant }),
          "size-(--cell-size) rounded border-2 p-0 shadow-sm select-none aria-disabled:opacity-50",
          defaultClassNames.button_next
        ),
        month_caption: cn(
          "flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
          defaultClassNames.month_caption
        ),
        dropdowns: cn(
          "flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
          defaultClassNames.dropdowns
        ),
        dropdown_root: cn(
          "cn-calendar-dropdown-root relative rounded-(--cell-radius)",
          defaultClassNames.dropdown_root
        ),
        dropdown: cn(
          "absolute inset-0 bg-popover opacity-0",
          defaultClassNames.dropdown
        ),
        caption_label: cn(
          "font-head font-medium select-none",
          captionLayout === "label"
            ? "cn-calendar-caption text-sm"
            : "cn-calendar-caption-label flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
          defaultClassNames.caption_label
        ),
        month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
        weekdays: cn("flex", defaultClassNames.weekdays),
        weekday: cn(
          "flex w-(--cell-size) items-center justify-center rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
          defaultClassNames.weekday
        ),
        week: cn("mt-2 flex w-full", defaultClassNames.week),
        week_number_header: cn(
          "w-(--cell-size) select-none",
          defaultClassNames.week_number_header
        ),
        week_number: cn(
          "text-[0.8rem] text-muted-foreground select-none",
          defaultClassNames.week_number
        ),
        day: cn(
          "group/day relative size-(--cell-size) rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
          props.showWeekNumber
            ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
            : "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
          defaultClassNames.day
        ),
        range_start: cn(
          "relative isolate z-0 rounded-l-(--cell-radius) bg-primary/30 after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-primary/30",
          defaultClassNames.range_start
        ),
        range_middle: cn("rounded-none", defaultClassNames.range_middle),
        range_end: cn(
          "relative isolate z-0 rounded-r-(--cell-radius) bg-primary/30 after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-primary/30",
          defaultClassNames.range_end
        ),
        today: cn(
          "rounded-(--cell-radius) border-2 border-primary text-foreground data-[selected=true]:rounded-none",
          defaultClassNames.today
        ),
        outside: cn(
          "text-muted-foreground aria-selected:text-muted-foreground",
          defaultClassNames.outside
        ),
        disabled: cn(
          "text-muted-foreground opacity-50",
          defaultClassNames.disabled
        ),
        hidden: cn("invisible", defaultClassNames.hidden),
        ...classNames,
      }}
      components={{
        Root: ({ className, rootRef, ...props }) => {
          return (
            <div
              data-slot="calendar"
              ref={rootRef}
              className={cn(className)}
              {...props}
            />
          )
        },
        Chevron: ({ className, orientation, ...props }) => {
          if (orientation === "left") {
            return (
              <ChevronLeftIcon
                className={cn("cn-rtl-flip size-4", className)}
                {...props}
              />
            )
          }

          if (orientation === "right") {
            return (
              <ChevronRightIcon
                className={cn("cn-rtl-flip size-4", className)}
                {...props}
              />
            )
          }

          return (
            <ChevronDownIcon className={cn("size-4", className)} {...props} />
          )
        },
        DayButton: ({ ...props }) => (
          <CalendarDayButton locale={locale} {...props} />
        ),
        WeekNumber: ({ children, ...props }) => {
          return (
            <td {...props}>
              <div className="flex size-(--cell-size) items-center justify-center text-center">
                {children}
              </div>
            </td>
          )
        },
        ...components,
      }}
      {...props}
    />
  )
}

function CalendarDayButton({
  className,
  day,
  modifiers,
  locale,
  ...props
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
  const defaultClassNames = getDefaultClassNames()

  const ref = React.useRef<HTMLButtonElement>(null)
  React.useEffect(() => {
    if (modifiers.focused) ref.current?.focus()
  }, [modifiers.focused])

  return (
    <Button
      ref={ref}
      variant="ghost"
      size="icon"
      data-day={`${day.date.getFullYear()}-${String(
        day.date.getMonth() + 1
      ).padStart(2, "0")}-${String(day.date.getDate()).padStart(2, "0")}`}
      data-selected-single={
        modifiers.selected &&
        !modifiers.range_start &&
        !modifiers.range_end &&
        !modifiers.range_middle
      }
      data-range-start={modifiers.range_start}
      data-range-end={modifiers.range_end}
      data-range-middle={modifiers.range_middle}
      className={cn(
        "relative isolate z-10 flex size-(--cell-size) flex-col items-center justify-center gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:outline-2 group-data-[focused=true]/day:outline-offset-2 group-data-[focused=true]/day:outline-primary data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:border-2 data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-primary/30 data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:border-2 data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:border-2 data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
        defaultClassNames.day,
        className
      )}
      {...props}
    />
  )
}

export { Calendar, CalendarDayButton }

Passe die Import-Pfade an die Struktur deines Projekts an.

Verwendung

import { Calendar } from "@/components/ui/calendar"
const [date, setDate] = React.useState<Date | undefined>(new Date())
 
return (
  <Calendar
    mode="single"
    selected={date}
    onSelect={setDate}
    className="rounded border-2 shadow-md"
  />
)

Weitere Informationen findest du in der React DayPicker-Dokumentation.

Über

Die Calendar-Komponente ist auf React DayPicker aufgebaut.

Date Picker

Du kannst die <Calendar>-Komponente verwenden, um einen Datumsauswähler zu bauen. Weitere Informationen findest du auf der Date Picker-Seite.

Persischer / Hijri / Jalali Kalender

Um den persischen Kalender zu verwenden, bearbeite components/ui/calendar.tsx und ersetze react-day-picker durch react-day-picker/persian.

- import { DayPicker } from "react-day-picker"
+ import { DayPicker } from "react-day-picker/persian"
"use client"

import * as React from "react"

Ausgewähltes Datum (mit Zeitzone)

Die Calendar-Komponente akzeptiert eine Prop timeZone, um sicherzustellen, dass Daten in der lokalen Zeitzone des Nutzers angezeigt und ausgewählt werden.

export function CalendarWithTimezone() {
  const [date, setDate] = React.useState<Date | undefined>(undefined)
  const [timeZone, setTimeZone] = React.useState<string | undefined>(undefined)
 
  React.useEffect(() => {
    setTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone)
  }, [])
 
  return (
    <Calendar
      mode="single"
      selected={date}
      onSelect={setDate}
      timeZone={timeZone}
    />
  )
}

Hinweis: Wenn dir ein Versatz beim ausgewählten Datum auffällt (zum Beispiel wird beim Auswählen des 20. der 19. hervorgehoben), stelle sicher, dass die Prop timeZone auf die lokale Zeitzone des Nutzers gesetzt ist.

Warum clientseitig? Die Zeitzone wird mit Intl.DateTimeFormat().resolvedOptions().timeZone innerhalb eines useEffect erkannt, um die Kompatibilität mit serverseitigem Rendering sicherzustellen. Die Zeitzone während des Renderings zu erkennen würde zu Hydration-Diskrepanzen führen, da Server und Client in unterschiedlichen Zeitzonen liegen können.

Beispiele

Basis

Eine einfache Kalender-Komponente. Wir haben className="rounded border-2 shadow-md" verwendet, um den Kalender zu gestalten.

"use client"

import { Calendar } from "@/components/ui/calendar"

Bereichskalender

Verwende die Prop mode="range", um die Bereichsauswahl zu aktivieren.

"use client"

import * as React from "react"

Monats- und Jahresauswahl

Verwende captionLayout="dropdown", um Dropdowns für Monat und Jahr anzuzeigen.

"use client"

import { Calendar } from "@/components/ui/calendar"

Presets

"use client"

import * as React from "react"

Datums- und Zeitauswahl

"use client"

import * as React from "react"

Gebuchte Daten

"use client"

import * as React from "react"

Benutzerdefinierte Zellengröße

"use client"

import * as React from "react"

Du kannst die Größe der Kalenderzellen mit der CSS-Variable --cell-size anpassen. Du kannst sie auch responsiv machen, indem du breakpoint-spezifische Werte verwendest:

<Calendar
  mode="single"
  selected={date}
  onSelect={setDate}
  className="rounded border-2 shadow-md [--cell-size:--spacing(11)] md:[--cell-size:--spacing(12)]"
/>

Oder verwende feste Werte:

<Calendar
  mode="single"
  selected={date}
  onSelect={setDate}
  className="rounded border-2 shadow-md [--cell-size:2.75rem] md:[--cell-size:3rem]"
/>

Kalenderwochen

Verwende showWeekNumber, um Kalenderwochen anzuzeigen.

"use client"

import * as React from "react"

RTL

Um die RTL-Unterstützung in Neobrutalism zu aktivieren, siehe die RTL-Konfigurationsanleitung.

Siehe auch die Hijri-Anleitung, um den persischen / Hijri / Jalali Kalender zu aktivieren.

"use client"

import * as React from "react"

Wenn du RTL verwendest, importiere die Locale aus react-day-picker/locale und übergib der Calendar-Komponente sowohl die Prop locale als auch die Prop dir:

import { arSA } from "react-day-picker/locale"
 
;<Calendar
  mode="single"
  selected={date}
  onSelect={setDate}
  locale={arSA}
  dir="rtl"
/>

Barrierefreiheit

Der Kalender folgt dem Grid-Navigationsmodell aus dem WAI-ARIA-Date-Picker-Dialog-Pattern: React DayPicker rendert den Monat als role="grid" mit jedem Tag als echtem Button und kündigt ausgewählte wie deaktivierte Tage für Screenreader an. Beide Backend-Varianten teilen dieses Verhalten, da sie dieselbe Bibliothek umhüllen.

Tastaturinteraktionen:

TasteAktion
Tab / Shift + TabBewegt den Fokus zwischen den Navigationsbuttons und dem Tagesraster
ArrowLeft / ArrowRightBewegt den Fokus zum vorherigen / nächsten Tag
ArrowUp / ArrowDownBewegt den Fokus zum selben Wochentag der vorherigen / nächsten Woche
Home / EndBewegt den Fokus zum ersten / letzten Tag der Woche
PageUp / PageDownWechselt zum vorherigen / nächsten Monat
Shift + PageUp / Shift + PageDownWechselt zum vorherigen / nächsten Jahr
Space / EnterWählt den fokussierten Tag aus

API-Referenz

Weitere Informationen zur Calendar-Komponente findest du in der React DayPicker-Dokumentation.

Changelog

RTL-Unterstützung

Wenn du von einer früheren Version der Calendar-Komponente aktualisierst, musst du die folgenden Änderungen anwenden, um Locale-Unterstützung hinzuzufügen:

Importiere den Typ Locale.

Füge Locale zu deinen Imports aus react-day-picker hinzu:

  import {
    DayPicker,
    getDefaultClassNames,
    type DayButton,
+   type Locale,
  } from "react-day-picker"

Füge die Prop locale zur Calendar-Komponente hinzu.

Füge die Prop locale zu den Props der Komponente hinzu:

  function Calendar({
    className,
    classNames,
    showOutsideDays = true,
    captionLayout = "label",
    buttonVariant = "ghost",
+   locale,
    formatters,
    components,
    ...props
  }: React.ComponentProps<typeof DayPicker> & {
    buttonVariant?: React.ComponentProps<typeof Button>["variant"]
  }) {

Übergib locale an DayPicker.

Übergib die Prop locale an die DayPicker-Komponente:

    <DayPicker
      showOutsideDays={showOutsideDays}
      className={cn(...)}
      captionLayout={captionLayout}
+     locale={locale}
      formatters={{
        formatMonthDropdown: (date) =>
-         date.toLocaleString("default", { month: "short" }),
+         date.toLocaleString(locale?.code, { month: "short" }),
        ...formatters,
      }}

Aktualisiere CalendarDayButton, damit es locale akzeptiert.

Aktualisiere die Signatur der CalendarDayButton-Komponente und übergib locale:

  function CalendarDayButton({
    className,
    day,
    modifiers,
+   locale,
    ...props
- }: React.ComponentProps<typeof DayButton>) {
+ }: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {

Aktualisiere die Datumsformatierung in CalendarDayButton.

Verwende locale?.code bei der Datumsformatierung:

    <Button
      variant="ghost"
      size="icon"
-     data-day={day.date.toLocaleDateString()}
+     data-day={day.date.toLocaleDateString(locale?.code)}
      ...
    />

Übergib locale an die DayButton-Komponente.

Aktualisiere die Verwendung der DayButton-Komponente, um die Prop locale zu übergeben:

      components={{
        ...
-       DayButton: CalendarDayButton,
+       DayButton: ({ ...props }) => (
+         <CalendarDayButton locale={locale} {...props} />
+       ),
        ...
      }}

Aktualisiere RTL-fähige CSS-Klassen.

Ersetze richtungsabhängige Klassen durch logische Eigenschaften für bessere RTL-Unterstützung:

  // In the day classNames:
- [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)
+ [&:last-child[data-selected=true]_button]:rounded-e-(--cell-radius)
- [&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)
+ [&:nth-child(2)[data-selected=true]_button]:rounded-s-(--cell-radius)
- [&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)
+ [&:first-child[data-selected=true]_button]:rounded-s-(--cell-radius)
 
  // In range_start classNames:
- rounded-l-(--cell-radius) ... after:right-0
+ rounded-s-(--cell-radius) ... after:end-0
 
  // In range_end classNames:
- rounded-r-(--cell-radius) ... after:left-0
+ rounded-e-(--cell-radius) ... after:start-0
 
  // In CalendarDayButton className:
- data-[range-end=true]:rounded-r-(--cell-radius)
+ data-[range-end=true]:rounded-e-(--cell-radius)
- data-[range-start=true]:rounded-l-(--cell-radius)
+ data-[range-start=true]:rounded-s-(--cell-radius)

Nachdem du diese Änderungen angewendet hast, kannst du die Prop locale verwenden, um eine locale-spezifische Formatierung bereitzustellen:

import { enUS } from "react-day-picker/locale"
 
;<Calendar mode="single" selected={date} onSelect={setDate} locale={enUS} />