Lịch
Công cụ chọn ngày theo dạng lưới tháng dành cho đặt chỗ, lập lịch và chọn khoảng thời gian — với phong cách thiết kế theo trường phái tân thô mộc, kết hợp đường viền và bóng đổ.
"use client"
import * as React from "react"Calendar render một lưới tháng để chọn một ngày, nhiều ngày hoặc một khoảng. Được xây dựng trên React DayPicker — cả hai biến thể backend đều bọc cùng một thư viện — và tạo kiểu theo công thức neobrutalist: viền dày, bóng đổ đậm và kiểu chữ đậm.
Nên dùng khi:
- Trường ngày trong form — ghép nó với một popover để dựng một Date Picker cho ngày sinh, hạn chót và ngày giao hàng.
- Luồng đặt chỗ —
mode="range"cho ngày nhận / ngày trả,disabledcho những ngày đã có người đặt. - Bộ lọc dashboard — khoảng ngày cho báo cáo, khung xem tình trạng còn trống và các lối tắt sẵn có như “7 ngày qua”.
Cài đặt#
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
Cài đặt các dependency sau:
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
Thêm thành phần Button vào dự án của bạn.
Thành phần Calendar dùng thành phần Button. Hãy đảm bảo bạn đã cài đặt nó trong dự án của mình.
Sao chép và dán đoạn mã sau vào dự án của bạn.
"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 }
Cập nhật các đường dẫn import cho khớp với cấu trúc dự án của bạn.
Cách dùng#
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"
/>
)Xem tài liệu React DayPicker để biết thêm thông tin.
Giới thiệu#
Thành phần Calendar được xây dựng trên React DayPicker.
Date Picker#
Bạn có thể dùng thành phần <Calendar> để dựng một bộ chọn ngày. Xem trang Date Picker để biết thêm thông tin.
Lịch Ba Tư / Hijri / Jalali#
Để dùng lịch Ba Tư, hãy chỉnh sửa components/ui/calendar.tsx và thay react-day-picker bằng react-day-picker/persian.
- import { DayPicker } from "react-day-picker"
+ import { DayPicker } from "react-day-picker/persian""use client"
import * as React from "react"Ngày đã chọn (Có múi giờ)#
Thành phần Calendar nhận một prop timeZone để đảm bảo các ngày được hiển thị và chọn theo múi giờ cục bộ của người dùng.
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}
/>
)
}Lưu ý: Nếu bạn thấy ngày đã chọn bị lệch (ví dụ, chọn ngày 20 lại làm nổi bật ngày 19), hãy đảm bảo prop timeZone được đặt theo múi giờ cục bộ của người dùng.
Vì sao ở phía client? Múi giờ được phát hiện bằng Intl.DateTimeFormat().resolvedOptions().timeZone bên trong một useEffect để đảm bảo tương thích với render phía máy chủ. Việc phát hiện múi giờ trong lúc render sẽ gây lệch hydration, vì máy chủ và client có thể ở các múi giờ khác nhau.
Ví dụ#
Cơ bản#
Một thành phần lịch cơ bản. Chúng tôi đã dùng className="rounded border-2 shadow-md" để tạo kiểu cho lịch.
"use client"
import { Calendar } from "@/components/ui/calendar"Lịch khoảng#
Dùng prop mode="range" để bật việc chọn khoảng.
"use client"
import * as React from "react"Bộ chọn tháng và năm#
Dùng captionLayout="dropdown" để hiển thị các dropdown tháng và năm.
"use client"
import { Calendar } from "@/components/ui/calendar"Preset#
"use client"
import * as React from "react"Bộ chọn ngày và giờ#
"use client"
import * as React from "react"Ngày đã đặt#
"use client"
import * as React from "react"Kích thước ô tùy chỉnh#
"use client"
import * as React from "react"Bạn có thể tùy chỉnh kích thước các ô lịch bằng biến CSS --cell-size. Bạn cũng có thể làm cho nó responsive bằng cách dùng các giá trị riêng cho từng breakpoint:
<Calendar
mode="single"
selected={date}
onSelect={setDate}
className="rounded border-2 shadow-md [--cell-size:--spacing(11)] md:[--cell-size:--spacing(12)]"
/>Hoặc dùng các giá trị cố định:
<Calendar
mode="single"
selected={date}
onSelect={setDate}
className="rounded border-2 shadow-md [--cell-size:2.75rem] md:[--cell-size:3rem]"
/>Số tuần#
Dùng showWeekNumber để hiển thị số tuần.
"use client"
import * as React from "react"RTL#
Để bật hỗ trợ RTL trong Neobrutalism, xem hướng dẫn cấu hình RTL.
Xem thêm hướng dẫn Hijri để bật lịch Ba Tư / Hijri / Jalali.
"use client"
import * as React from "react"Khi dùng RTL, hãy import locale từ react-day-picker/locale và truyền cả prop locale lẫn prop dir cho thành phần Calendar:
import { arSA } from "react-day-picker/locale"
;<Calendar
mode="single"
selected={date}
onSelect={setDate}
locale={arSA}
dir="rtl"
/>Khả năng truy cập#
Calendar tuân theo mô hình điều hướng dạng lưới của mẫu Date Picker Dialog của WAI-ARIA: React DayPicker render tháng thành role="grid" với mỗi ngày là một nút thật, và đọc lên cho trình đọc màn hình những ngày đã chọn cũng như bị vô hiệu hóa. Cả hai biến thể backend đều có hành vi này vì chúng bọc cùng một thư viện.
Tương tác bàn phím:
| Phím | Hành động |
|---|---|
Tab / Shift + Tab | Di chuyển focus giữa các nút điều hướng và lưới ngày |
ArrowLeft / ArrowRight | Di chuyển focus đến ngày trước đó / kế tiếp |
ArrowUp / ArrowDown | Di chuyển focus đến cùng thứ đó ở tuần trước / tuần sau |
Home / End | Di chuyển focus đến ngày đầu / cuối của tuần |
PageUp / PageDown | Chuyển sang tháng trước / tháng sau |
Shift + PageUp / Shift + PageDown | Chuyển sang năm trước / năm sau |
Space / Enter | Chọn ngày đang được focus |
Tham chiếu API#
Xem tài liệu React DayPicker để biết thêm thông tin về thành phần Calendar.
Nhật ký thay đổi#
Hỗ trợ RTL#
Nếu bạn đang nâng cấp từ một phiên bản trước của thành phần Calendar, bạn sẽ cần áp dụng các cập nhật sau để thêm hỗ trợ locale:
Import kiểu Locale.
Thêm Locale vào các import của bạn từ react-day-picker:
import {
DayPicker,
getDefaultClassNames,
type DayButton,
+ type Locale,
} from "react-day-picker"Thêm prop locale vào thành phần Calendar.
Thêm prop locale vào các prop của thành phần:
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
+ locale,
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {Truyền locale cho DayPicker.
Truyền prop locale cho thành phần 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,
}}Cập nhật CalendarDayButton để nhận locale.
Cập nhật chữ ký của thành phần CalendarDayButton và truyền locale:
function CalendarDayButton({
className,
day,
modifiers,
+ locale,
...props
- }: React.ComponentProps<typeof DayButton>) {
+ }: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {Cập nhật định dạng ngày trong CalendarDayButton.
Dùng locale?.code trong định dạng ngày:
<Button
variant="ghost"
size="icon"
- data-day={day.date.toLocaleDateString()}
+ data-day={day.date.toLocaleDateString(locale?.code)}
...
/>Truyền locale cho thành phần DayButton.
Cập nhật cách dùng thành phần DayButton để truyền prop locale:
components={{
...
- DayButton: CalendarDayButton,
+ DayButton: ({ ...props }) => (
+ <CalendarDayButton locale={locale} {...props} />
+ ),
...
}}Cập nhật các lớp CSS nhận biết RTL.
Thay các lớp theo hướng bằng các thuộc tính logic để hỗ trợ RTL tốt hơn:
// 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)Sau khi áp dụng các thay đổi này, bạn có thể dùng prop locale để cung cấp định dạng riêng cho từng locale:
import { enUS } from "react-day-picker/locale"
;<Calendar mode="single" selected={date} onSelect={setDate} locale={enUS} />