Skip to content
Pebble Pro is here at $49 one timeSee what’s included ↗
Pebble
ComponentsDocsMCPPricing
⌕ Find a component↗Log in
All components 673

Categories

Core inputs & forms46Buttons & actions33Navigation36Overlays & feedback49Data display33Charts & data viz40Cards & layout43Marketing blocks38App shells & pages37AI interfaces40Text & typography effects33Backgrounds & visual effects38Motion & transitions32Numbers & counters32Media34E-commerce333D & interactive36Themed sets40
Start with the essentials.Installation guide ↗Connect your agent ↗
← All componentsData display

Calendar

A month grid with single, range and multiple selection, a direction-aware month slide, lit-key selected days and a full WAI-ARIA keyboard grid.

Preview

November 2026 – December 2026

November 2026

SunMonTueWedThuFriSat
1
2
3
4
5
6
7
8
9
10
11
12

December 2026

SunMonTueWedThuFriSat
29
30
1
2
3
4
5
6
7
8
9
Nov 12 → Nov 186 nights
November 2026

November 2026

WkMonTueWedThuFriSatSun
44
26
27
28
29
30
31
45
46
47
48
49
1
2
3
4
5
6
Review on Nov 19
November 2026

November 2026

SunMonTueWedThuFriSat
4 on-call shifts

Install

Add the editable source with the shadcn CLI. Your project also needs the shared theme tokens this component uses. Setup and requirements. The Pebble installer is not published yet.

npx shadcn@latest add https://pebbleui.com/r/calendar.json

Props

PropTypeDefaultDescription
mode"single" | "range" | "multiple""single"Selection model: one day, a from–to range, or a set of days.
selectedDate | DateRange | Date[]—Controlled selection (typed by mode). Present-but-undefined clears it.
defaultSelectedDate | DateRange | Date[]—Initial selection when uncontrolled.
onSelect(value) => void—Called with the new selection for the current mode.
requiredbooleanfalseClicking the selected day does not clear it.
month / defaultMonthDate—Controlled or initial first visible month.
onMonthChange(month: Date) => void—Called with the first day of the newly visible month.
numberOfMonths1 | 21Months shown side by side.
min / maxDate—Selectable bounds; navigation stops at their months.
disabledDate[] | (date: Date) => boolean—Blocked days: faded, struck and not selectable.
showOutsideDaysbooleantrueShow muted days from neighbouring months.
showWeekNumberbooleanfalseAdd a column of ISO week numbers.
weekStartsOn0 | 1 | 2 | 3 | 4 | 5 | 60First weekday column; 0 is Sunday.
todayDate—Day marked as today. Omitted, it is read on the client after mount.
localestring"en-US"Intl locale for captions, weekdays and full-date labels.
autoFocusbooleanfalseFocus the tab-stop day on mount (for popovers).
footerReactNode—Summary line or actions under the grid.
Build notes
- Dates are local-midnight `Date`s; helpers at the top of the file do day
  maths (`addMonths` clamps the day). `toDayKey` (`yyyy-mm-dd`) is the React
  key and `data-day`.
- `getWeeks` starts at the first day of the month minus
  `(firstDay - weekStartsOn + 7) % 7` and always returns 6×7 days. ISO week
  numbers use the Thursday in each row.
- Month slide: the months strip is one `motion.div` keyed by the first
  visible month inside `AnimatePresence mode="popLayout" initial={false}`,
  with `custom={direction}` variants (enter x = dir × 28 px, exit
  x = −dir × 28 px), x on the house spring (420/32/0.8) and opacity 160 ms.
  The clip box has `overflow-hidden` with 6 px padding and matching negative
  margin so focus rings at the edge are not cut. Direction is derived with
  the "adjust state while rendering" pattern by comparing the month index to
  the previous one, so controlled `month` animates too.
- Focus after a keyboard move: set `focused`, flag `pendingFocus`, and an
  effect focuses `[data-strip=<current key>] button[data-day=<key>]`; the
  strip key keeps the exiting copy (which may contain the same date) out of
  the query.
- Range band: an absolutely positioned span behind the day button inside the
  gridcell: `start-1/2 end-0` on the start day, `start-0 end-1/2` on the end,
  `inset-x-0` between, `rounded-s-lg` on column 0 / day 1 and `rounded-e-lg`
  on column 6 / last day. Committed band `bg-accent/16` (20% dark); preview
  `bg-accent/10`.
- The lit key is a separate span mounted only while selected, with
  `starting:scale-75 starting:opacity-0` and a `scale,opacity` transition,
  so `@starting-style` pops it in on each new selection without JS.
- Today without a prop uses `useSyncExternalStore` with an undefined server
  snapshot, so SSR and hydration agree and the dot appears after mount. If no
  month can be resolved yet, the strip holds its space with an empty box.
- Selection is controlled when `selected` is present in props (checked with
  `hasOwnProperty`), so a parent can clear it with `selected={undefined}`.

<!-- PEBBLE-LICENSE -->
View source

registry/data/calendar/calendar.tsx

"use client";

/* PEBBLE-LICENSE
Built by Pebble. Component: calendar.

First-party license: MIT

The following first-party terms apply only to original Pebble material.

MIT License

Copyright (c) 2026 Praxys Solutions LLC (Pebble)

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

THIRD-PARTY NOTICES
These notices apply to identified upstream portions; Pebble terms do not replace or narrow their rights.

Upstream: https://raw.githubusercontent.com/shadcn-ui/ui/main/LICENSE.md
MIT License

Copyright (c) 2023 shadcn

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
END PEBBLE-LICENSE */

import * as React from "react";
import { AnimatePresence, MotionConfig, motion } from "motion/react";

import { cn } from "@/lib/utils";

/* -------------------------------------------------------------------------- */
/* Date helpers (local time, midnight-based). Exported for the date picker.   */
/* -------------------------------------------------------------------------- */

export function startOfDay(date: Date) {
  return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}

export function startOfMonth(date: Date) {
  return new Date(date.getFullYear(), date.getMonth(), 1);
}

export function addDays(date: Date, days: number) {
  return new Date(date.getFullYear(), date.getMonth(), date.getDate() + days);
}

/** Adds months and clamps the day, so Jan 31 + 1 month is Feb 28/29. */
export function addMonths(date: Date, months: number) {
  const target = new Date(date.getFullYear(), date.getMonth() + months, 1);
  const last = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate();
  return new Date(target.getFullYear(), target.getMonth(), Math.min(date.getDate(), last));
}

export function isSameDay(a: Date | undefined, b: Date | undefined) {
  return (
    !!a &&
    !!b &&
    a.getFullYear() === b.getFullYear() &&
    a.getMonth() === b.getMonth() &&
    a.getDate() === b.getDate()
  );
}

function isSameMonth(a: Date, b: Date) {
  return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();
}

/** Whole-day comparison: negative when a is before b. */
export function compareDays(a: Date, b: Date) {
  return startOfDay(a).getTime() - startOfDay(b).getTime();
}

function monthIndex(date: Date) {
  return date.getFullYear() * 12 + date.getMonth();
}

/** `yyyy-mm-dd` in local time; used as keys and `data-day`. */
export function toDayKey(date: Date) {
  const m = String(date.getMonth() + 1).padStart(2, "0");
  const d = String(date.getDate()).padStart(2, "0");
  return `${date.getFullYear()}-${m}-${d}`;
}

function fromDayKey(key: string) {
  const [y, m, d] = key.split("-").map(Number);
  return new Date(y, m - 1, d);
}

/** ISO-8601 week number of the week containing `date`. */
function isoWeek(date: Date) {
  const target = startOfDay(date);
  target.setDate(target.getDate() + 3 - ((target.getDay() + 6) % 7));
  const jan4 = new Date(target.getFullYear(), 0, 4);
  return (
    1 +
    Math.round(((target.getTime() - jan4.getTime()) / 864e5 - 3 + ((jan4.getDay() + 6) % 7)) / 7)
  );
}

/* -------------------------------------------------------------------------- */
/* Types                                                                      */
/* -------------------------------------------------------------------------- */

export type DateRange = { from: Date | undefined; to?: Date | undefined };
/** Dates to disable: a list, or a test that returns true for disabled days. */
export type DateMatcher = Date[] | ((date: Date) => boolean);
type Weekday = 0 | 1 | 2 | 3 | 4 | 5 | 6;

type CalendarBaseProps = Omit<React.ComponentProps<"div">, "onSelect" | "defaultValue"> & {
  /** Controlled first visible month. */
  month?: Date;
  /** Initial first visible month when uncontrolled. */
  defaultMonth?: Date;
  onMonthChange?: (month: Date) => void;
  /** One or two months side by side. */
  numberOfMonths?: 1 | 2;
  /** Earliest selectable day; navigation stops at its month. */
  min?: Date;
  /** Latest selectable day; navigation stops at its month. */
  max?: Date;
  disabled?: DateMatcher;
  /** Show the muted trailing and leading days of neighbour months. */
  showOutsideDays?: boolean;
  /** Add a column of ISO week numbers. */
  showWeekNumber?: boolean;
  /** 0 = Sunday … 6 = Saturday. */
  weekStartsOn?: Weekday;
  /** The day to mark as today. Omitted, it is read on the client after mount. */
  today?: Date;
  /** BCP 47 locale for month, weekday and full-date labels. */
  locale?: string;
  /** Move focus into the grid on mount (use inside popovers). */
  autoFocus?: boolean;
  /** Content under the grid: a summary line or actions. */
  footer?: React.ReactNode;
};

export type CalendarSingleProps = CalendarBaseProps & {
  mode?: "single";
  selected?: Date | undefined;
  defaultSelected?: Date;
  onSelect?: (date: Date | undefined) => void;
  /** Keep a selection: clicking the selected day does not clear it. */
  required?: boolean;
};

export type CalendarRangeProps = CalendarBaseProps & {
  mode: "range";
  selected?: DateRange | undefined;
  defaultSelected?: DateRange;
  onSelect?: (range: DateRange | undefined) => void;
  required?: boolean;
};

export type CalendarMultipleProps = CalendarBaseProps & {
  mode: "multiple";
  selected?: Date[] | undefined;
  defaultSelected?: Date[];
  onSelect?: (dates: Date[]) => void;
  required?: boolean;
};

export type CalendarProps = CalendarSingleProps | CalendarRangeProps | CalendarMultipleProps;

type Selection = Date | DateRange | Date[] | undefined;

const SPRING = { type: "spring", stiffness: 420, damping: 32, mass: 0.8 } as const;
const SLIDE = 28;

const slide = {
  enter: (dir: number) => ({ x: dir * SLIDE, opacity: 0 }),
  center: { x: 0, opacity: 1 },
  exit: (dir: number) => ({ x: dir * -SLIDE, opacity: 0 }),
};

// Read today on the client only: the server snapshot is undefined, so
// server and hydration markup agree and the marker appears right after.
const noopSubscribe = () => () => {};
const readTodayKey = () => toDayKey(new Date());
const readServerToday = () => undefined;

function useFormatters(locale: string) {
  return React.useMemo(
    () => ({
      caption: new Intl.DateTimeFormat(locale, { month: "long" }),
      year: new Intl.DateTimeFormat(locale, { year: "numeric" }),
      monthYear: new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }),
      weekday: new Intl.DateTimeFormat(locale, { weekday: "short" }),
      weekdayLong: new Intl.DateTimeFormat(locale, { weekday: "long" }),
      day: new Intl.DateTimeFormat(locale, { day: "numeric" }),
      full: new Intl.DateTimeFormat(locale, {
        weekday: "long",
        month: "long",
        day: "numeric",
        year: "numeric",
      }),
      week: new Intl.NumberFormat(locale),
    }),
    [locale],
  );
}

function getWeeks(month: Date, weekStartsOn: Weekday) {
  const first = startOfMonth(month);
  const start = addDays(first, -((first.getDay() - weekStartsOn + 7) % 7));
  // Always six rows, so two-month layouts line up and height never jumps
  // while sliding between a 5-week and a 6-week month.
  return Array.from({ length: 6 }, (_, w) =>
    Array.from({ length: 7 }, (_, d) => addDays(start, w * 7 + d)),
  );
}

function ChevronIcon({ direction }: { direction: "left" | "right" }) {
  return (
    <svg
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.5}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden
      className="size-4 rtl:-scale-x-100"
    >
      <path
        d={direction === "left" ? "M9.75 3.75 5.5 8l4.25 4.25" : "M6.25 3.75 10.5 8l-4.25 4.25"}
      />
    </svg>
  );
}

export function Calendar(props: CalendarProps) {
  const {
    mode = "single",
    selected: selectedProp,
    defaultSelected,
    onSelect,
    required = false,
    month: monthProp,
    defaultMonth,
    onMonthChange,
    numberOfMonths = 1,
    min,
    max,
    disabled,
    showOutsideDays = true,
    showWeekNumber = false,
    weekStartsOn = 0,
    today: todayProp,
    locale = "en-US",
    autoFocus = false,
    footer,
    className,
    ...rest
  } = props;

  const fmt = useFormatters(locale);
  const labelId = React.useId();
  const stripRef = React.useRef<HTMLDivElement>(null);

  /* ----- selection (controlled when `selected` is passed, even undefined) ---- */
  const controlled = Object.prototype.hasOwnProperty.call(props, "selected");
  const [internal, setInternal] = React.useState<Selection>(defaultSelected);
  const selection: Selection = controlled ? selectedProp : internal;

  const single = mode === "single" ? (selection as Date | undefined) : undefined;
  const range = mode === "range" ? (selection as DateRange | undefined) : undefined;
  const multiple = mode === "multiple" ? ((selection as Date[] | undefined) ?? []) : [];

  /* ----- today ----- */
  const clientTodayKey = React.useSyncExternalStore(noopSubscribe, readTodayKey, readServerToday);
  const today = todayProp
    ? startOfDay(todayProp)
    : clientTodayKey
      ? fromDayKey(clientTodayKey)
      : undefined;

  /* ----- visible month ----- */
  const firstSelected = single ?? range?.from ?? multiple[0];
  const [monthState, setMonthState] = React.useState<Date | undefined>(() => {
    const seed = defaultMonth ?? firstSelected ?? todayProp;
    return seed ? startOfMonth(seed) : undefined;
  });
  const fallbackMonth = today ? startOfMonth(today) : undefined;
  const displayMonth = monthProp ? startOfMonth(monthProp) : (monthState ?? fallbackMonth);
  const displayIndex = displayMonth ? monthIndex(displayMonth) : 0;

  // Direction of the last month change, kept with the "adjust state while
  // rendering" pattern so controlled months animate the right way too.
  const [prevIndex, setPrevIndex] = React.useState(displayIndex);
  const [direction, setDirection] = React.useState(0);
  if (prevIndex !== displayIndex) {
    setPrevIndex(displayIndex);
    setDirection(displayIndex > prevIndex ? 1 : -1);
  }

  const months = displayMonth
    ? Array.from({ length: numberOfMonths }, (_, i) => addMonths(displayMonth, i))
    : [];

  const minDay = min ? startOfDay(min) : undefined;
  const maxDay = max ? startOfDay(max) : undefined;
  const canGoBack = !!displayMonth && (!minDay || monthIndex(displayMonth) > monthIndex(minDay));
  const canGoForward =
    !!displayMonth &&
    (!maxDay || monthIndex(displayMonth) + numberOfMonths - 1 < monthIndex(maxDay));

  const isDisabled = (date: Date) => {
    if (minDay && compareDays(date, minDay) < 0) return true;
    if (maxDay && compareDays(date, maxDay) > 0) return true;
    if (!disabled) return false;
    return Array.isArray(disabled) ? disabled.some((d) => isSameDay(d, date)) : disabled(date);
  };

  /* ----- focus (roving tabindex) ----- */
  const [focused, setFocused] = React.useState<Date | undefined>(undefined);
  const [hovered, setHovered] = React.useState<Date | undefined>(undefined);
  const pendingFocus = React.useRef(autoFocus);

  const inView = (date: Date) => months.some((m) => isSameMonth(m, date));
  const clampDay = (date: Date) => {
    if (minDay && compareDays(date, minDay) < 0) return minDay;
    if (maxDay && compareDays(date, maxDay) > 0) return maxDay;
    return date;
  };

  // The one tabbable day: the focused day if visible, else the selection,
  // today, or the first enabled day of the first visible month.
  const tabbable = (() => {
    if (focused && inView(focused)) return focused;
    const candidates = [single, range?.from, ...multiple, today];
    for (const c of candidates) if (c && inView(c) && !isDisabled(c)) return c;
    for (const m of months) {
      const last = new Date(m.getFullYear(), m.getMonth() + 1, 0).getDate();
      for (let d = 1; d <= last; d++) {
        const day = new Date(m.getFullYear(), m.getMonth(), d);
        if (!isDisabled(day)) return day;
      }
    }
    return months[0];
  })();

  const stripKey = displayMonth ? toDayKey(displayMonth) : "empty";
  const tabbableKey = tabbable ? toDayKey(tabbable) : "";

  React.useEffect(() => {
    if (!pendingFocus.current || !tabbableKey) return;
    const button = stripRef.current?.querySelector<HTMLButtonElement>(
      `[data-strip="${stripKey}"] button[data-day="${tabbableKey}"]`,
    );
    if (button) {
      pendingFocus.current = false;
      button.focus({ preventScroll: true });
    }
  }, [stripKey, tabbableKey]);

  const changeMonth = (next: Date) => {
    const m = startOfMonth(next);
    if (!monthProp) setMonthState(m);
    onMonthChange?.(m);
  };

  const navigate = (step: number) => {
    if (!displayMonth) return;
    changeMonth(addMonths(displayMonth, step));
    // Keep the roving stop on the same day number in the new view.
    if (focused) setFocused(clampDay(addMonths(focused, step)));
  };

  const moveFocus = (next: Date) => {
    const target = clampDay(next);
    setFocused(target);
    if (mode === "range" && range?.from && !range.to) setHovered(target);
    pendingFocus.current = true;
    if (!displayMonth || inView(target)) return;
    const forward = monthIndex(target) > monthIndex(displayMonth);
    changeMonth(forward ? addMonths(startOfMonth(target), -(numberOfMonths - 1)) : target);
  };

  /* ----- selection logic ----- */
  const select = (date: Date) => {
    if (isDisabled(date)) return;
    let next: Selection;
    if (mode === "single") {
      next = isSameDay(single, date) && !required ? undefined : date;
      setInternal(next);
      (onSelect as CalendarSingleProps["onSelect"])?.(next as Date | undefined);
    } else if (mode === "multiple") {
      const has = multiple.some((d) => isSameDay(d, date));
      const list =
        has && !(required && multiple.length === 1)
          ? multiple.filter((d) => !isSameDay(d, date))
          : has
            ? multiple
            : [...multiple, date].sort((a, b) => a.getTime() - b.getTime());
      setInternal(list);
      (onSelect as CalendarMultipleProps["onSelect"])?.(list);
    } else {
      const from = range?.from;
      const to = range?.to;
      let r: DateRange | undefined;
      if (!from || to) r = { from: date, to: undefined };
      else if (compareDays(date, from) < 0) r = { from: date, to: from };
      else r = { from, to: date };
      setInternal(r);
      setHovered(undefined);
      (onSelect as CalendarRangeProps["onSelect"])?.(r);
    }
    setFocused(date);
  };

  const onDayKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>, date: Date) => {
    const rtl = getComputedStyle(event.currentTarget).direction === "rtl";
    const offset = (date.getDay() - weekStartsOn + 7) % 7;
    let next: Date | undefined;
    switch (event.key) {
      case "ArrowLeft":
        next = addDays(date, rtl ? 1 : -1);
        break;
      case "ArrowRight":
        next = addDays(date, rtl ? -1 : 1);
        break;
      case "ArrowUp":
        next = addDays(date, -7);
        break;
      case "ArrowDown":
        next = addDays(date, 7);
        break;
      case "Home":
        next = addDays(date, -offset);
        break;
      case "End":
        next = addDays(date, 6 - offset);
        break;
      case "PageUp":
        next = addMonths(date, event.shiftKey ? -12 : -1);
        break;
      case "PageDown":
        next = addMonths(date, event.shiftKey ? 12 : 1);
        break;
      default:
        return;
    }
    event.preventDefault();
    moveFocus(next);
  };

  /* ----- range preview ----- */
  const rangeEnd = range?.to ?? (range?.from && hovered ? hovered : undefined);
  const [bandFrom, bandTo] =
    range?.from && rangeEnd
      ? compareDays(rangeEnd, range.from) < 0
        ? [rangeEnd, range.from]
        : [range.from, rangeEnd]
      : [undefined, undefined];
  const previewing = !!range?.from && !range.to;

  const weekdays = months[0] ? getWeeks(months[0], weekStartsOn)[0] : [];
  const columns = showWeekNumber
    ? "grid-cols-[1.75rem_repeat(7,var(--cell))]"
    : "grid-cols-[repeat(7,var(--cell))]";

  const liveLabel =
    months.length === 0
      ? ""
      : months.length === 1
        ? fmt.monthYear.format(months[0])
        : `${fmt.monthYear.format(months[0])} – ${fmt.monthYear.format(months[months.length - 1])}`;

  return (
    <MotionConfig reducedMotion="user">
      <div
        data-slot="calendar"
        data-mode={mode}
        className={cn(
          "relative w-fit rounded-xl bg-card p-3 text-sm text-card-foreground [--cell:--spacing(8)] sm:[--cell:--spacing(9)]",
          "shadow-raised-md ring-1 inset-shadow-highlight ring-hairline",
          // Inside a popover the popover is the surface.
          "[[data-slot=popover-content]_&]:bg-transparent [[data-slot=popover-content]_&]:p-0 [[data-slot=popover-content]_&]:shadow-none [[data-slot=popover-content]_&]:ring-0 [[data-slot=popover-content]_&]:inset-shadow-none",
          className,
        )}
        {...rest}
        onMouseLeave={(event) => {
          setHovered(undefined);
          rest.onMouseLeave?.(event);
        }}
      >
        <span className="sr-only" aria-live="polite" aria-atomic>
          {liveLabel}
        </span>

        <div
          data-slot="calendar-nav"
          className="absolute end-3 top-3 z-10 flex items-center gap-1 [[data-slot=popover-content]_&]:end-0 [[data-slot=popover-content]_&]:top-0"
        >
          {(["left", "right"] as const).map((side) => {
            const back = side === "left";
            const enabled = back ? canGoBack : canGoForward;
            return (
              <button
                key={side}
                type="button"
                data-slot={back ? "calendar-previous" : "calendar-next"}
                aria-label={back ? "Previous month" : "Next month"}
                disabled={!enabled}
                onClick={() => navigate(back ? -1 : 1)}
                className={cn(
                  "grid size-7 cursor-pointer place-items-center rounded-md bg-card text-muted-foreground",
                  "shadow-raised-xs ring-1 inset-shadow-highlight ring-hairline",
                  "transition-[background-color,color,transform,box-shadow] duration-(--motion-fast) ease-enter",
                  "hover:bg-muted hover:text-foreground active:translate-y-px active:scale-[0.94] active:shadow-none active:inset-shadow-none",
                  "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none",
                  "disabled:pointer-events-none disabled:opacity-40 motion-reduce:transition-none motion-reduce:active:scale-100",
                )}
              >
                <ChevronIcon direction={side} />
              </button>
            );
          })}
        </div>

        {/* Clip box for the slide; padded so edge focus rings are not cut. */}
        <div ref={stripRef} className="relative -m-1.5 overflow-hidden p-1.5">
          <AnimatePresence initial={false} mode="popLayout" custom={direction}>
            <motion.div
              key={stripKey}
              data-strip={stripKey}
              custom={direction}
              variants={slide}
              initial="enter"
              animate="center"
              exit="exit"
              transition={{ x: SPRING, opacity: { duration: 0.16 } }}
              // Two months stack on narrow screens instead of overflowing.
              className="flex flex-wrap gap-6"
            >
              {months.length === 0 && (
                // Nothing to anchor on yet (no month, selection or today
                // before mount): hold the space so the layout does not jump.
                <div
                  aria-hidden
                  className="h-[calc(var(--cell)*7+2.75rem)] w-[calc(var(--cell)*7)]"
                />
              )}
              {months.map((month, mi) => {
                const captionId = `${labelId}-m${mi}`;
                return (
                  <div
                    key={toDayKey(month)}
                    data-slot="calendar-month"
                    className="flex flex-col gap-2"
                  >
                    <div className="flex h-7 items-center ps-1.5">
                      <h2
                        id={captionId}
                        data-slot="calendar-caption"
                        className="text-sm font-medium tracking-[-0.01em] text-foreground"
                      >
                        {fmt.caption.format(month)}{" "}
                        <span className="font-normal text-muted-foreground tabular-nums">
                          {fmt.year.format(month)}
                        </span>
                      </h2>
                    </div>

                    <div role="grid" aria-labelledby={captionId} className="flex flex-col gap-0.5">
                      <div role="row" className={cn("grid", columns)}>
                        {showWeekNumber && (
                          <span
                            role="columnheader"
                            aria-label="Week"
                            className="grid h-7 place-items-center text-[10px] font-medium text-muted-foreground"
                          >
                            Wk
                          </span>
                        )}
                        {weekdays.map((d) => (
                          <span
                            key={d.getDay()}
                            role="columnheader"
                            aria-label={fmt.weekdayLong.format(d)}
                            className="grid h-7 place-items-center text-[11px] font-medium tracking-wide text-muted-foreground uppercase"
                          >
                            {fmt.weekday.format(d)}
                          </span>
                        ))}
                      </div>

                      {getWeeks(month, weekStartsOn).map((week) => {
                        const thursday = week.find((d) => d.getDay() === 4) ?? week[0];
                        return (
                          <div key={toDayKey(week[0])} role="row" className={cn("grid", columns)}>
                            {showWeekNumber && (
                              <span
                                role="rowheader"
                                aria-label={`Week ${isoWeek(thursday)}`}
                                className="grid h-(--cell) place-items-center font-mono text-[10px] text-muted-foreground tabular-nums"
                              >
                                {fmt.week.format(isoWeek(thursday))}
                              </span>
                            )}
                            {week.map((date, col) => {
                              const outside = !isSameMonth(date, month);
                              const key = toDayKey(date);
                              const dayDisabled = isDisabled(date);
                              const outOfBounds =
                                (!!minDay && compareDays(date, minDay) < 0) ||
                                (!!maxDay && compareDays(date, maxDay) > 0);
                              const isToday = isSameDay(date, today);

                              const isStart = !!bandFrom && isSameDay(date, bandFrom);
                              const isEnd = !!bandTo && isSameDay(date, bandTo);
                              const inBand =
                                !!bandFrom &&
                                !!bandTo &&
                                compareDays(date, bandFrom) >= 0 &&
                                compareDays(date, bandTo) <= 0;
                              const committedEdge =
                                !!range?.from &&
                                (isSameDay(date, range.from) || isSameDay(date, range.to));
                              const isSelected =
                                mode === "single"
                                  ? isSameDay(date, single)
                                  : mode === "multiple"
                                    ? multiple.some((d) => isSameDay(d, date))
                                    : committedEdge;
                              const middle = inBand && !isStart && !isEnd;
                              const showBand =
                                inBand &&
                                !isSameDay(bandFrom, bandTo) &&
                                (!outside || showOutsideDays);

                              if (outside) {
                                return (
                                  <div
                                    key={key}
                                    role="gridcell"
                                    className="relative grid h-(--cell) place-items-center"
                                  >
                                    {showBand && (
                                      <span
                                        aria-hidden
                                        className={cn(
                                          "absolute inset-y-0.5 bg-accent/8",
                                          isStart
                                            ? "start-1/2 end-0"
                                            : isEnd
                                              ? "start-0 end-1/2"
                                              : "inset-x-0",
                                          col === 0 && "rounded-s-lg",
                                          col === 6 && "rounded-e-lg",
                                        )}
                                      />
                                    )}
                                    {showOutsideDays && (
                                      <span
                                        aria-hidden
                                        className="relative text-[13px] font-light text-muted-foreground tabular-nums"
                                      >
                                        {fmt.day.format(date)}
                                      </span>
                                    )}
                                  </div>
                                );
                              }

                              const lastOfMonth =
                                date.getDate() ===
                                new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
                              return (
                                <div
                                  key={key}
                                  role="gridcell"
                                  aria-selected={
                                    isSelected ||
                                    (mode === "range" && inBand && !previewing) ||
                                    undefined
                                  }
                                  data-range={
                                    showBand
                                      ? isStart
                                        ? "start"
                                        : isEnd
                                          ? "end"
                                          : "middle"
                                      : undefined
                                  }
                                  className="relative grid h-(--cell) place-items-center"
                                >
                                  {showBand && (
                                    <span
                                      aria-hidden
                                      data-slot="calendar-range-band"
                                      className={cn(
                                        "absolute inset-y-0.5 transition-colors duration-(--motion-fast)",
                                        previewing
                                          ? "bg-accent/10"
                                          : "bg-accent/16 dark:bg-accent/20",
                                        isStart
                                          ? "start-1/2 end-0"
                                          : isEnd
                                            ? "start-0 end-1/2"
                                            : "inset-x-0",
                                        // Round the band where a row or the month breaks it.
                                        (col === 0 || date.getDate() === 1) && "rounded-s-lg",
                                        (col === 6 || lastOfMonth) && "rounded-e-lg",
                                      )}
                                    />
                                  )}
                                  <button
                                    type="button"
                                    data-slot="calendar-day"
                                    data-day={key}
                                    data-today={isToday || undefined}
                                    data-selected={isSelected || undefined}
                                    data-preview-end={
                                      (previewing && (isStart || isEnd) && !isSelected) || undefined
                                    }
                                    tabIndex={key === tabbableKey ? 0 : -1}
                                    aria-label={fmt.full.format(date) + (isToday ? ", today" : "")}
                                    aria-current={isToday ? "date" : undefined}
                                    aria-disabled={dayDisabled || undefined}
                                    onClick={() => select(date)}
                                    onKeyDown={(e) => onDayKeyDown(e, date)}
                                    onFocus={() => setFocused(date)}
                                    onMouseEnter={() => previewing && setHovered(date)}
                                    className={cn(
                                      "relative grid size-(--cell) cursor-pointer place-items-center rounded-lg text-[13px] text-foreground tabular-nums select-none",
                                      "transition-[background-color,color,box-shadow,transform] duration-(--motion-fast) ease-enter motion-reduce:transition-none",
                                      "focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card focus-visible:outline-none",
                                      "active:scale-[0.92] motion-reduce:active:scale-100",
                                      !isSelected && "hover:bg-muted",
                                      middle && "font-medium hover:bg-accent/15",
                                      isToday && !isSelected && "font-semibold",
                                      // The lit key: the selected day is a pressed-in-light ink keycap.
                                      isSelected && "font-medium text-primary-foreground",
                                      !isSelected &&
                                        previewing &&
                                        (isStart || isEnd) &&
                                        "ring-1 ring-accent/60 ring-inset",
                                      dayDisabled &&
                                        "cursor-not-allowed text-muted-foreground/50 hover:bg-transparent active:scale-100",
                                      // Blocked days are struck; days outside min/max only fade.
                                      dayDisabled &&
                                        !outOfBounds &&
                                        "line-through decoration-muted-foreground/40",
                                    )}
                                  >
                                    {isSelected && (
                                      // The lit key, mounted fresh on select so @starting-style
                                      // pops it in (0.75 → 1) instead of snapping.
                                      <span
                                        aria-hidden
                                        data-slot="calendar-key"
                                        className={cn(
                                          "absolute inset-0 rounded-lg bg-linear-to-b from-key-top to-key-bottom shadow-[var(--key-shine),var(--elevation-sm)] ring-1 ring-key-edge",
                                          "transition-[scale,opacity] duration-(--motion-base) ease-enter motion-reduce:transition-none starting:scale-75 starting:opacity-0",
                                        )}
                                      />
                                    )}
                                    <span className="relative">{fmt.day.format(date)}</span>
                                    {isToday && (
                                      <span
                                        aria-hidden
                                        data-slot="calendar-today"
                                        className="absolute bottom-1 left-1/2 size-1 -translate-x-1/2 rounded-full bg-accent"
                                      />
                                    )}
                                  </button>
                                </div>
                              );
                            })}
                          </div>
                        );
                      })}
                    </div>
                  </div>
                );
              })}
            </motion.div>
          </AnimatePresence>
        </div>

        {footer && (
          <div
            data-slot="calendar-footer"
            className="mt-3 border-t border-border pt-3 text-[13px] text-muted-foreground"
          >
            {footer}
          </div>
        )}
      </div>
    </MotionConfig>
  );
}
Pebble

Small details.
Better interfaces.

React components to explore, make your own, and build on.

Library

  • Components
  • Installation
  • Pricing

Resources

  • For agents
  • About
  • Support

Legal

  • License
  • Terms
  • Privacy
  • Final sale

Built by Pebble

A product of Praxys Solutions LLC ↗

Pebble Pro is live

$49 one time, lifetime updates ↗