Snooze Picker
Inputs & Forms

Snooze Picker

Snooze menu — later today, tomorrow, weekend, next week, or pick a time — with computed concrete times right-aligned, and the chosen time flying into the chip on select.

Install

npx shadcn@latest add @paragon/snooze-picker

snooze-picker.tsx

"use client";

import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { AlarmClock, CalendarClock, ChevronLeft, X } from "lucide-react";
import { cn } from "@/lib/utils";

const WEEKDAYS_SHORT = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const MONTHS_SHORT = [
  "Jan", "Feb", "Mar", "Apr", "May", "Jun",
  "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];

function at(base: Date, dayOffset: number, hour: number): Date {
  return new Date(
    base.getFullYear(),
    base.getMonth(),
    base.getDate() + dayOffset,
    hour,
    0,
    0,
    0,
  );
}

function fmtTime(d: Date): string {
  const h = d.getHours();
  const h12 = h % 12 === 0 ? 12 : h % 12;
  const mm = String(d.getMinutes()).padStart(2, "0");
  return `${h12}:${mm} ${h < 12 ? "AM" : "PM"}`;
}

/** "6:00 PM" for today, otherwise "Sat 9:00 AM". */
function fmtConcrete(d: Date, now: Date): string {
  const sameDay =
    d.getFullYear() === now.getFullYear() &&
    d.getMonth() === now.getMonth() &&
    d.getDate() === now.getDate();
  return sameDay ? fmtTime(d) : `${WEEKDAYS_SHORT[d.getDay()]} ${fmtTime(d)}`;
}

const TIME_SLOTS = [
  { hour: 9, label: "9 AM" },
  { hour: 12, label: "12 PM" },
  { hour: 15, label: "3 PM" },
  { hour: 18, label: "6 PM" },
];

const snoozePickerStyles = `
@keyframes pg-snooze-in { from { opacity: 0; scale: 0.97; } }
@keyframes pg-snooze-out { to { opacity: 0; scale: 0.99; } }
@media (prefers-reduced-motion: reduce) {
  @keyframes pg-snooze-in { from { opacity: 0; } }
  @keyframes pg-snooze-out { to { opacity: 0; } }
}
`;

export interface SnoozePickerProps
  extends Omit<React.ComponentProps<"button">, "value" | "defaultValue" | "onChange"> {
  /** Controlled snooze-until value. */
  value?: Date | null;
  defaultValue?: Date | null;
  onChange?: (until: Date | null) => void;
  /** Reference time. Pass a fixed date for deterministic renders. */
  now?: Date;
  /** Idle trigger label. */
  label?: string;
  /** Disables the row-into-chip layout flight. */
  static?: boolean;
}

/**
 * Snooze picker: later today / tomorrow / weekend / next week with the
 * computed concrete times right-aligned in tabular figures, plus a
 * pick-a-time pane (time-slot pills over the next seven days). On select
 * the chosen time literally flies from its row into the chip via a shared
 * layout animation.
 */
export function SnoozePicker({
  value,
  defaultValue = null,
  onChange,
  now,
  label = "Snooze",
  static: isStatic = false,
  className,
  disabled,
  ...props
}: SnoozePickerProps) {
  const uid = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
  const reduced = useReducedMotion();
  const [open, setOpen] = React.useState(false);
  const [pane, setPane] = React.useState<"main" | "custom">("main");
  const [slotHour, setSlotHour] = React.useState(9);
  const [internal, setInternal] = React.useState<Date | null>(defaultValue);
  const until = value !== undefined ? value : internal;

  const [fallbackNow] = React.useState(() => new Date());
  const ref = now ?? fallbackNow;

  const animate = !isStatic && !reduced;
  const flyId = `pg-snooze-fly-${uid}`;
  const [flying, setFlying] = React.useState<string | null>(null);
  const flyTimeout = React.useRef<ReturnType<typeof setTimeout>>(null);
  React.useEffect(() => {
    return () => {
      if (flyTimeout.current) clearTimeout(flyTimeout.current);
    };
  }, []);

  const commit = (next: Date | null, rowKey?: string) => {
    if (next && rowKey && animate) {
      setFlying(rowKey);
      if (flyTimeout.current) clearTimeout(flyTimeout.current);
      flyTimeout.current = setTimeout(() => setFlying(null), 450);
    }
    setInternal(next);
    onChange?.(next);
    setOpen(false);
  };

  // Deterministic quick options relative to `ref`.
  const laterToday = at(ref, 0, Math.min(23, ref.getHours() + 3));
  const tomorrow = at(ref, 1, 9);
  const daysToSat = ((6 - ref.getDay()) % 7) || 7;
  const weekend = at(ref, daysToSat, 9);
  const daysToMon = ((8 - ref.getDay()) % 7) || 7;
  const nextWeek = at(ref, daysToMon, 9);

  const quick = [
    { key: "later", label: "Later today", date: laterToday },
    { key: "tomorrow", label: "Tomorrow", date: tomorrow },
    { key: "weekend", label: "This weekend", date: weekend },
    { key: "next-week", label: "Next week", date: nextWeek },
  ];

  const listNav = (e: React.KeyboardEvent<HTMLDivElement>) => {
    const root = e.currentTarget;
    const buttons = Array.from(
      root.querySelectorAll<HTMLButtonElement>("[data-snooze-row]"),
    );
    const idx = buttons.indexOf(document.activeElement as HTMLButtonElement);
    if (e.key === "ArrowDown" || e.key === "ArrowUp") {
      e.preventDefault();
      const next =
        (Math.max(0, idx) + (e.key === "ArrowDown" ? 1 : -1) + buttons.length) %
        buttons.length;
      buttons[next]?.focus();
    }
  };

  const chipLabel = until ? fmtConcrete(until, ref) : label;

  return (
    <PopoverPrimitive.Root
      open={open}
      onOpenChange={(next) => {
        setOpen(next);
        if (next) setPane("main");
      }}
    >
      <PopoverPrimitive.Trigger asChild disabled={disabled}>
        <button
          type="button"
          data-slot="snooze-picker"
          aria-label={until ? `Snoozed until ${fmtConcrete(until, ref)}` : label}
          className={cn(
            "group inline-flex h-7 items-center gap-1.5 rounded-md border border-input bg-transparent px-2 text-xs font-medium whitespace-nowrap",
            "transition-[background-color,border-color,box-shadow,scale] duration-150 ease-(--ease-out)",
            "outline-none hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/25",
            "data-[state=open]:border-ring disabled:pointer-events-none disabled:opacity-50",
            !isStatic && "active:not-disabled:scale-[0.97]",
            className,
          )}
          {...props}
        >
          <AlarmClock
            className={cn(
              "size-3.5 shrink-0",
              until ? "text-foreground" : "text-muted-foreground",
            )}
            aria-hidden
          />
          {flying && animate ? (
            <motion.span
              layoutId={flyId}
              transition={{ type: "spring", duration: 0.4, bounce: 0 }}
              className="tabular-nums"
            >
              {chipLabel}
            </motion.span>
          ) : (
            <AnimatePresence mode="popLayout" initial={false}>
              <motion.span
                key={until ? until.getTime() : "idle"}
                initial={{ opacity: 0, filter: animate ? "blur(4px)" : "blur(0px)" }}
                animate={{ opacity: 1, filter: "blur(0px)" }}
                exit={{
                  opacity: 0,
                  filter: animate ? "blur(4px)" : "blur(0px)",
                  transition: { duration: 0.1 },
                }}
                transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}
                className={cn("tabular-nums", !until && "text-muted-foreground")}
              >
                {chipLabel}
              </motion.span>
            </AnimatePresence>
          )}
        </button>
      </PopoverPrimitive.Trigger>
      {/* Hoisted outside the Portal: React 19 keeps a hoistable <style> as a
          child node, and the Radix Portal enforces a single child. */}
      <style href="paragon-snooze-picker" precedence="paragon">
        {snoozePickerStyles}
      </style>
      <PopoverPrimitive.Portal>
        <PopoverPrimitive.Content
          align="start"
          sideOffset={6}
          collisionPadding={8}
          className={cn(
            "z-50 w-64 origin-(--radix-popover-content-transform-origin) overflow-x-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-overlay outline-none",
            "data-[state=open]:animate-[pg-snooze-in_180ms_var(--ease-out)]",
            "data-[state=closed]:animate-[pg-snooze-out_90ms_var(--ease-exit)_forwards]",
          )}
        >
          <AnimatePresence mode="popLayout" initial={false}>
            {pane === "main" ? (
              <motion.div
                key="main"
                initial={{ opacity: 0, x: -10, filter: animate ? "blur(2px)" : "blur(0px)" }}
                animate={{ opacity: 1, x: 0, filter: "blur(0px)" }}
                exit={{
                  opacity: 0,
                  x: -10,
                  filter: animate ? "blur(2px)" : "blur(0px)",
                  transition: { duration: 0.1 },
                }}
                transition={{ duration: 0.16, ease: [0.22, 1, 0.36, 1] }}
                role="group"
                aria-label="Snooze until"
                onKeyDown={listNav}
              >
                {quick.map((q, i) => (
                  <button
                    key={q.key}
                    type="button"
                    data-snooze-row
                    onClick={() => commit(q.date, q.key)}
                    onKeyDown={(e) => {
                      if (/^[1-4]$/.test(e.key)) {
                        e.preventDefault();
                        const t = quick[Number(e.key) - 1];
                        commit(t.date, t.key);
                      }
                    }}
                    className={cn(
                      "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs outline-none",
                      "transition-[background-color] duration-100 ease-(--ease-out)",
                      "hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground",
                    )}
                  >
                    <span className="min-w-0 flex-1 truncate">{q.label}</span>
                    {flying === q.key && animate ? (
                      <motion.span
                        layoutId={flyId}
                        transition={{ type: "spring", duration: 0.4, bounce: 0 }}
                        className="shrink-0 text-[11px] text-muted-foreground tabular-nums"
                      >
                        {fmtConcrete(q.date, ref)}
                      </motion.span>
                    ) : (
                      <span className="shrink-0 text-[11px] text-muted-foreground tabular-nums">
                        {fmtConcrete(q.date, ref)}
                      </span>
                    )}
                    <span
                      aria-hidden
                      className="w-3 shrink-0 text-right font-mono text-[10px] text-muted-foreground/70 tabular-nums"
                    >
                      {i + 1}
                    </span>
                  </button>
                ))}
                <button
                  type="button"
                  data-snooze-row
                  onClick={() => setPane("custom")}
                  className={cn(
                    "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs outline-none",
                    "transition-[background-color] duration-100 ease-(--ease-out)",
                    "hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground",
                  )}
                >
                  <CalendarClock className="size-3.5 shrink-0 text-muted-foreground" aria-hidden />
                  <span className="min-w-0 flex-1 truncate">Pick a time…</span>
                </button>
                {until && (
                  <>
                    <div className="mx-1 my-1 h-px bg-border" role="separator" />
                    <button
                      type="button"
                      data-snooze-row
                      onClick={() => commit(null)}
                      className={cn(
                        "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-muted-foreground outline-none",
                        "transition-[background-color,color] duration-100 ease-(--ease-out)",
                        "hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground",
                      )}
                    >
                      <X className="size-3.5 shrink-0" aria-hidden />
                      Remove snooze
                    </button>
                  </>
                )}
              </motion.div>
            ) : (
              <motion.div
                key="custom"
                initial={{ opacity: 0, x: 10, filter: animate ? "blur(2px)" : "blur(0px)" }}
                animate={{ opacity: 1, x: 0, filter: "blur(0px)" }}
                exit={{
                  opacity: 0,
                  x: 10,
                  filter: animate ? "blur(2px)" : "blur(0px)",
                  transition: { duration: 0.1 },
                }}
                transition={{ duration: 0.16, ease: [0.22, 1, 0.36, 1] }}
              >
                <div className="flex items-center gap-1 px-1 pt-0.5 pb-1">
                  <button
                    type="button"
                    aria-label="Back to quick options"
                    onClick={() => setPane("main")}
                    className={cn(
                      "pressable relative flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground",
                      "transition-[background-color,color] duration-100 ease-(--ease-out)",
                      "outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
                      "after:absolute after:top-1/2 after:left-1/2 after:size-9 after:-translate-1/2",
                    )}
                  >
                    <ChevronLeft className="size-3.5" aria-hidden />
                  </button>
                  <span
                    role="radiogroup"
                    aria-label="Time of day"
                    className="flex flex-1 items-center justify-end gap-1"
                  >
                    {TIME_SLOTS.map((slot) => {
                      const isOn = slot.hour === slotHour;
                      return (
                        <button
                          key={slot.hour}
                          type="button"
                          role="radio"
                          aria-checked={isOn}
                          onClick={() => setSlotHour(slot.hour)}
                          className={cn(
                            "h-5 rounded-full px-1.5 text-[10px] font-medium tabular-nums outline-none",
                            "transition-[background-color,color,scale] duration-150 ease-(--ease-out)",
                            "focus-visible:ring-2 focus-visible:ring-ring",
                            !isStatic && "active:scale-[0.95]",
                            isOn
                              ? "bg-primary text-primary-foreground"
                              : "text-muted-foreground hover:bg-accent hover:text-foreground",
                          )}
                        >
                          {slot.label}
                        </button>
                      );
                    })}
                  </span>
                </div>
                <div role="group" aria-label="Snooze day" onKeyDown={listNav}>
                  {Array.from({ length: 7 }, (_, i) => {
                    const d = at(ref, i + 1, slotHour);
                    const dayLabel =
                      i === 0 ? "Tomorrow" : WEEKDAYS_SHORT[d.getDay()];
                    const rowKey = `day-${i}`;
                    return (
                      <button
                        key={rowKey}
                        type="button"
                        data-snooze-row
                        onClick={() => commit(d, rowKey)}
                        className={cn(
                          "flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs outline-none",
                          "transition-[background-color] duration-100 ease-(--ease-out)",
                          "hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground",
                        )}
                      >
                        <span className="min-w-0 flex-1 truncate">{dayLabel}</span>
                        {flying === rowKey && animate ? (
                          <motion.span
                            layoutId={flyId}
                            transition={{ type: "spring", duration: 0.4, bounce: 0 }}
                            className="shrink-0 text-[11px] text-muted-foreground tabular-nums"
                          >
                            {fmtConcrete(d, ref)}
                          </motion.span>
                        ) : (
                          <span className="shrink-0 text-[11px] text-muted-foreground tabular-nums">
                            {`${MONTHS_SHORT[d.getMonth()]} ${d.getDate()} · ${fmtTime(d)}`}
                          </span>
                        )}
                      </button>
                    );
                  })}
                </div>
              </motion.div>
            )}
          </AnimatePresence>
        </PopoverPrimitive.Content>
      </PopoverPrimitive.Portal>
    </PopoverPrimitive.Root>
  );
}