Datepicker

PreviousNext

A range date picker built on react-day-picker v9 with price annotations, day status colors, hover-range preview, and a responsive layout that adapts between mobile scroll and desktop two-up view.

Nov 2026

Dec 2026

Jan 2027

Feb 2027

Mar 2027

Apr 2027

May 2027

Jun 2027

Jul 2027

Aug 2027

Sep 2027

Oct 2027

'use client';
import { useState } from 'react';
import type { DateRange } from 'react-day-picker';

import { DeviceProvider } from '@/ui/contexts/devices/Device';
import { Datepicker } from '@/ui/Datepicker';

const today = new Date();

const makeDateId = (offset: number) => {
  const d = new Date(today);
  d.setDate(d.getDate() + offset);
  return d.toISOString().slice(0, 10);
};

export function DatepickerDemo() {
  const [selected, setSelected] = useState<DateRange | undefined>();

  return (
    <DeviceProvider device="all">
      <Datepicker
        fromMonth={today}
        selected={selected}
        onSelect={(range) => setSelected(range)}
        labels={{
          seeNextMonth: 'See {currentMonth} and {nextMonth}',
          seePreviousMonth: 'See {previousMonth} and {currentMonth}',
        }}
        dates={[
          { id: makeDateId(3), price: '890 €', status: 'green' },
          { id: makeDateId(5), price: '1 200 €', status: 'orange' },
          { id: makeDateId(7), price: '760 €', status: 'outline' },
        ]}
        statusLabels={{
          green: 'Best price',
          orange: 'Last availabilities',
          outline: 'Available',
        }}
      />
    </DeviceProvider>
  );
}

Installation

npx shadcn@latest add https://develop.trident-ui.pro.clubmed/r/datepicker.json

Dependencies

Install the peer dependencies:

npm install react-day-picker date-fns

The component reads the current breakpoint via useDevice, which requires DeviceProvider somewhere above it in the tree. In most apps this belongs at the root layout:

// app/layout.tsx (Next.js) or your root provider
import { DeviceProvider } from '@/ui/contexts/devices/Device';
 
export default function RootLayout({ children }) {
  return (
    <DeviceProvider device="all">
      {children}
    </DeviceProvider>
  );
}

You can also wrap just the Datepicker when DeviceProvider is not at the root:

import { DeviceProvider } from '@/ui/contexts/devices/Device';
import { Datepicker } from '@/ui/Datepicker';
 
<DeviceProvider device="all">
  <Datepicker ... />
</DeviceProvider>

Usage

Basic range selection

The minimum configuration: controlled state, a fromMonth to anchor the calendar, and the two navigation button labels.

'use client';
 
import { useState } from 'react';
import type { DateRange } from 'react-day-picker';
import { Datepicker } from '@/ui/Datepicker';
 
export function Component() {
  const [selected, setSelected] = useState<DateRange | undefined>();
 
  return (
    <Datepicker
      fromMonth={new Date()}
      selected={selected}
      onSelect={(range) => setSelected(range)}
      labels={{
        seeNextMonth: 'See {currentMonth} and {nextMonth}',
        seePreviousMonth: 'See {previousMonth} and {currentMonth}',
      }}
    />
  );
}

With a pre-selected range

Pass an initial selected value to open the calendar with a range already highlighted.

Nov 2026

Dec 2026

Jan 2027

Feb 2027

Mar 2027

Apr 2027

May 2027

Jun 2027

Jul 2027

Aug 2027

Sep 2027

Oct 2027

'use client';
import { useState } from 'react';
import type { DateRange } from 'react-day-picker';

import { DeviceProvider } from '@/ui/contexts/devices/Device';
import { Datepicker } from '@/ui/Datepicker';

// Build a date offset from today
const d = (offsetDays: number) => {
  const date = new Date();
  date.setDate(date.getDate() + offsetDays);
  return date;
};

const id = (offsetDays: number) => d(offsetDays).toISOString().slice(0, 10);

export function DatepickerPreselectedDemo() {
  const [selected, setSelected] = useState<DateRange>({
    from: d(5),
    to: d(12),
  });

  return (
    <DeviceProvider device="all">
      <Datepicker
        fromMonth={new Date()}
        selected={selected}
        onSelect={(range) => setSelected(range ?? { from: undefined, to: undefined })}
        labels={{
          seeNextMonth: 'See {currentMonth} and {nextMonth}',
          seePreviousMonth: 'See {previousMonth} and {currentMonth}',
        }}
        dates={[
          { id: id(3), price: '890 €', status: 'green' },
          { id: id(4), price: '910 €', status: 'green' },
          { id: id(8), price: '1 450 €', status: 'orange' },
          { id: id(9), price: '1 450 €', status: 'orange' },
          { id: id(14), price: '720 €', status: 'outline' },
          { id: id(15), price: '720 €', status: 'outline' },
          { id: id(20), price: '1 100 €', status: 'default' },
        ]}
        statusLabels={{
          green: 'Best price of the month',
          orange: 'Last available dates',
          outline: 'Available',
        }}
      />
    </DeviceProvider>
  );
}

With price and status annotations

Each day can carry a price string and a status that controls its visual style. Pass the dates array and provide statusLabels for screen-reader announcements.

<Datepicker
  fromMonth={new Date()}
  selected={selected}
  onSelect={setSelected}
  labels={{ ... }}
  dates={[
    { id: '2025-07-10', price: '890 €',   status: 'green'   },
    { id: '2025-07-11', price: '890 €',   status: 'green'   },
    { id: '2025-07-15', price: '1 450 €', status: 'orange'  },
    { id: '2025-07-20', price: '720 €',   status: 'outline' },
    { id: '2025-07-22', price: '1 100 €', status: 'default' },
  ]}
  statusLabels={{
    green:   'Best price of the month',
    orange:  'Last available dates',
    outline: 'Available',
  }}
/>

With disabled days

Pass any react-day-picker Matcher or array of Matcher values to disabledDays. Common patterns include blocking dates before today, specific dates, and date ranges.

Nov 2026

Dec 2026

Jan 2027

Feb 2027

Mar 2027

Apr 2027

May 2027

Jun 2027

Jul 2027

Aug 2027

Sep 2027

Oct 2027

'use client';
import { useState } from 'react';
import type { DateRange } from 'react-day-picker';

import { DeviceProvider } from '@/ui/contexts/devices/Device';
import { Datepicker } from '@/ui/Datepicker';

const d = (offsetDays: number) => {
  const date = new Date();
  date.setDate(date.getDate() + offsetDays);
  return date;
};

export function DatepickerDisabledDaysDemo() {
  const [selected, setSelected] = useState<DateRange | undefined>();

  return (
    <DeviceProvider device="all">
      <Datepicker
        fromMonth={new Date()}
        selected={selected}
        onSelect={(range) => setSelected(range)}
        disabledDays={[
          // Prevent selecting past dates
          { before: new Date() },
          // Block specific unavailable dates
          d(4),
          d(5),
          d(11),
          d(12),
          d(18),
          d(19),
          // Block out an entire period
          { from: d(25), to: d(30) },
        ]}
        labels={{
          seeNextMonth: 'See {currentMonth} and {nextMonth}',
          seePreviousMonth: 'See {previousMonth} and {currentMonth}',
        }}
      />
    </DeviceProvider>
  );
}

With duration tooltip

When the user has selected a start date and hovers over a potential end date, a tooltip shows how many nights that range would be. Provide a formatDurationsLabel function that receives the night count and returns the string to display.

Nov 2026

Dec 2026

Jan 2027

Feb 2027

Mar 2027

Apr 2027

May 2027

Jun 2027

Jul 2027

Aug 2027

Sep 2027

Oct 2027

'use client';
import { useState } from 'react';
import type { DateRange } from 'react-day-picker';

import { DeviceProvider } from '@/ui/contexts/devices/Device';
import { Datepicker } from '@/ui/Datepicker';

const d = (offsetDays: number) => {
  const date = new Date();
  date.setDate(date.getDate() + offsetDays);
  return date;
};

const id = (offsetDays: number) => d(offsetDays).toISOString().slice(0, 10);

const pluralNights = (n: number) => (n === 1 ? '1 night' : `${n} nights`);

export function DatepickerDurationTooltipDemo() {
  const [selected, setSelected] = useState<DateRange | undefined>();

  return (
    <DeviceProvider device="all">
      <Datepicker
        fromMonth={new Date()}
        selected={selected}
        onSelect={(range) => setSelected(range)}
        formatDurationsLabel={pluralNights}
        labels={{
          seeNextMonth: 'See {currentMonth} and {nextMonth}',
          seePreviousMonth: 'See {previousMonth} and {currentMonth}',
        }}
        dates={[
          { id: id(2), price: '1 090 €', status: 'green' },
          { id: id(3), price: '1 090 €', status: 'green' },
          { id: id(6), price: '1 480 €', status: 'orange' },
          { id: id(7), price: '1 480 €', status: 'orange' },
          { id: id(10), price: '850 €', status: 'outline' },
          { id: id(13), price: '920 €', status: 'default' },
        ]}
        statusLabels={{
          green: 'Best price',
          orange: 'Last availabilities',
          outline: 'Available',
        }}
        disabledDays={{ before: new Date() }}
      />
    </DeviceProvider>
  );
}

The tooltip appears above the hovered end date on desktop only (mouse events are not fired on touch).

Without prices (simple mode)

Omitting dates gives a clean calendar with no sub-labels. Useful when availability is expressed through disabledDays alone.

Nov 2026

Dec 2026

Jan 2027

Feb 2027

Mar 2027

Apr 2027

May 2027

Jun 2027

Jul 2027

Aug 2027

Sep 2027

Oct 2027

'use client';
import { useState } from 'react';
import type { DateRange } from 'react-day-picker';

import { DeviceProvider } from '@/ui/contexts/devices/Device';
import { Datepicker } from '@/ui/Datepicker';

const d = (offsetDays: number) => {
  const date = new Date();
  date.setDate(date.getDate() + offsetDays);
  return date;
};

export function DatepickerNoPricesDemo() {
  const [selected, setSelected] = useState<DateRange | undefined>();

  return (
    <div className="space-y-16">
      <DeviceProvider device="all">
        <Datepicker
          fromMonth={new Date()}
          selected={selected}
          onSelect={(range) => setSelected(range)}
          disabledDays={[{ before: new Date() }, d(6), d(7), d(13), d(14)]}
          labels={{
            seeNextMonth: 'See {currentMonth} and {nextMonth}',
            seePreviousMonth: 'See {previousMonth} and {currentMonth}',
          }}
        />
      </DeviceProvider>
      {selected?.from && (
        <p className="text-b3 text-center">
          {selected.from.toLocaleDateString()}
          {selected.to ? ` → ${selected.to.toLocaleDateString()}` : ' → pick an end date'}
        </p>
      )}
    </div>
  );
}

API Reference

Datepicker

PropTypeDefaultDescription
labelsLabels—Required. Accessible labels for the previous/next month navigation buttons. Supports token interpolation — see Labels.
selectedDateRangeundefinedThe current selection as { from?: Date; to?: Date }. The component is fully controlled: you own the state.
onSelectSelectRangeHandlerundefinedFired every time the user clicks a date. Receives (range, triggerDate, modifiers, event). range may be undefined if the user deselects.
datesDayDataProps[][]Per-day data objects that annotate individual days with a price and a status color. See DayDataProps.
statusLabelsStatusLabelMapundefinedScreen-reader text for each non-default status. If a day has status: 'green' and statusLabels.green is set, a visually-hidden <span> with that text is rendered inside the day button.
disabledDaysMatcher | Matcher[]undefinedDays that cannot be selected. Accepts any react-day-picker Matcher — a Date, a { before } / { after } / { from, to } object, or an array mixing any of the above.
fromMonthDatenew Date()The earliest month the user can navigate to. Also the default starting month on desktop. The previous-month button is disabled when the displayed month equals this value.
defaultMonthDatefromMonthThe month shown initially on desktop. Has no effect on mobile (the full scroll list always starts at fromMonth).
maxNumberOfMonthsnumber12How many months to render on mobile. The scroll list always starts at fromMonth and ends maxNumberOfMonths months later.
localeLocaleundefinedA date-fns locale object. Affects weekday names, month names, and date formatting in navigation labels.
formatDurationsLabel(nights: number) => stringundefinedWhen provided, a tooltip appears above the hovered end date showing this label. Only fires on desktop (hover events). The function receives the integer night count.

Labels

Navigation button labels support token interpolation. The tokens are replaced with locale-formatted month names at render time.

FieldTokens availableExample value
seeNextMonth{currentMonth}, {nextMonth}"See {currentMonth} and {nextMonth}"
seePreviousMonth{previousMonth}, {currentMonth}"See {previousMonth} and {currentMonth}"

The resulting string becomes the aria-label on the navigation button, which is read aloud by screen readers. Choose a phrasing that is meaningful as a spoken sentence, e.g. "See January 2026 and February 2026".

On mobile the buttons are always hidden visually (hidden md:inline-block) but remain in the DOM with their labels so keyboard users can still navigate.

DayDataProps

Annotates a single calendar day. Pass an array of these objects to the dates prop.

FieldTypeDescription
idstringRequired. The date to annotate, in ISO yyyy-MM-dd format (e.g. "2025-07-15"). Days not present in the array render with the default unstyled appearance.
pricestring | nullA pre-formatted price string displayed below the day number (e.g. "1 200 €"). Hidden when the day is part of a selected range. Pass null to explicitly show no price.
status"default" | "green" | "orange" | "outline"Controls the background pill drawn behind the day number. See Day statuses for the visual mapping.

StatusLabelMap

Maps each non-default status value to a screen-reader string. Only the statuses you include are announced.

interface StatusLabelMap {
  green?:   string;   // e.g. "Best price of the month"
  orange?:  string;   // e.g. "Last available dates"
  outline?: string;   // e.g. "Available"
}

SelectRangeHandler

The full type of the onSelect callback:

type SelectRangeHandler = (
  selected: DateRange | undefined,
  triggerDate: Date,
  modifiers: Modifiers,
  e: React.MouseEvent | React.KeyboardEvent,
) => void;
  • selected — the new range after the click. undefined when the user deselects the last date. The component normalises the first click to { from: date, to: undefined } to match v8 behaviour (react-day-picker v9 sets from === to on the first click internally).
  • triggerDate — the exact date the user clicked.
  • modifiers — the full react-day-picker modifier state for triggerDate (e.g. selected, disabled, today).
  • e — the original mouse or keyboard event.

Day statuses

Each status draws a different background on the day-number circle:

StatusVisualWhen to use
defaultNo background — plain numberDate is available but has no special highlight
greenFilled green pill (bg-green text-white)Best-price day of the month
orangeFilled orange rounded square (bg-orange text-white)High-demand / last available dates
outlineWhite rounded square with black borderAvailable but not the cheapest or most constrained

When a day is disabled, all statuses are overridden with bg-sand text-middleGrey opacity-60.

When a day is selected (part of the highlighted range), the status background is suppressed — the selection highlight from the calendar CSS takes over.

Responsive behaviour

The component switches layout based on the medium breakpoint provided by DeviceProvider:

ViewportLayoutNavigation
MobileVertical scroll list of maxNumberOfMonths monthsPrevious/next buttons hidden visually, but focusable
DesktopTwo months side by sidePrevious/next buttons fully visible

The medium breakpoint corresponds to min-width: 768px in the default Trident UI theme. DeviceProvider reads this via matchMedia, so the layout responds to window resize without a page reload.

Hover-range preview

On desktop, when the user has selected exactly one date (either from or to is set but not both), hovering over a candidate end date highlights the in-between days using the same rdp-range_middle class as the confirmed selection. This gives the user a visual preview of the range before committing.

The preview is implemented with custom day modifiers (hover_range_middle, hover_range_end) that are mapped to the CSS class names from the confirmed range. It is completely suppressed on mobile because mouseover events do not fire on touch screens.

CSS

The component ships its own stylesheet (Datepicker.css) imported at the top of Datepicker.tsx. It targets the rdp-* class names emitted by react-day-picker v9 and overrides them with Trident UI design tokens:

.rdp-root           — container background (lightSand), border-radius, padding
.rdp-months         — flex column on mobile, row on md+
.rdp-day_button     — invisible border box that shows on focus / hover
.rdp-range_middle   — range fill (white bg + black text)
.rdp-range_start    — start-of-range rounded start corner
.rdp-range_end      — end-of-range rounded end corner
.rdp-day[data-selected] — selected day (black bg, white text)
.rdp-day[data-disabled] — reduced opacity

The file uses @reference to pull in the global CSS variables, so Tailwind utility classes (@apply) resolve correctly. If you copy the component manually, make sure your build pipeline processes this file with the same Tailwind config.

Accessibility

  • Month headings are rendered as <p role="heading" aria-level="3" tabindex="-1">. After the user clicks a navigation button, focus moves programmatically to the heading of the newly visible first month so keyboard and screen-reader users receive immediate feedback.
  • Month grids (<table>) are annotated with aria-labelledby pointing to their month heading's id, so screen readers can announce which month's grid the user is navigating.
  • Navigation buttons carry full spoken labels produced by interpolating the Labels tokens. On mobile the buttons are hidden md:inline-block but remain in the tab order.
  • Day status labels are injected as visually-hidden <span class="sr-only"> text inside each annotated day button. Screen readers will read e.g. "15, Best price of the month" rather than just "15".
  • Disabled days receive the standard disabled HTML attribute on their button, which prevents focus and interaction without relying on visual styling alone.

Notes

Controlled-only

The Datepicker is fully controlled — it holds no internal selection state. You must always pass selected and onSelect together, otherwise clicking a date has no visible effect.

First-click normalisation

react-day-picker v9 sets from === to when the user clicks the very first date (where previously v8 only set from). The component normalises this by detecting the case and calling onSelect with { from: date, to: undefined } instead. This preserves the v8 interaction model without requiring consumer code changes.

fromMonth is the lower bound for navigation, not for selection

fromMonth prevents the user from navigating to an earlier month, but it does not disable the dates within that month. If you also want to disable dates before today, pass { before: new Date() } (or your custom lower bound) to disabledDays.

Price string formatting

The price field in DayDataProps is a pre-formatted display string — the component does not apply any currency logic. Format the string on the data side before passing it in (e.g. Intl.NumberFormat or a backend-provided string). Very long price strings are scaled down automatically using a CSS scale transform based on character count, so they do not overflow the day cell.

Client component

The Datepicker uses useState, useCallback, and useMemo. Add 'use client' at the top of any file that renders it in a Next.js App Router project.