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éservation —
mode="range"pour l’arrivée et le départ,disabledpour 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.jsonnpx shadcn@latest add https://neobrutalism.com/r/base/calendar.jsonyarn dlx shadcn@latest add https://neobrutalism.com/r/base/calendar.jsonbunx --bun shadcn@latest add https://neobrutalism.com/r/base/calendar.json
Installez les dépendances suivantes :
pnpm add react-day-picker date-fnsnpm install react-day-picker date-fnsyarn add react-day-picker date-fnsbun 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.
"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 :
| Touche | Action |
|---|---|
Tab / Shift + Tab | Déplace le focus entre les boutons de navigation et la grille des jours |
ArrowLeft / ArrowRight | Déplace le focus vers le jour précédent / suivant |
ArrowUp / ArrowDown | Déplace le focus vers le même jour de la semaine précédente / suivante |
Home / End | Déplace le focus vers le premier / dernier jour de la semaine |
PageUp / PageDown | Passe au mois précédent / suivant |
Shift + PageUp / Shift + PageDown | Passe à l’année précédente / suivante |
Space / Enter | Sé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} />