Aller au contenu

Calendrier

Un sélecteur de dates sous forme de grille mensuelle pour les réservations, la planification et la sélection de plages horaires — avec un style néobrutaliste caractérisé par des bordures et des ombres.

"use client"

import * as React from "react"

Le calendrier affiche une grille mensuelle pour choisir une date unique, plusieurs dates ou une plage. Construit sur React DayPicker — les deux variantes enveloppent la même bibliothèque — et habillé du style néobrutaliste : bordures épaisses, ombres marquées et typographie affirmée.

À privilégier pour :

  • Champs de date de formulaire — associez-le à un popover pour construire un Date Picker pour les anniversaires, les échéances et les dates de livraison.
  • Parcours de réservationmode="range" pour l’arrivée et le départ, disabled pour les jours déjà réservés.
  • Filtres de tableau de bord — plages de dates de rapports, vues de disponibilité et raccourcis prédéfinis comme « les 7 derniers jours ».

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

Installez les dépendances suivantes :

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

Ajoutez le composant Button à votre projet.

Le composant Calendar utilise le composant Button. Assurez-vous de l’avoir installé dans votre projet.

Copiez et collez le code suivant dans votre projet.

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 }

Adaptez les chemins d’import à la structure de votre projet.

Utilisation

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"
  />
)

Consultez la documentation React DayPicker pour en savoir plus.

À propos

Le composant Calendar repose sur React DayPicker.

Date Picker

Vous pouvez utiliser le composant <Calendar> pour construire un sélecteur de date. Consultez la page Date Picker pour en savoir plus.

Calendrier persan / hijri / jalali

Pour utiliser le calendrier persan, modifiez components/ui/calendar.tsx et remplacez react-day-picker par react-day-picker/persian.

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

import * as React from "react"

Date sélectionnée (avec fuseau horaire)

Le composant Calendar accepte une prop timeZone pour garantir que les dates sont affichées et sélectionnées dans le fuseau horaire local de l’utilisateur.

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}
    />
  )
}

Remarque : Si vous constatez un décalage de la date sélectionnée (par exemple, sélectionner le 20 surligne le 19), assurez-vous que la prop timeZone correspond au fuseau horaire local de l’utilisateur.

Pourquoi côté client ? Le fuseau horaire est détecté via Intl.DateTimeFormat().resolvedOptions().timeZone dans un useEffect afin de garantir la compatibilité avec le rendu côté serveur. Détecter le fuseau horaire pendant le rendu provoquerait des incohérences d’hydratation, car le serveur et le client peuvent se trouver dans des fuseaux horaires différents.

Exemples

Simple

Un composant calendrier simple. Nous avons utilisé className="rounded border-2 shadow-md" pour styliser le calendrier.

"use client"

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

Calendrier de plage

Utilisez la prop mode="range" pour activer la sélection de plage.

"use client"

import * as React from "react"

Sélecteur de mois et d’année

Utilisez captionLayout="dropdown" pour afficher des menus déroulants de mois et d’année.

"use client"

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

Préréglages

"use client"

import * as React from "react"

Sélecteur de date et d’heure

"use client"

import * as React from "react"

Dates réservées

"use client"

import * as React from "react"

Taille de cellule personnalisée

"use client"

import * as React from "react"

Vous pouvez personnaliser la taille des cellules du calendrier à l’aide de la variable CSS --cell-size. Vous pouvez aussi la rendre responsive en utilisant des valeurs spécifiques à chaque point de rupture :

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

Ou utilisez des valeurs fixes :

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

Numéros de semaine

Utilisez showWeekNumber pour afficher les numéros de semaine.

"use client"

import * as React from "react"

RTL

Pour activer le RTL dans Neobrutalism, consultez le guide de configuration RTL.

Consultez également le guide hijri pour activer le calendrier persan / hijri / jalali.

"use client"

import * as React from "react"

En RTL, importez la locale depuis react-day-picker/locale et passez à la fois les props locale et dir au composant Calendar :

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

Accessibilité

Le calendrier reprend la navigation en grille du modèle WAI-ARIA Date Picker Dialog : React DayPicker rend le mois en role="grid" où chaque jour est un vrai bouton, et annonce les jours sélectionnés et désactivés aux lecteurs d’écran. Les deux variantes partagent ce comportement puisqu’elles enveloppent la même bibliothèque.

Interactions clavier :

ToucheAction
Tab / Shift + TabDéplace le focus entre les boutons de navigation et la grille des jours
ArrowLeft / ArrowRightDéplace le focus vers le jour précédent / suivant
ArrowUp / ArrowDownDéplace le focus vers le même jour de la semaine précédente / suivante
Home / EndDéplace le focus vers le premier / dernier jour de la semaine
PageUp / PageDownPasse au mois précédent / suivant
Shift + PageUp / Shift + PageDownPasse à l’année précédente / suivante
Space / EnterSélectionne le jour ayant le focus

Référence API

Consultez la documentation React DayPicker pour en savoir plus sur le composant Calendar.

Changelog

Support RTL

Si vous effectuez une mise à niveau depuis une version précédente du composant Calendar, vous devrez appliquer les modifications suivantes pour prendre en charge les locales :

Importez le type Locale.

Ajoutez Locale à vos imports depuis react-day-picker :

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

Ajoutez la prop locale au composant Calendar.

Ajoutez la prop locale aux props du composant :

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

Passez locale à DayPicker.

Passez la prop locale au composant DayPicker :

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

Mettez à jour CalendarDayButton pour accepter locale.

Mettez à jour la signature du composant CalendarDayButton et passez locale :

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

Mettez à jour le formatage des dates dans CalendarDayButton.

Utilisez locale?.code dans le formatage des dates :

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

Passez locale au composant DayButton.

Mettez à jour l’utilisation du composant DayButton pour passer la prop locale :

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

Mettez à jour les classes CSS compatibles RTL.

Remplacez les classes directionnelles par des propriétés logiques pour une meilleure compatibilité RTL :

  // 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)

Une fois ces modifications appliquées, vous pouvez utiliser la prop locale pour fournir un formatage spécifique à la locale :

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