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.
npx shadcn@latest add @paragon/snooze-picker"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>
);
}