- Accordion
- Agent Thinking
- Alert
- Alert Dialog
- Announcement
- Aspect Ratio
- Attachment
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Checkbox
- Checkbox Card
- Code Block
- Collapsible
- Color Picker
- Combobox
- Command
- Composer
- Composer Loader
- Context Menu
- Data Grid
- Data Table
- Date Picker
- Dialog
- Direction
- Drawer
- Dropdown Menu
- Empty
- Field
- File Upload
- Hover Card
- Input
- Input Group
- Input Otp
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Meter
- Native Select
- Navigation Menu
- Number Field
- Pagination
- Period Switcher
- Popover
- Progress
- Questionnaire
- Radio Card
- Radio Group
- Resizable
- Reveal
- Scroll Area
- Search Field
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Social Button
- Sonner
- Spinner
- Status Dot
- Switch
- Switch Card
- Table
- Tabs
- Tag Group
- Textarea
- Theme Toggle
- Time Field
- Toggle
- Toggle Group
- Tooltip
- Trace
- Tree View
- Agent Creator 01
- Agent Limits Card 01
- Agent Progress 01
- AI Chat 01
- AI Image Generation 01
- AI Profile 01
- Alerts Card 01
- Auth Card 01
- Calendar 01
- Composer 01
- Composer Attachments 01
- Composer Panel 01
- Finance Dashboard 01
- Home Dashboard 01
- Marketing Dashboard 01
- Medical Profile 01
- Meeting Scheduler 01
- Multi Agent Chat 01
- Notification Center 01
- Patient Info Card 01
- Project Board 01
- Questionnaire 01
- Settings Modal 01
- Sidebar 01
- Stat Cards 01
- Task List 01
- Web Search 01
- Activity Rings Card 01
- Area Chart Card 01
- Bar List Card 01
- Combo Chart Card 01
- Contributions Card 01
- Earnings Chart Card 01
- Funnel Chart Card 01
- Heatmap Chart Card 01
- Line Chart Card 01
- Most Active Days Card 01
- Orders Chart Card 01
- Radar Chart Card 01
- Radial Chart Card 01
- Revenue Chart Card 01
- Sankey Chart Card 01
- Scatter Chart Card 01
- Sleep Score Card 01
- Stage Bars Card 01
- Steps Card 01
No date chosen yet
Installation
$ pnpm dlx shadcn@latest add @cobalt/date-picker
Source
"use client";
import { RiCalendarLine } from "@remixicon/react";
import { cn } from "cn";
import {
addDays,
endOfMonth,
endOfYear,
differenceInCalendarDays,
isSameDay,
format,
startOfMonth,
startOfYear,
subMonths,
subYears,
} from "date-fns";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import * as React from "react";
import type { DateRange } from "react-day-picker";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
interface CommonProps {
disabled?: boolean;
className?: string;
popoverClassName?: string;
"aria-label"?: string;
}
type DatePickerProps = CommonProps & {
/**
* `field` fills its container at input height, for forms. `filled` does the
* same with Input's borderless fill, for forms of Inputs and TimeFields.
*/
variant?: "default" | "field" | "filled";
id?: string;
value?: Date;
defaultValue?: Date;
onChange?: (value: Date | undefined) => void;
};
type DateRangePickerProps = CommonProps & {
value?: DateRange;
defaultValue?: DateRange;
onChange?: (value: DateRange | undefined) => void;
};
const triggerClass =
"inline-flex items-center gap-0.5 rounded-[10px] border border-border bg-popover p-2 text-sm font-medium text-foreground shadow-[shadow:var(--shadow-control,0_1px_2px_0_rgb(0_0_0/0.05))] outline-none transition-[background-color,border-color,box-shadow] duration-150 hover:bg-[var(--surface-hover,var(--muted))] hover:border-[var(--border-hover,var(--border))] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground disabled:shadow-none motion-reduce:transition-none";
const fieldTriggerClass =
"h-9 w-full justify-start gap-1.5 px-2.5 py-0 leading-5 [&_svg]:size-4 [&_span]:px-0";
const filledTriggerClass =
"rounded-[10px] border-0 bg-[var(--surface-strong,var(--muted))] font-normal shadow-none ring-2 ring-transparent ring-inset hover:bg-[var(--surface-strong,var(--muted))] hover:ring-[var(--border-hover,var(--border))] focus-visible:ring-2 focus-visible:ring-[var(--foreground-subtle,var(--ring))] focus-visible:ring-offset-0 disabled:bg-[var(--input-disabled-surface,var(--muted))] [&_svg]:text-[color:var(--foreground-subtle,var(--muted-foreground))]";
const popupClass =
"w-auto gap-0 rounded-[24px] bg-[var(--surface-subtle,var(--muted))] p-0 shadow-[shadow:var(--shadow-dropdown,0_1px_1px_0_rgb(0_0_0/0.04),0_4px_4px_0_rgb(0_0_0/0.02))] ring-0 origin-top transition-[opacity,scale,filter] duration-150 ease-out data-starting-style:scale-95 data-starting-style:opacity-0 data-starting-style:blur-[2px] data-ending-style:scale-95 data-ending-style:opacity-0 data-ending-style:blur-[2px] motion-reduce:transition-none";
// Opened from the keyboard, focus lands on the day button (the selected day, or
// today) so the arrow keys move between days. Pointer opens keep the default.
const focusDayWhenKeyboard = (openType: string) =>
openType === "keyboard"
? (document.querySelector<HTMLElement>(
'[data-slot="popover-content"] button[data-day][tabindex="0"]'
) ?? undefined)
: undefined;
function DateChip({
date,
label,
onCommit,
}: {
date: Date;
label: string;
onCommit: (date: Date) => void;
}) {
const formatted = format(date, "dd/MM/yyyy");
const [text, setText] = React.useState(formatted);
React.useEffect(() => setText(formatted), [formatted]);
function commit() {
const match = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(text.trim());
if (match) {
const day = Number(match[1]),
month = Number(match[2]),
year = Number(match[3]);
const next = new Date(year, month - 1, day);
if (
next.getFullYear() === year &&
next.getMonth() === month - 1 &&
next.getDate() === day
) {
onCommit(next);
return;
}
}
setText(formatted);
}
return (
<input
aria-label={label}
dir="ltr"
inputMode="numeric"
value={text}
onChange={(e) => setText(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.currentTarget.blur();
}
if (e.key === "Escape") {
setText(formatted);
}
}}
className="w-[104px] rounded-[10px] border border-border bg-popover px-2 py-2 text-sm font-medium text-foreground shadow-[shadow:var(--shadow-control,0_1px_2px_0_rgb(0_0_0/0.05))] outline-none transition-colors duration-100 ease-out focus-visible:border-ring motion-reduce:transition-none"
/>
);
}
function DatePicker({
variant = "default",
id,
value,
defaultValue,
onChange,
disabled,
className,
popoverClassName,
"aria-label": label = "Date",
}: DatePickerProps) {
const [internal, setInternal] = React.useState(defaultValue);
const selected = value === undefined ? internal : value;
const [pending, setPending] = React.useState(selected);
const [open, setOpen] = React.useState(false);
return (
<Popover
open={open}
onOpenChange={(next) => {
if (next) {
setPending(selected);
}
setOpen(next);
}}
>
<PopoverTrigger
id={id}
disabled={disabled}
className={cn(
triggerClass,
variant !== "default" && fieldTriggerClass,
variant === "filled" && filledTriggerClass,
className
)}
>
<RiCalendarLine className="size-5 shrink-0" aria-hidden />
<span className="px-1 whitespace-nowrap">
{selected ? format(selected, "MMM d, yyyy") : "Select date"}
</span>
</PopoverTrigger>
<PopoverContent
align={variant === "default" ? "end" : "start"}
aria-label={label}
initialFocus={focusDayWhenKeyboard}
className={cn(popupClass, popoverClassName)}
>
<div className="flex flex-col p-2 pb-3">
<Calendar
mode="single"
showOutsideDays={false}
selected={pending}
onSelect={setPending}
defaultMonth={pending}
/>
<div className="flex items-center justify-between gap-4 px-4 pt-3">
<div>
{pending && (
<DateChip date={pending} label="Date" onCommit={setPending} />
)}
</div>
<div className="flex gap-2.5">
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
disabled={!pending}
onClick={() => {
if (value === undefined) {
setInternal(pending);
}
onChange?.(pending);
setOpen(false);
}}
>
Apply
</Button>
</div>
</div>
</div>
</PopoverContent>
</Popover>
);
}
function DateRangePicker({
value,
defaultValue,
onChange,
disabled,
className,
popoverClassName,
"aria-label": label = "Date range",
}: DateRangePickerProps) {
const [internal, setInternal] = React.useState(defaultValue);
const selected = value === undefined ? internal : value;
const [pending, setPending] = React.useState(selected);
const [open, setOpen] = React.useState(false);
const reduceMotion = useReducedMotion();
const now = new Date();
const presets = [
["Today", now, now],
["Yesterday", addDays(now, -1), addDays(now, -1)],
["Last week", addDays(now, -7), addDays(now, -1)],
["This month", startOfMonth(now), endOfMonth(now)],
[
"Last month",
startOfMonth(subMonths(now, 1)),
endOfMonth(subMonths(now, 1)),
],
["This year", startOfYear(now), endOfYear(now)],
["Last year", startOfYear(subYears(now, 1)), endOfYear(subYears(now, 1))],
["All time", subYears(now, 10), now],
] as const;
return (
<Popover
open={open}
onOpenChange={(next) => {
if (next) {
setPending(selected);
}
setOpen(next);
}}
>
<PopoverTrigger
disabled={disabled}
className={cn(triggerClass, className)}
>
<RiCalendarLine className="size-5 shrink-0" aria-hidden />
<span className="px-1 whitespace-nowrap">
{selected?.from && selected.to
? `${format(selected.from, "MMM d, yyyy")} - ${format(selected.to, "MMM d, yyyy")}`
: "Select date range"}
</span>
</PopoverTrigger>
<PopoverContent
align="end"
aria-label={label}
initialFocus={focusDayWhenKeyboard}
className={cn(
popupClass,
"max-w-[calc(100vw-16px)] overflow-auto",
popoverClassName
)}
>
<div className="flex gap-3">
<div className="flex w-[118px] shrink-0 flex-col gap-1.5 pt-4 pl-4">
{presets.map(([name, from, to]) => (
<button
key={name}
type="button"
onClick={() => setPending({ from, to })}
aria-pressed={
!!pending?.from &&
!!pending.to &&
isSameDay(pending.from, from) &&
isSameDay(pending.to, to)
}
className="relative isolate w-full cursor-pointer rounded-[10px] px-2 py-1.5 text-start text-sm font-medium text-foreground transition-colors duration-150 ease-[ease] before:absolute before:inset-0 before:-z-10 before:rounded-[inherit] aria-pressed:before:bg-[var(--surface-strong,var(--muted))] hover:not-aria-pressed:bg-[var(--surface-strong,var(--muted))] motion-reduce:transition-none"
>
{name}
</button>
))}
</div>
<div className="flex flex-col pt-2 pr-2 pb-3">
<Calendar
mode="range"
showOutsideDays={false}
selected={pending}
onSelect={setPending}
defaultMonth={pending?.from}
numberOfMonths={2}
/>
<div className="flex items-center justify-between gap-4 pt-3 pr-4">
<div className="flex items-center gap-2.5">
<div className="flex items-center gap-[5px]">
{pending?.from && (
<DateChip
date={pending.from}
label="Start date"
onCommit={(from) =>
setPending({
from,
to:
pending.to && pending.to < from ? from : pending.to,
})
}
/>
)}
{pending?.to && (
<>
<span>–</span>
<DateChip
date={pending.to}
label="End date"
onCommit={(to) =>
setPending({
from:
pending.from && pending.from > to
? to
: pending.from,
to,
})
}
/>
</>
)}
</div>
<AnimatePresence>
{pending?.from && pending.to && (
<motion.span
key="range-summary"
initial={reduceMotion ? false : { opacity: 0, y: -12 }}
animate={{ opacity: 1, y: 0 }}
exit={
reduceMotion
? { opacity: 1, y: 0 }
: { opacity: 0, y: -12 }
}
transition={
reduceMotion
? { duration: 0 }
: { duration: 0.25, ease: [0.34, 1.2, 0.64, 1] }
}
className="rounded-[12px] bg-[var(--surface-strong,var(--muted))] px-2 py-2 text-sm font-medium whitespace-nowrap text-[var(--date-picker-summary-foreground,var(--muted-foreground))]"
aria-live="polite"
>
{(() => {
const days =
differenceInCalendarDays(pending.to, pending.from) +
1;
return `${days} day${days === 1 ? "" : "s"} selected`;
})()}
</motion.span>
)}
</AnimatePresence>
</div>
<div className="flex gap-2.5">
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
disabled={!pending?.from || !pending.to}
onClick={() => {
if (value === undefined) {
setInternal(pending);
}
onChange?.(pending);
setOpen(false);
}}
>
Apply
</Button>
</div>
</div>
</div>
</div>
</PopoverContent>
</Popover>
);
}
export { DatePicker, DateRangePicker };
export type { DatePickerProps, DateRangePickerProps }; On this page