Due Date Chip
Inputs & Forms

Due Date Chip

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.

Install

npx shadcn@latest add @paragon/due-date-chip

Also installs: mini-month, calendar

due-date-chip.tsx

"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>
  );
}