All patterns

Date Picker Open

The calendar unfolds from the field and the days arrive in a wave, today already marked.

formselegantpremiuminteraction · finite · advanced · ~0.4s
Interactive · click to play
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

538 lines · react + motion only
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Date Picker Open
 *
 * The calendar unfolds from under the field and the days arrive in a
 * wave that runs down the weeks, with today already marked and the
 * chosen day already filled. A real grid: arrow keys walk the month and
 * cross its edges, Page keys change month, Escape returns to the field.
 *
 * Self-contained: depends only on `react` and `motion`. The panel uses
 * the `Canvas` system colors so it stays opaque over whatever it covers,
 * on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `label`, `accent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type DatePickerOpenProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Field label above the control. */
  label?: string;
  /** Field width. */
  width?: number;
  /** Selection, marker and focus color. */
  accent?: string;
  /** Fires with the chosen date. */
  onSelect?: (date: Date) => void;
};

type VariantConfig = {
  /** Carries the panel down from under the field. */
  panel: { type: "spring"; stiffness: number; damping: number };
  /** How far the panel starts above its resting place, in pixels. */
  lift: number;
  /** Seconds between one week of the wave and the next. */
  row: number;
  /** Seconds between one day and the next within a week. */
  cell: number;
  /** Grows the fill under the chosen day. */
  fill: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: both springs sit at or above a 0.8 damping ratio. The
// panel is full of numbers and a wobble would drag forty glyphs with it,
// and the day fill is a disc behind text that must not push the numeral
// around. The wave is opacity and a few pixels of travel — nothing in a
// calendar scales.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // The grid is simply there. For a form with a date field in the middle
  // of it.
  subtle: {
    panel: { type: "spring", stiffness: 640, damping: 46 },
    lift: 4,
    row: 0.014,
    cell: 0.004,
    fill: { type: "spring", stiffness: 700, damping: 48 },
  },
  // The wave runs down the weeks fast enough to read as one unfold.
  // All-purpose.
  default: {
    panel: { type: "spring", stiffness: 520, damping: 40 },
    lift: 8,
    row: 0.028,
    cell: 0.008,
    fill: { type: "spring", stiffness: 560, damping: 42 },
  },
  // A longer drop and a slower wave, for a booking screen where picking
  // the date is the task.
  playful: {
    panel: { type: "spring", stiffness: 400, damping: 33 },
    lift: 12,
    row: 0.042,
    cell: 0.012,
    fill: { type: "spring", stiffness: 420, damping: 34 },
  },
};

/** Theme-adaptive neutral: `currentColor` is the text color this component
 *  inherits — near-black on a light page, near-white on a dark one — so
 *  mixing it with `transparent` yields a surface, border or hover fill
 *  that is correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const MONTHS = [
  "January", "February", "March", "April", "May", "June",
  "July", "August", "September", "October", "November", "December",
];
const WEEKDAYS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];

type Ymd = { y: number; m: number; d: number };

const same = (a: Ymd, b: Ymd) => a.y === b.y && a.m === b.m && a.d === b.d;
const fromDate = (date: Date): Ymd => ({
  y: date.getFullYear(),
  m: date.getMonth(),
  d: date.getDate(),
});

/** Monday-first weeks, padded with nulls so every row holds seven cells. */
function weeksOf(year: number, month: number): (number | null)[][] {
  const lead = (new Date(year, month, 1).getDay() + 6) % 7;
  const length = new Date(year, month + 1, 0).getDate();
  const cells: (number | null)[] = [
    ...Array<null>(lead).fill(null),
    ...Array.from({ length }, (_, index) => index + 1),
  ];
  while (cells.length % 7 !== 0) cells.push(null);
  const weeks: (number | null)[][] = [];
  for (let index = 0; index < cells.length; index += 7) {
    weeks.push(cells.slice(index, index + 7));
  }
  return weeks;
}

export default function DatePickerOpen({
  variant = "default",
  label = "Delivery date",
  width = 268,
  accent = "#5B5BD6",
  onSelect,
}: DatePickerOpenProps) {
  // Read the clock once, so a component that lives across midnight does
  // not silently disagree with itself about which day is today.
  const [today] = useState<Ymd>(() => fromDate(new Date()));
  const [selected, setSelected] = useState<Ymd>(today);
  const [view, setView] = useState({ y: today.y, m: today.m });
  const [cursor, setCursor] = useState(today.d);
  const [open, setOpen] = useState(false);
  const [ring, setRing] = useState(false);
  const fieldRef = useRef<HTMLButtonElement>(null);
  const gridRef = useRef<HTMLDivElement>(null);
  const baseId = useId();
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const weeks = weeksOf(view.y, view.m);

  // Pointer down anywhere else closes the panel.
  useEffect(() => {
    if (!open) return;
    const onPointerDown = (event: PointerEvent) => {
      const root = fieldRef.current?.parentElement;
      if (root && !root.contains(event.target as Node)) setOpen(false);
    };
    document.addEventListener("pointerdown", onPointerDown);
    return () => document.removeEventListener("pointerdown", onPointerDown);
  }, [open]);

  // The grid owns the focus while it is open: opening lands on the day
  // the user would edit, and every arrow key moves that focus with it.
  useEffect(() => {
    if (!open) return;
    const cell = gridRef.current?.querySelector<HTMLButtonElement>(
      `[data-day="${cursor}"]`
    );
    cell?.focus();
  }, [open, cursor, view.y, view.m]);

  const openPanel = () => {
    setView({ y: selected.y, m: selected.m });
    setCursor(selected.d);
    setOpen(true);
  };

  const close = () => {
    setOpen(false);
    fieldRef.current?.focus();
  };

  /** Walk the cursor by whole days, letting the month change under it. */
  const move = (deltaDays: number) => {
    const next = new Date(view.y, view.m, cursor + deltaDays);
    setView({ y: next.getFullYear(), m: next.getMonth() });
    setCursor(next.getDate());
  };

  const shiftMonth = (delta: number) => {
    const next = new Date(view.y, view.m + delta, 1);
    const length = new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate();
    setView({ y: next.getFullYear(), m: next.getMonth() });
    setCursor(Math.min(cursor, length));
  };

  const choose = (day: number) => {
    const picked = { y: view.y, m: view.m, d: day };
    setSelected(picked);
    setCursor(day);
    setOpen(false);
    onSelect?.(new Date(picked.y, picked.m, picked.d));
    fieldRef.current?.focus();
  };

  const onGridKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    const key = event.key;
    if (key === "Escape") {
      event.preventDefault();
      close();
    } else if (key === "ArrowRight") {
      event.preventDefault();
      move(1);
    } else if (key === "ArrowLeft") {
      event.preventDefault();
      move(-1);
    } else if (key === "ArrowDown") {
      event.preventDefault();
      move(7);
    } else if (key === "ArrowUp") {
      event.preventDefault();
      move(-7);
    } else if (key === "Home") {
      event.preventDefault();
      // Monday of the cursor's week.
      move(-((new Date(view.y, view.m, cursor).getDay() + 6) % 7));
    } else if (key === "End") {
      event.preventDefault();
      move(6 - ((new Date(view.y, view.m, cursor).getDay() + 6) % 7));
    } else if (key === "PageUp") {
      event.preventDefault();
      shiftMonth(-1);
    } else if (key === "PageDown") {
      event.preventDefault();
      shiftMonth(1);
    }
  };

  const fieldText = `${selected.d} ${MONTHS[selected.m].slice(0, 3)} ${selected.y}`;

  const navButton = (delta: number, path: string, name: string) => (
    <button
      type="button"
      aria-label={name}
      onClick={() => shiftMonth(delta)}
      style={{
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
        width: 26,
        height: 26,
        padding: 0,
        borderRadius: 7,
        border: "none",
        background: "transparent",
        color: "inherit",
        opacity: 0.6,
        cursor: "pointer",
      }}
    >
      <svg viewBox="0 0 16 16" width={14} height={14} fill="none" aria-hidden>
        <path
          d={path}
          stroke="currentColor"
          strokeWidth={1.7}
          strokeLinecap="round"
          strokeLinejoin="round"
        />
      </svg>
    </button>
  );

  return (
    <div style={{ position: "relative", width, color: "inherit" }}>
      <div
        id={`${baseId}-label`}
        style={{ fontSize: 12.5, fontWeight: 600, opacity: 0.6, marginBottom: 7 }}
      >
        {label}
      </div>

      <button
        ref={fieldRef}
        type="button"
        aria-haspopup="dialog"
        aria-expanded={open}
        aria-labelledby={`${baseId}-label`}
        onClick={() => (open ? setOpen(false) : openPanel())}
        onKeyDown={(event) => {
          if (event.key === "ArrowDown" && !open) {
            event.preventDefault();
            openPanel();
          }
        }}
        // The ring is for keyboard users only. `:focus-visible` is the
        // browser's own answer to "was this focus deliberate?" — read it
        // instead of guessing at the input modality.
        onFocus={(event) => setRing(event.currentTarget.matches(":focus-visible"))}
        onBlur={() => setRing(false)}
        style={{
          display: "flex",
          alignItems: "center",
          gap: 9,
          width: "100%",
          padding: "10px 12px",
          borderRadius: 10,
          fontSize: 13.5,
          fontFamily: "inherit",
          textAlign: "left",
          color: "inherit",
          background: tone(6),
          border: `1px solid ${open ? accent : tone(14)}`,
          cursor: "pointer",
          outline: "none",
          boxShadow: ring ? `0 0 0 3px ${accent}66` : "none",
          transition: "border-color 160ms ease-out, box-shadow 140ms ease-out",
          WebkitTapHighlightColor: "transparent",
        }}
      >
        <svg viewBox="0 0 18 18" width={15} height={15} fill="none" aria-hidden>
          <rect
            x={2.2}
            y={3.4}
            width={13.6}
            height={12}
            rx={2.4}
            stroke="currentColor"
            strokeWidth={1.5}
          />
          <path
            d="M2.2 7.2 H15.8 M6.2 1.9 V4.4 M11.8 1.9 V4.4"
            stroke="currentColor"
            strokeWidth={1.5}
            strokeLinecap="round"
          />
        </svg>
        <span style={{ flex: 1 }}>{fieldText}</span>
      </button>

      <AnimatePresence>
        {open ? (
          <motion.div
            role="dialog"
            aria-label={`${MONTHS[view.m]} ${view.y}`}
            initial={{ opacity: 0, y: reduceMotion ? 0 : -cfg.lift }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: reduceMotion ? 0 : -cfg.lift * 0.6 }}
            transition={
              reduceMotion
                ? { duration: 0.1 }
                : { ...cfg.panel, opacity: { duration: 0.14 } }
            }
            style={{
              position: "absolute",
              top: "100%",
              left: 0,
              zIndex: 20,
              marginTop: 8,
              padding: 12,
              borderRadius: 14,
              border: `1px solid ${tone(14)}`,
              // The panel sits over page content, so it needs a real
              // opaque background. `Canvas`/`CanvasText` are the CSS
              // system colors for page background and page text: they
              // follow the user's light or dark setting without this file
              // hard-coding either one.
              background: "Canvas",
              color: "CanvasText",
              boxShadow: "0 16px 40px rgba(0,0,0,0.24)",
            }}
          >
            <div
              style={{
                display: "flex",
                alignItems: "center",
                justifyContent: "space-between",
                marginBottom: 8,
              }}
            >
              {navButton(-1, "M10 3.5 L5.5 8 L10 12.5", "Previous month")}
              <div id={`${baseId}-month`} style={{ fontSize: 13, fontWeight: 650 }}>
                {MONTHS[view.m]} {view.y}
              </div>
              {navButton(1, "M6 3.5 L10.5 8 L6 12.5", "Next month")}
            </div>

            <div
              ref={gridRef}
              role="grid"
              aria-labelledby={`${baseId}-month`}
              onKeyDown={onGridKeyDown}
            >
              <div role="row" style={{ display: "flex" }}>
                {WEEKDAYS.map((day) => (
                  <div
                    key={day}
                    role="columnheader"
                    aria-label={day}
                    style={{
                      width: 34,
                      height: 22,
                      display: "flex",
                      alignItems: "center",
                      justifyContent: "center",
                      fontSize: 11,
                      fontWeight: 650,
                      opacity: 0.4,
                    }}
                  >
                    {day}
                  </div>
                ))}
              </div>

              {weeks.map((week, rowIndex) => (
                <div
                  key={`${view.y}-${view.m}-${rowIndex}`}
                  role="row"
                  style={{ display: "flex" }}
                >
                  {week.map((day, columnIndex) => {
                    if (day === null) {
                      return (
                        // An empty cell rather than no cell: a grid row
                        // that is short of columns is a grid a screen
                        // reader cannot navigate.
                        <div
                          key={`pad-${columnIndex}`}
                          role="gridcell"
                          style={{ width: 34, height: 29 }}
                        />
                      );
                    }
                    const cell = { y: view.y, m: view.m, d: day };
                    const isSelected = same(cell, selected);
                    const isToday = same(cell, today);
                    return (
                      <div
                        key={day}
                        role="gridcell"
                        aria-selected={isSelected}
                        style={{ width: 34, height: 29 }}
                      >
                        {/* The wave: each day fades up a few pixels, later
                            down the weeks and later across each one. It
                            gives the grid a reading order without anything
                            having to scale. */}
                        <motion.button
                          type="button"
                          data-day={day}
                          // Roving tabindex: the grid is one tab stop and
                          // the arrow keys move inside it.
                          tabIndex={day === cursor ? 0 : -1}
                          aria-label={`${day} ${MONTHS[view.m]} ${view.y}`}
                          aria-current={isToday ? "date" : undefined}
                          onClick={() => choose(day)}
                          initial={{ opacity: 0, y: reduceMotion ? 0 : -4 }}
                          animate={{ opacity: 1, y: 0 }}
                          transition={{
                            duration: reduceMotion ? 0 : 0.16,
                            delay: reduceMotion
                              ? 0
                              : rowIndex * cfg.row + columnIndex * cfg.cell,
                            ease: "easeOut",
                          }}
                          style={{
                            position: "relative",
                            width: 34,
                            height: 29,
                            padding: 0,
                            borderRadius: 9,
                            border: "none",
                            background: "transparent",
                            fontFamily: "inherit",
                            fontSize: 12.5,
                            fontWeight: isSelected || isToday ? 700 : 500,
                            color: "inherit",
                            cursor: "pointer",
                            outline: "none",
                            boxShadow:
                              day === cursor && !isSelected
                                ? `inset 0 0 0 1.5px ${accent}80`
                                : "none",
                            WebkitTapHighlightColor: "transparent",
                          }}
                        >
                          {isSelected ? (
                            // A disc behind the numeral, so the fill can
                            // spring in while the number itself holds its
                            // size — text must never ride a scale.
                            <motion.span
                              aria-hidden
                              initial={{ scale: reduceMotion ? 1 : 0.4, opacity: 0 }}
                              animate={{ scale: 1, opacity: 1 }}
                              transition={
                                reduceMotion
                                  ? { duration: 0.1 }
                                  : { ...cfg.fill, opacity: { duration: 0.1 } }
                              }
                              style={{
                                position: "absolute",
                                inset: 2,
                                borderRadius: 9,
                                background: accent,
                              }}
                            />
                          ) : null}

                          <span
                            style={{
                              position: "relative",
                              color: isSelected ? "#FFFFFF" : "inherit",
                            }}
                          >
                            {day}
                          </span>

                          {/* Today keeps a marker of its own, so the grid
                              still answers "where am I?" after another
                              day is chosen. */}
                          {isToday && !isSelected ? (
                            <span
                              aria-hidden
                              style={{
                                position: "absolute",
                                left: "50%",
                                bottom: 4,
                                width: 3,
                                height: 3,
                                marginLeft: -1.5,
                                borderRadius: 3,
                                background: accent,
                              }}
                            />
                          ) : null}
                        </motion.button>
                      </div>
                    );
                  })}
                </div>
              ))}
            </div>
          </motion.div>
        ) : null}
      </AnimatePresence>
    </div>
  );
}

About this pattern

Forty small numbers appearing at once is a wall; the same forty arriving in a wave that runs down the weeks and across each one gives the grid a reading order for free, and it costs only opacity and four pixels of travel. Nothing scales, because everything in a calendar is a numeral. The chosen day is a disc that springs in behind its number rather than under it, so the digit never rides the fill. Today keeps a marker of its own after another day is picked, and the grid is a real one: arrows walk it, crossing into the next month when they run off the edge, Page keys change month, Escape hands focus back to the field.

Delivery or booking dateReport range startDue date fieldAppointment scheduling

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • MTWTFSS
    12345678910111213141516
    17
    1819202122232425262728
    Calendar

    A calendar that opens onto the current month with the day already marked.

Related patterns