FewTone UI
ComponentsDocs
  • Home

Getting started

  • Introduction
  • Installation
  • MCP Server
  • Smooth UI Skill
  • Roadmap

Components46

  • Accordion
  • Alert Dialog
  • Avatar
  • Badge
  • Breadcrumb
  • Button
  • Calendar
  • Card
  • Checkbox
  • Combobox
  • Command
  • Context Menu
  • Copy Button
  • Date Picker
  • Dialog
  • Dropdown Menu
  • Hover Card
  • Input
  • Input OTP
  • Loading Button
  • Marquee
  • Navigation Menu
  • Number Input
  • Number Ticker
  • Pagination
  • Popover
  • Progress
  • Radio Group
  • Scroll Area
  • Select
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Spinner
  • Stepper
  • Switch
  • Switcher
  • Table
  • Tabs
  • Text Effects
  • Textarea
  • Theme Toggle
  • Toast
  • Toggle Group
  • Tooltip
© 2026 FewTone · MIT licenceGitHub
  1. Components
  2. Date Picker

Date Picker

pnpm dlx shadcn@latest add …/r/date-picker.json

An accessible React date picker for Next.js and Tailwind CSS: a calendar in a popover for a date or a date range, with presets and Intl formatting.

  • Date Picker
  • Date Range Picker
"use client"

import { useState } from "react"
import { DatePicker } from "@/components/ui/date-picker"

export function DueDate() {
  const [date, setDate] = useState<Date | undefined>()

  return (
    <div className="grid gap-2">
      <label htmlFor="due" className="text-sm font-medium">
        Due date
      </label>
      <DatePicker id="due" value={date} onChange={setDate} />
    </div>
  )
}
pickers.tsx
"use client";

import { DatePicker, DateRangePicker } from "@/components/ui/date-picker";

// Fixed days, made in the browser's time zone (a client module), so the server and the browser agree
const due = new Date(2026, 9, 20);
const stay = { from: new Date(2026, 9, 12), to: new Date(2026, 9, 18) };

/*
 * A date and a date range side by side, named underneath. Picking a day closes the first once the circle
 * lands; the second stays open while the band grows between the two days.
 */
export default function DatePickers() {
  return (
    <ul className="flex flex-wrap items-start justify-center gap-x-10 gap-y-6">
      <li className="flex flex-col items-center gap-2">
        <DatePicker aria-label="Due date" defaultValue={due} className="w-56" />
        <span className="text-xs text-muted-foreground">Date Picker</span>
      </li>
      <li className="flex flex-col items-center gap-2">
        <DateRangePicker aria-label="Stay" defaultValue={stay} className="w-64" />
        <span className="text-xs text-muted-foreground">Date Range Picker</span>
      </li>
    </ul>
  );
}

Installation

  1. 1

    Install the dependencies.

    Package manager
    $ pnpm add lucide-react motion
  2. 2

    Add the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.

    app/globals.css
    :root {
      --size-control-sm: 2rem;
      --size-control-md: 2.25rem;
      --size-control-lg: 2.5rem;
      --size-icon-sm: 0.875rem;
      --size-icon-md: 1rem;
      --size-icon-lg: 1.25rem;
      --size-roll-fade: 0.25em;
      --size-roll-fade-x: 0.5em;
      --duration-instant: 80ms;
      --duration-fast: 150ms;
      --duration-base: 220ms;
      --duration-slow: 350ms;
      --duration-slower: 500ms;
      --z-overlay: 1200;
      --z-modal: 1300;
      --z-popover: 1400;
      --z-toast: 1500;
      --z-tooltip: 1600;
      --focus-ring-width: 2px;
      --focus-ring-offset: 2px;
      --opacity-disabled: 0.5;
      --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1);
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent);
    }
    
    .dark {
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent);
    }
    
    @theme inline {
      --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both;
      --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both;
      --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1);
      --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both;
      --color-destructive-foreground: var(--destructive-foreground);
      --color-overlay: var(--overlay);
      @keyframes popover-in {
        from {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
        to {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
      }
      @keyframes popover-out {
        from {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
        to {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
      }
      @keyframes collapsible-open {
        from {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
        to {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
      }
      @keyframes collapsible-close {
        from {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
        to {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
      }
    }
    
    @media (prefers-reduced-motion: reduce) {
      :root {
        --duration-instant: 1ms;
        --duration-fast: 1ms;
        --duration-base: 1ms;
        --duration-slow: 1ms;
        --duration-slower: 1ms;
      }
    }
  3. 3

    Add the motion presets.

    lib/motion.ts
    import type { Transition } from "motion/react";
    
    /*
     * Motion tokens for JavaScript animations (motion/react).
     * Durations and easings mirror the CSS tokens in src/styles/tokens.css;
     * `pnpm check:tokens` fails if the two drift apart.
     */
    
    /** Durations in seconds. Mirrors --duration-* in tokens.css. */
    export const duration = {
      instant: 0.08,
      fast: 0.15,
      base: 0.22,
      slow: 0.35,
      slower: 0.5,
    } as const;
    
    /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */
    export const ease = {
      out: [0.22, 1, 0.36, 1],
      in: [0.55, 0, 1, 0.45],
      inOut: [0.65, 0, 0.35, 1],
    } as const;
    
    /**
     * Named springs: the library's motion voice.
     * - snappy: quick, almost no overshoot. Toggles, tabs, small controls.
     * - smooth: slow and settled. Panels, sidebars, page sections.
     * - bouncy: playful overshoot. Rare, for moments of delight.
     */
    export const spring = {
      snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 },
      smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 },
      bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 },
    } as const satisfies Record<string, Transition>;
    
    export type SpringName = keyof typeof spring;
    
    /**
     * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it
     * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills,
     * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only.
     * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`).
     */
    export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const;
    
    /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */
    export const swap = {
      initial: { opacity: 0, filter: blur.hidden, y: 6 },
      animate: { opacity: 1, filter: blur.shown, y: 0 },
      exit: { opacity: 0, filter: blur.hidden, y: -6 },
    } as const;
    
    /**
     * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits'
     * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in
     * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in
     * components/number-ticker/number-ticker.tsx).
     */
    export const flow = {
      transform: {
        duration: 900,
        easing:
          "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)",
      },
      opacity: { duration: 450, easing: "ease-out" },
    } as const satisfies Record<string, EffectTiming>;
  4. 4

    Add Calendar, Popover first.

  5. 5

    Copy the source into your project.

    components/ui/date-picker.tsx
    "use client";
    
    import {
      Calendar,
      type CalendarLabels,
      type DateRange,
      type Matcher,
      type Weekday,
    } from "@/components/ui/calendar";
    import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
    import { duration, ease, spring, swap } from "@/lib/motion";
    import { cn } from "@/lib/utils";
    import { CalendarIcon } from "lucide-react";
    import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react";
    import { type ComponentProps, type ReactNode, useEffect, useId, useRef, useState } from "react";
    
    /*
     * Date Picker and Date Range Picker (roadmap, 2026-10-16: "A calendar in a popover, for dates and ranges").
     * - Our Calendar in our Popover, opened from a trigger drawn like Select's: a calendar icon, then the date or
     *   a placeholder. The trigger is a combobox that pops up a dialog (the WAI-ARIA date picker pattern), so a
     *   <label htmlFor> names it and screen readers read the date as its value.
     * - Opening moves the focus to the picked day (or today), ready for the arrow keys. Escape, a click outside
     *   or the trigger closes it, and the focus goes back to the trigger.
     * - Motion (.agents/rules/motion.md): the popover opens from the trigger through the blur (Popover), and the
     *   trigger's text swaps through `swap` when the date changes. DatePicker closes a beat after a pick
     *   (duration.base), once the circle has landed on the new day. DateRangePicker stays open, so the band can
     *   be seen growing and the range picked again.
     * - Presets ("Today", "Next 7 days") are a small list beside the calendar, with one highlight gliding between
     *   them (Select's pill). Their values can be functions, worked out when picked, so nothing reads the clock
     *   while rendering (no hydration mismatch).
     * - Dates are formatted by Intl: dateStyle "medium" by default, and formatRange for ranges ("Oct 12 – 18, 2026").
     */
    
    export type DatePreset<T> = {
      label: string;
      /** The date (or range) it picks, or a function that works it out when it's picked */
      value: T | (() => T);
    };
    
    export type DatePickerLabels = CalendarLabels & {
      /** Names the popup, a dialog */
      dialog: string;
      /** Names the list of presets */
      presets: string;
    };
    
    const triggerSizes = {
      sm: "h-(--size-control-sm) rounded-md px-2.5",
      md: "h-(--size-control-md) px-3",
    } as const;
    
    type PickerProps = Omit<
      ComponentProps<"button">,
      "value" | "defaultValue" | "onChange" | "children" | "name"
    > & {
      /** Shown in the trigger while nothing is picked */
      placeholder?: string;
      size?: keyof typeof triggerSizes;
      /** Whether it's open, when you control it */
      open?: boolean;
      defaultOpen?: boolean;
      onOpenChange?: (open: boolean) => void;
      /** A BCP 47 language tag for the trigger's date and the calendar */
      locale?: string;
      /** How the trigger writes the date, as Intl.DateTimeFormat options */
      formatOptions?: Intl.DateTimeFormatOptions;
      /** The earliest day that can be picked */
      min?: Date;
      /** The latest day that can be picked */
      max?: Date;
      /** Days that can't be picked: Calendar's `disabled` */
      disabledDates?: Matcher | Matcher[];
      weekStartsOn?: Weekday;
      numberOfMonths?: number;
      /** The day marked as today. Defaults to the browser's today. */
      today?: Date;
      /** How the popover lines up with the trigger */
      align?: "start" | "center" | "end";
      labels?: Partial<DatePickerLabels>;
    };
    
    export type DatePickerProps = PickerProps & {
      /** The picked day, when you control it */
      value?: Date | undefined;
      defaultValue?: Date;
      onChange?: (date: Date | undefined) => void;
      presets?: DatePreset<Date>[];
      /** Submits the day with a form, as YYYY-MM-DD */
      name?: string;
    };
    
    export type DateRangePickerProps = PickerProps & {
      /** The picked range, when you control it */
      value?: DateRange | undefined;
      defaultValue?: DateRange;
      onChange?: (range: DateRange | undefined) => void;
      presets?: DatePreset<DateRange>[];
    };
    
    const monthOf = (date: Date) => new Date(date.getFullYear(), date.getMonth(), 1);
    const monthIndex = (date: Date) => date.getFullYear() * 12 + date.getMonth();
    const pad = (value: number, length = 2) => String(value).padStart(length, "0");
    const keyOf = (date: Date) =>
      `${pad(date.getFullYear(), 4)}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
    const resolve = <T,>(value: T | (() => T)) => (typeof value === "function" ? (value as () => T)() : value);
    
    /**
     * What both pickers share: whether they're open, the month the calendar shows, and closing a beat after a
     * pick. Each opening starts on the picked month, or on today's (which Calendar knows).
     */
    function usePicker(
      openProp: boolean | undefined,
      defaultOpen: boolean | undefined,
      onOpenChange: ((open: boolean) => void) | undefined,
      anchor: Date | undefined,
      count: number,
      today: Date | undefined,
    ) {
      const reduce = useReducedMotion() ?? false;
      const [ownOpen, setOwnOpen] = useState(defaultOpen ?? false);
      const open = openProp ?? ownOpen;
      const [month, setMonth] = useState<Date | undefined>(() => (anchor ? monthOf(anchor) : undefined));
      const closing = useRef<ReturnType<typeof setTimeout>>(undefined);
    
      useEffect(() => () => clearTimeout(closing.current), []);
    
      const setOpen = (next: boolean) => {
        clearTimeout(closing.current);
        if (next) setMonth(anchor ? monthOf(anchor) : undefined);
        if (openProp === undefined) setOwnOpen(next);
        onOpenChange?.(next);
      };
    
      return {
        open,
        setOpen,
        month,
        setMonth,
        /** Closes once the circle has landed on the picked day */
        closeSoon: () => {
          clearTimeout(closing.current);
          closing.current = setTimeout(() => setOpen(false), reduce ? 0 : duration.base * 1000);
        },
        /** Pages the calendar to a picked day it isn't showing (a preset's) */
        reveal: (date: Date) => {
          // With no month set, the calendar is on today's (yours, or the clock's: this runs on a click, not a render)
          const shown = month ?? monthOf(today ?? new Date());
          const offset = monthIndex(date) - monthIndex(shown);
          if (offset < 0 || offset >= count) setMonth(monthOf(date));
        },
      };
    }
    
    /** The list of presets, with one highlight gliding to the row under the mouse or with the focus */
    function PresetList<T>({
      presets,
      label,
      onPick,
    }: {
      presets: DatePreset<T>[];
      label: string;
      onPick: (value: T) => void;
    }) {
      const groupId = useId();
      const reduce = useReducedMotion() ?? false;
      const [hovered, setHovered] = useState<string | null>(null);
      const [focused, setFocused] = useState<string | null>(null);
      const active = hovered ?? focused;
    
      return (
        <ul
          data-slot="date-picker-presets"
          aria-label={label}
          className="relative isolate flex shrink-0 flex-col gap-0.5 py-3 ps-3"
          onPointerLeave={() => setHovered(null)}
          onBlur={(event) => {
            if (!event.currentTarget.contains(event.relatedTarget)) setFocused(null);
          }}
        >
          <LayoutGroup id={groupId}>
            {presets.map((preset) => (
              <li key={preset.label}>
                <button
                  type="button"
                  data-slot="date-picker-preset"
                  onPointerEnter={(event) => {
                    // Hover highlights follow the mouse only
                    if (event.pointerType === "mouse") setHovered(preset.label);
                  }}
                  onFocus={() => setFocused(preset.label)}
                  onClick={() => onPick(resolve(preset.value))}
                  className={cn(
                    "relative flex w-full cursor-pointer items-center rounded-md px-2 py-1.5 text-start text-sm whitespace-nowrap",
                    "transition-colors duration-(--duration-fast) ease-out",
                    "outline-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)",
                    active === preset.label && "text-accent-foreground",
                  )}
                >
                  {/* The rows aren't stacking contexts: the pill joins the list's layer (it's `isolate`), behind every label */}
                  {active === preset.label ? (
                    <motion.span
                      layoutId="date-picker-preset-highlight"
                      aria-hidden
                      className="pointer-events-none absolute inset-0 -z-10 rounded-md bg-accent"
                      transition={reduce ? { duration: 0 } : spring.snappy}
                    />
                  ) : null}
                  {preset.label}
                </button>
              </li>
            ))}
          </LayoutGroup>
        </ul>
      );
    }
    
    /** The trigger, drawn like Select's, and the popover round whatever the picker puts in it */
    function PickerFrame({
      open,
      onOpenChange,
      text,
      empty,
      size = "md",
      align,
      dialogLabel,
      className,
      children,
      ...props
    }: Omit<ComponentProps<"button">, "children"> & {
      open: boolean;
      onOpenChange: (open: boolean) => void;
      text: string;
      empty: boolean;
      size?: keyof typeof triggerSizes;
      align: "start" | "center" | "end";
      dialogLabel: string;
      children: ReactNode;
    }) {
      const reduce = useReducedMotion() ?? false;
      const content = useRef<HTMLDivElement>(null);
    
      return (
        <Popover open={open} onOpenChange={onOpenChange}>
          <PopoverTrigger asChild>
            <button
              type="button"
              // A combobox that pops up a dialog (Radix adds aria-haspopup="dialog" and aria-controls)
              role="combobox"
              aria-expanded={open}
              data-slot="date-picker-trigger"
              data-size={size}
              data-placeholder={empty ? "" : undefined}
              className={cn(
                "group flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-lg border border-input bg-transparent text-start text-sm whitespace-nowrap shadow-xs",
                "transition duration-(--duration-fast) ease-out",
                "outline-ring focus-visible:border-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)",
                "aria-invalid:border-destructive aria-invalid:outline-destructive",
                "disabled:cursor-not-allowed disabled:opacity-(--opacity-disabled)",
                triggerSizes[size],
                className,
              )}
              {...props}
            >
              <CalendarIcon aria-hidden className="size-(--size-icon-md) shrink-0 text-muted-foreground" />
              {/* Screen readers read this as the combobox's value; what's drawn swaps through the blur */}
              <span data-slot="date-picker-value" className="sr-only">
                {text}
              </span>
              <span aria-hidden className="grid min-w-0 flex-1">
                <AnimatePresence initial={false}>
                  <motion.span
                    key={text}
                    {...(reduce
                      ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }
                      : swap)}
                    transition={
                      reduce
                        ? { duration: duration.fast }
                        : { duration: duration.base, ease: ease.out, y: spring.snappy }
                    }
                    // Each text keeps its own colour as it leaves, so the placeholder goes out muted
                    className={cn("col-start-1 row-start-1 truncate", empty && "text-muted-foreground")}
                  >
                    {text}
                  </motion.span>
                </AnimatePresence>
              </span>
            </button>
          </PopoverTrigger>
          <PopoverContent
            ref={content}
            align={align}
            aria-label={dialogLabel}
            // Focus goes to the picked day (or today), not to the first button, as in the WAI-ARIA date picker
            onOpenAutoFocus={(event) => {
              event.preventDefault();
              content.current?.querySelector<HTMLElement>("[data-slot=calendar-day][tabindex='0']")?.focus();
            }}
            // Where two months or the presets don't fit, the calendar wraps under them
            className="flex w-auto max-w-(--radix-popover-content-available-width) flex-wrap p-0"
          >
            {children}
          </PopoverContent>
        </Popover>
      );
    }
    
    /**
     * A date field: a trigger showing the picked day (or a placeholder) that opens a Calendar in a Popover.
     * Picking a day closes it. Give it a visible label (`<label htmlFor>`) or an aria-label.
     */
    function DatePicker(props: DatePickerProps) {
      const {
        value: valueProp,
        defaultValue,
        onChange,
        presets,
        name,
        placeholder = "Pick a date",
        locale = "en-US",
        formatOptions = { dateStyle: "medium" },
        min,
        max,
        disabledDates,
        weekStartsOn,
        numberOfMonths = 1,
        today,
        labels,
        open: openProp,
        defaultOpen,
        onOpenChange,
        align = "start",
        ...trigger
      } = props;
      const controlled = "value" in props;
      const [own, setOwn] = useState(defaultValue);
      const value = controlled ? valueProp : own;
      const picker = usePicker(openProp, defaultOpen, onOpenChange, value, numberOfMonths, today);
    
      const commit = (next: Date | undefined) => {
        if (!controlled) setOwn(next);
        onChange?.(next);
      };
    
      return (
        <>
          <PickerFrame
            open={picker.open}
            onOpenChange={picker.setOpen}
            text={value ? new Intl.DateTimeFormat(locale, formatOptions).format(value) : placeholder}
            empty={!value}
            align={align}
            dialogLabel={labels?.dialog ?? "Choose date"}
            {...trigger}
          >
            {presets?.length ? (
              <PresetList
                presets={presets}
                label={labels?.presets ?? "Presets"}
                onPick={(date) => {
                  commit(date);
                  picker.reveal(date);
                  picker.closeSoon();
                }}
              />
            ) : null}
            <Calendar
              mode="single"
              // Picking the same day again keeps it and closes, rather than clearing the field
              required
              selected={value}
              onSelect={(date) => {
                commit(date);
                picker.closeSoon();
              }}
              month={picker.month}
              onMonthChange={picker.setMonth}
              numberOfMonths={numberOfMonths}
              min={min}
              max={max}
              disabled={disabledDates}
              weekStartsOn={weekStartsOn}
              locale={locale}
              today={today}
              labels={labels}
            />
          </PickerFrame>
          {name ? <input type="hidden" name={name} value={value ? keyOf(value) : ""} /> : null}
        </>
      );
    }
    
    /**
     * A date range field: a trigger showing the picked range (or a placeholder) that opens a Calendar in range
     * mode, two months side by side by default. It stays open while you pick. Give it a visible label or an
     * aria-label.
     */
    function DateRangePicker(props: DateRangePickerProps) {
      const {
        value: valueProp,
        defaultValue,
        onChange,
        presets,
        placeholder = "Pick a date range",
        locale = "en-US",
        formatOptions = { dateStyle: "medium" },
        min,
        max,
        disabledDates,
        weekStartsOn,
        numberOfMonths = 2,
        today,
        labels,
        open: openProp,
        defaultOpen,
        onOpenChange,
        align = "start",
        ...trigger
      } = props;
      const controlled = "value" in props;
      const [own, setOwn] = useState(defaultValue);
      const value = controlled ? valueProp : own;
      const picker = usePicker(openProp, defaultOpen, onOpenChange, value?.from, numberOfMonths, today);
      const from = value?.from;
      const to = value?.to;
      const format = new Intl.DateTimeFormat(locale, formatOptions);
    
      const commit = (next: DateRange | undefined) => {
        if (!controlled) setOwn(next);
        onChange?.(next);
      };
    
      return (
        <PickerFrame
          open={picker.open}
          onOpenChange={picker.setOpen}
          // Intl writes a range the short way: "Oct 12 – 18, 2026"
          text={from && to ? format.formatRange(from, to) : from ? `${format.format(from)} – …` : placeholder}
          empty={!from}
          align={align}
          dialogLabel={labels?.dialog ?? "Choose dates"}
          {...trigger}
        >
          {presets?.length ? (
            <PresetList
              presets={presets}
              label={labels?.presets ?? "Presets"}
              onPick={(range) => {
                commit(range);
                if (range.from) picker.reveal(range.from);
              }}
            />
          ) : null}
          <Calendar
            mode="range"
            selected={value}
            onSelect={commit}
            month={picker.month}
            onMonthChange={picker.setMonth}
            numberOfMonths={numberOfMonths}
            min={min}
            max={max}
            disabled={disabledDates}
            weekStartsOn={weekStartsOn}
            locale={locale}
            today={today}
            labels={labels}
          />
        </PickerFrame>
      );
    }
    
    export { DatePicker, DateRangePicker };
  6. 6

    Update the import paths to match your project.

Examples

Presets

A small list beside the calendar for the dates people pick most. Each works its date out from today when it's picked, and the calendar turns to it.

presets.tsx
"use client";

import type { DateRange } from "@/components/ui/calendar";
import { DatePicker, type DatePreset, DateRangePicker } from "@/components/ui/date-picker";
import { useId } from "react";

/** A day counted from today. Presets call it when they're picked, so nothing reads the clock while rendering. */
const fromToday = (days: number) => {
  const now = new Date();
  return new Date(now.getFullYear(), now.getMonth(), now.getDate() + days);
};

const days: DatePreset<Date>[] = [
  { label: "Today", value: () => fromToday(0) },
  { label: "Tomorrow", value: () => fromToday(1) },
  { label: "In a week", value: () => fromToday(7) },
  { label: "In a month", value: () => fromToday(30) },
];

const ranges: DatePreset<DateRange>[] = [
  { label: "Next 7 days", value: () => ({ from: fromToday(0), to: fromToday(6) }) },
  { label: "Next 14 days", value: () => ({ from: fromToday(0), to: fromToday(13) }) },
  { label: "Next 30 days", value: () => ({ from: fromToday(0), to: fromToday(29) }) },
];

/*
 * Presets beside the calendar, for the dates people pick most. Each works its date out from today when it's
 * picked; the calendar turns to it, the circle travels or the band grows, and the first picker closes.
 */
export default function DatePickerPresets() {
  const id = useId();
  return (
    <div className="grid w-full max-w-xs gap-4">
      <div className="grid gap-2">
        <label htmlFor={`${id}-follow-up`} className="text-sm font-medium">
          Follow up
        </label>
        <DatePicker id={`${id}-follow-up`} presets={days} />
      </div>
      <div className="grid gap-2">
        <label htmlFor={`${id}-campaign`} className="text-sm font-medium">
          Campaign
        </label>
        <DateRangePicker id={`${id}-campaign`} presets={ranges} />
      </div>
    </div>
  );
}

API reference

DatePicker

A field for one day: a trigger drawn like Select's that opens a Calendar in a Popover, and closes once a day is picked. The trigger is a combobox with a dialog, so give it a visible label (<label htmlFor>) or an aria-label. Also accepts every button prop, for the trigger.

PropTypeDefaultDescription
value?Date–The picked day, when you control it.
defaultValue?Date–The day picked at first.
onChange?(date: Date | undefined) => void–Called with the new day.
name?string–Submits the day with a form, as YYYY-MM-DD.
numberOfMonths?number1Months shown side by side.
placeholder?string"Pick a date"Shown in the trigger while nothing is picked.
presets?{ label: string; value: T | (() => T) }[]–A short list beside the calendar. A function value is worked out when it's picked, so presets like Today never read the clock while rendering.
locale?string"en-US"A language tag for the trigger's date and the calendar's names.
formatOptions?Intl.DateTimeFormatOptions{ dateStyle: "medium" }How the trigger writes the date, as Intl.DateTimeFormat options.
min?Date–The earliest day that can be picked.
max?Date–The latest day that can be picked.
disabledDates?Matcher | Matcher[]–Days that can't be picked: Calendar's disabled prop.
weekStartsOn?0 | 1 | 2 | 3 | 4 | 5 | 600 is Sunday.
open?boolean–Whether the calendar is open, when you control it.
onOpenChange?(open: boolean) => void–Called when it opens or closes.
align?"start" | "center" | "end""start"How the popover lines up with the trigger.
size?"sm" | "md""md"The trigger's height, as Select's.
today?Date–The day marked as today. Defaults to the browser's today.
labels?{ dialog?, presets?, previousMonth?, nextMonth?, selected? }–The words it says, for other languages: the popup's name, the presets' name and Calendar's labels.
disabled?booleanfalsePrevents opening.

DateRangePicker

A field for a range: the same trigger, opening a Calendar in range mode that stays open while you pick. The trigger writes the range the short way (Oct 12 – 18, 2026). Also accepts every button prop, for the trigger.

PropTypeDefaultDescription
value?DateRange–The picked range ({ from, to }), when you control it.
defaultValue?DateRange–The range picked at first.
onChange?(range: DateRange | undefined) => void–Called with the new range: first with only its start, then with both ends.
numberOfMonths?number2Months shown side by side.
placeholder?string"Pick a date range"Shown in the trigger while nothing is picked.
presets?{ label: string; value: T | (() => T) }[]–A short list beside the calendar. A function value is worked out when it's picked, so presets like Today never read the clock while rendering.
locale?string"en-US"A language tag for the trigger's date and the calendar's names.
formatOptions?Intl.DateTimeFormatOptions{ dateStyle: "medium" }How the trigger writes the date, as Intl.DateTimeFormat options.
min?Date–The earliest day that can be picked.
max?Date–The latest day that can be picked.
disabledDates?Matcher | Matcher[]–Days that can't be picked: Calendar's disabled prop.
weekStartsOn?0 | 1 | 2 | 3 | 4 | 5 | 600 is Sunday.
open?boolean–Whether the calendar is open, when you control it.
onOpenChange?(open: boolean) => void–Called when it opens or closes.
align?"start" | "center" | "end""start"How the popover lines up with the trigger.
size?"sm" | "md""md"The trigger's height, as Select's.
today?Date–The day marked as today. Defaults to the browser's today.
labels?{ dialog?, presets?, previousMonth?, nextMonth?, selected? }–The words it says, for other languages: the popup's name, the presets' name and Calendar's labels.
disabled?booleanfalsePrevents opening.

Related components

  • October 2026October 2026
    SunMonTueWedThuFriSat
    Calendar

    An accessible React calendar for Next.js and Tailwind CSS: pick a day or a range, with full keyboard support, disabled days and Intl names. No date library.

  • Popover

    An accessible React popover for Next.js and Tailwind CSS: forms, settings and other rich content anchored to a button, built on Radix Popover.

  • Select

    An accessible React select with a gliding highlight, groups, labels and a scrollable list, built on Radix Select.