Relative due-date chip (in 3d, Tomorrow, destructive overdue) opening a quick-options rail with concrete dates plus an inline mini-month, with hover clear.
npx shadcn@latest add @paragon/due-date-chipAlso installs: mini-month, calendar
"use client";
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { CalendarDays, X } from "lucide-react";
import { MiniMonth } from "@/registry/paragon/ui/mini-month";
import { addDays, MONTH_NAMES, startOfDay } from "@/registry/paragon/ui/calendar";
import { cn } from "@/lib/utils";
const WEEKDAYS_SHORT = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const DAY_MS = 86_400_000;
function diffDays(from: Date, to: Date): number {
return Math.round((startOfDay(to).getTime() - startOfDay(from).getTime()) / DAY_MS);
}
function shortDate(d: Date, withWeekday = false): string {
const base = `${MONTH_NAMES[d.getMonth()].slice(0, 3)} ${d.getDate()}`;
return withWeekday ? `${WEEKDAYS_SHORT[d.getDay()]} ${base}` : base;
}
/** Relative label for a due date: "Today", "in 3d", "2d overdue", "Aug 14". */
export function relativeDueLabel(due: Date, now: Date): string {
const n = diffDays(now, due);
if (n < 0) return `${-n}d overdue`;
if (n === 0) return "Today";
if (n === 1) return "Tomorrow";
if (n < 14) return `in ${n}d`;
const sameYear = due.getFullYear() === now.getFullYear();
return sameYear ? shortDate(due) : `${shortDate(due)}, ${due.getFullYear()}`;
}
const dueDateChipStyles = `
@keyframes pg-due-in { from { opacity: 0; scale: 0.97; } }
@keyframes pg-due-out { to { opacity: 0; scale: 0.99; } }
@media (prefers-reduced-motion: reduce) {
@keyframes pg-due-in { from { opacity: 0; } }
@keyframes pg-due-out { to { opacity: 0; } }
}
`;
export interface DueDateChipProps
extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
/** Controlled due date. */
value?: Date | null;
defaultValue?: Date | null;
onChange?: (date: Date | null) => void;
/** Reference "today". Pass a fixed date for deterministic renders. */
now?: Date;
/** Show the hover clear button on the chip. */
clearable?: boolean;
placeholder?: string;
/** Disables trigger swap motion. */
static?: boolean;
}
/**
* Due-date chip: relative display ("in 3d", "Tomorrow", destructive
* "2d overdue") that opens a quick-options rail (today / tomorrow / next
* week with concrete dates right-aligned) above an inline mini-month.
* Digits 1–3 pick from the rail, selecting closes and blur-swaps the chip,
* and the date clears from the chip or the footer row.
*/
export function DueDateChip({
value,
defaultValue = null,
onChange,
now,
clearable = true,
placeholder = "Due date",
static: isStatic = false,
className,
...props
}: DueDateChipProps) {
const reduced = useReducedMotion();
const [open, setOpen] = React.useState(false);
const [internal, setInternal] = React.useState<Date | null>(defaultValue);
const date = value !== undefined ? value : internal;
const [fallbackNow] = React.useState(() => startOfDay(new Date()));
const today = now ? startOfDay(now) : fallbackNow;
const commit = (next: Date | null, close = true) => {
setInternal(next);
onChange?.(next);
if (close) setOpen(false);
};
const nextMonday = addDays(today, ((8 - today.getDay()) % 7) || 7);
const quick = [
{ id: "today", label: "Today", date: today, hint: WEEKDAYS_SHORT[today.getDay()] },
{ id: "tomorrow", label: "Tomorrow", date: addDays(today, 1), hint: WEEKDAYS_SHORT[addDays(today, 1).getDay()] },
{ id: "next-week", label: "Next week", date: nextMonday, hint: shortDate(nextMonday, true) },
];
const railRef = React.useRef<HTMLDivElement>(null);
const onRailKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const buttons = Array.from(
railRef.current?.querySelectorAll<HTMLButtonElement>("button") ?? [],
);
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();
} else if (/^[1-3]$/.test(e.key)) {
e.preventDefault();
commit(quick[Number(e.key) - 1].date);
}
};
const overdue = date ? diffDays(today, date) < 0 : false;
const animate = !isStatic && !reduced;
const blur = animate ? "blur(4px)" : "blur(0px)";
const chipKey = date ? `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}` : "none";
return (
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
<div
data-slot="due-date-chip"
className={cn(
"group/chip relative inline-flex items-center rounded-md border border-input bg-transparent",
"transition-[background-color,border-color,box-shadow,scale] duration-150 ease-(--ease-out)",
"hover:bg-accent/50 has-[[data-chip-trigger]:active]:scale-[0.97] motion-reduce:has-[[data-chip-trigger]:active]:scale-100",
"has-[[data-state=open]]:border-ring has-[button:focus-visible]:border-ring has-[button:focus-visible]:ring-[3px] has-[button:focus-visible]:ring-ring/25",
isStatic && "has-[[data-chip-trigger]:active]:scale-100",
className,
)}
{...props}
>
<PopoverPrimitive.Trigger asChild>
<button
type="button"
data-chip-trigger
aria-label={
date
? `Due ${shortDate(date, true)}${overdue ? ", overdue" : ""}`
: "Set due date"
}
className={cn(
"inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-xs font-medium whitespace-nowrap outline-none",
overdue ? "text-destructive" : date ? "text-foreground" : "text-muted-foreground",
)}
>
<CalendarDays className="size-3.5 shrink-0" aria-hidden />
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={chipKey}
initial={{ opacity: 0, filter: blur }}
animate={{ opacity: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, filter: blur, transition: { duration: 0.1 } }}
transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}
className="tabular-nums"
>
{date ? relativeDueLabel(date, today) : placeholder}
</motion.span>
</AnimatePresence>
</button>
</PopoverPrimitive.Trigger>
{clearable && date && (
<button
type="button"
aria-label="Clear due date"
onClick={() => commit(null, false)}
className={cn(
"relative mr-1 -ml-0.5 flex size-4 shrink-0 items-center justify-center rounded-sm text-muted-foreground",
"pointer-events-none opacity-0 transition-[opacity,color] duration-100 ease-(--ease-out)",
"group-hover/chip:pointer-events-auto group-hover/chip:opacity-100",
"focus-visible:pointer-events-auto focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring",
"hover:text-foreground outline-none",
"after:absolute after:top-1/2 after:left-1/2 after:size-6 after:-translate-1/2",
)}
>
<X className="size-3" aria-hidden />
</button>
)}
</div>
{/* 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-due-date-chip" precedence="paragon">
{dueDateChipStyles}
</style>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
align="start"
sideOffset={6}
collisionPadding={8}
className={cn(
"z-50 w-auto origin-(--radix-popover-content-transform-origin) rounded-lg bg-popover p-1.5 text-popover-foreground shadow-overlay outline-none",
"data-[state=open]:animate-[pg-due-in_180ms_var(--ease-out)]",
"data-[state=closed]:animate-[pg-due-out_90ms_var(--ease-exit)_forwards]",
)}
>
<div
ref={railRef}
role="group"
aria-label="Quick due dates"
onKeyDown={onRailKeyDown}
>
{quick.map((q, i) => (
<button
key={q.id}
type="button"
onClick={() => commit(q.date)}
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>
<span className="shrink-0 text-[11px] text-muted-foreground tabular-nums">
{q.hint}
</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>
))}
</div>
<div className="mx-1 my-1 h-px bg-border" role="separator" />
<MiniMonth
className="px-1 pb-0.5"
now={today}
selected={date}
defaultMonth={date ?? today}
onSelect={(d) => commit(d)}
static={isStatic}
aria-label="Due date"
/>
{date && (
<>
<div className="mx-1 my-1 h-px bg-border" role="separator" />
<button
type="button"
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 />
Clear due date
</button>
</>
)}
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
);
}