Recurrence Editor
Inputs & Forms

Recurrence Editor

An "Every N days/weeks/months" composer: a stepper interval, a sliding segmented unit control, weekday toggle pills that pop on press, a monthly ordinal select, and a live human-readable summary that blur-swaps as you edit. Emits an RRULE-lite object.

Install

npx shadcn@latest add @paragon/recurrence-editor

recurrence-editor.tsx

"use client";

import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, ChevronDown, ChevronUp, Repeat2 } from "lucide-react";
import { cn } from "@/lib/utils";

export type RecurrenceFreq = "daily" | "weekly" | "monthly";

/** RRULE-lite: the useful subset of RFC 5545 for product schedules. */
export interface RecurrenceRule {
  freq: RecurrenceFreq;
  interval: number;
  /** Weekly: selected weekdays, 0 (Sun) – 6 (Sat). */
  byDay?: number[];
  /** Monthly: day of month, or -1 for the last day. */
  byMonthDay?: number;
  /** Monthly ordinal week: 1–4, or -1 for last (with byWeekday). */
  bySetPos?: number;
  /** Weekday for the monthly ordinal, 0 (Sun) – 6 (Sat). */
  byWeekday?: number;
}

const UNITS: { id: RecurrenceFreq; label: string; plural: string }[] = [
  { id: "daily", label: "day", plural: "days" },
  { id: "weekly", label: "week", plural: "weeks" },
  { id: "monthly", label: "month", plural: "months" },
];

const DAY_LETTERS = ["S", "M", "T", "W", "T", "F", "S"];
const DAY_NAMES = [
  "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday",
];
const DAY_SHORT = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const ORDINAL_WORDS = ["first", "second", "third", "fourth"];

interface MonthlyMode {
  id: string;
  label: string;
  rule: Pick<RecurrenceRule, "byMonthDay" | "bySetPos" | "byWeekday">;
}

function monthlyModes(anchor: Date): MonthlyMode[] {
  const day = anchor.getDate();
  const weekday = anchor.getDay();
  const ordinal = Math.min(4, Math.ceil(day / 7));
  return [
    { id: "day", label: `on day ${day}`, rule: { byMonthDay: day } },
    {
      id: "ordinal",
      label: `on the ${ORDINAL_WORDS[ordinal - 1]} ${DAY_NAMES[weekday]}`,
      rule: { bySetPos: ordinal, byWeekday: weekday },
    },
    {
      id: "last-weekday",
      label: `on the last ${DAY_NAMES[weekday]}`,
      rule: { bySetPos: -1, byWeekday: weekday },
    },
    { id: "last-day", label: "on the last day", rule: { byMonthDay: -1 } },
  ];
}

function summarize(
  freq: RecurrenceFreq,
  interval: number,
  byDay: number[],
  monthly: MonthlyMode,
): string {
  const unit = UNITS.find((u) => u.id === freq)!;
  const every =
    interval === 1 ? `Every ${unit.label}` : `Every ${interval} ${unit.plural}`;
  if (freq === "weekly" && byDay.length > 0) {
    const days =
      byDay.length === 7
        ? "every day"
        : [...byDay].sort((a, b) => a - b).map((d) => DAY_SHORT[d]).join(", ");
    return `${every} on ${days}`;
  }
  if (freq === "monthly") return `${every} ${monthly.label}`;
  return every;
}

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

export interface RecurrenceEditorProps
  extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
  /** Seed rule. */
  defaultValue?: Partial<RecurrenceRule>;
  /** Emitted on every edit with the current RRULE-lite object. */
  onChange?: (rule: RecurrenceRule) => void;
  /** Anchor date for the monthly ordinal options ("on the first Tuesday"). */
  anchor?: Date;
  /** Disables pill pops and the summary swap. */
  static?: boolean;
}

/**
 * "Every N [days|weeks|months]" composer: a stepper interval, a sliding
 * segmented unit control, weekday toggle pills that pop on press, a
 * monthly ordinal select, and a live human-readable summary that
 * blur-swaps as you edit. Emits an RRULE-lite object on every change.
 */
export function RecurrenceEditor({
  defaultValue,
  onChange,
  anchor,
  static: isStatic = false,
  className,
  ...props
}: RecurrenceEditorProps) {
  const uid = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
  const reduced = useReducedMotion();
  const [fallbackAnchor] = React.useState(() => new Date());
  const anchorDate = anchor ?? fallbackAnchor;
  const modes = monthlyModes(anchorDate);

  const [freq, setFreq] = React.useState<RecurrenceFreq>(
    defaultValue?.freq ?? "weekly",
  );
  const [interval, setIntervalN] = React.useState(defaultValue?.interval ?? 1);
  const [intervalText, setIntervalText] = React.useState(
    String(defaultValue?.interval ?? 1),
  );
  const [byDay, setByDay] = React.useState<number[]>(
    defaultValue?.byDay ?? [anchorDate.getDay()],
  );
  const [monthlyId, setMonthlyId] = React.useState(() => {
    if (defaultValue?.byMonthDay === -1) return "last-day";
    if (defaultValue?.bySetPos === -1) return "last-weekday";
    if (defaultValue?.bySetPos !== undefined) return "ordinal";
    return "day";
  });
  const [monthlyOpen, setMonthlyOpen] = React.useState(false);
  const monthly = modes.find((m) => m.id === monthlyId) ?? modes[0];

  // Emit after each committed edit (skip the initial render).
  const first = React.useRef(true);
  React.useEffect(() => {
    if (first.current) {
      first.current = false;
      return;
    }
    const rule: RecurrenceRule = { freq, interval };
    if (freq === "weekly") rule.byDay = [...byDay].sort((a, b) => a - b);
    if (freq === "monthly") Object.assign(rule, monthly.rule);
    onChange?.(rule);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [freq, interval, byDay, monthlyId]);

  const commitInterval = (raw: string) => {
    const n = Math.max(1, Math.min(99, Math.round(Number(raw) || 1)));
    setIntervalN(n);
    setIntervalText(String(n));
  };
  const step = (delta: number) => {
    const n = Math.max(1, Math.min(99, interval + delta));
    setIntervalN(n);
    setIntervalText(String(n));
  };

  const toggleDay = (d: number) => {
    setByDay((prev) =>
      prev.includes(d) ? prev.filter((x) => x !== d) : [...prev, d],
    );
  };

  const animate = !isStatic && !reduced;
  const summary = summarize(freq, interval, byDay, monthly);
  const unitIndex = UNITS.findIndex((u) => u.id === freq);

  const onUnitKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
    if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
    e.preventDefault();
    const next =
      (unitIndex + (e.key === "ArrowRight" ? 1 : -1) + UNITS.length) %
      UNITS.length;
    setFreq(UNITS[next].id);
    (
      e.currentTarget.querySelectorAll("button")[next] as HTMLButtonElement
    )?.focus();
  };

  return (
    <div
      data-slot="recurrence-editor"
      className={cn("w-full max-w-sm select-none", className)}
      {...props}
    >
      <div className="flex flex-wrap items-center gap-2">
        <span className="text-xs text-muted-foreground">Every</span>

        {/* Interval stepper */}
        <span
          className={cn(
            "inline-flex h-7 items-stretch overflow-hidden rounded-md border border-input",
            "transition-[border-color,box-shadow] duration-150 ease-(--ease-out)",
            "focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/25",
          )}
        >
          <input
            value={intervalText}
            inputMode="numeric"
            aria-label="Repeat interval"
            onChange={(e) => {
              const raw = e.target.value.replace(/[^\d]/g, "").slice(0, 2);
              setIntervalText(raw);
              if (raw !== "") commitInterval(raw);
            }}
            onBlur={() => commitInterval(intervalText)}
            onKeyDown={(e) => {
              if (e.key === "ArrowUp") {
                e.preventDefault();
                step(1);
              } else if (e.key === "ArrowDown") {
                e.preventDefault();
                step(-1);
              }
            }}
            className="w-7 bg-transparent text-center text-xs font-medium outline-none tabular-nums"
          />
          <span className="flex flex-col border-l border-input">
            <button
              type="button"
              aria-label="Increase interval"
              tabIndex={-1}
              onClick={() => step(1)}
              className="flex flex-1 items-center justify-center px-0.5 text-muted-foreground transition-[background-color,color] duration-100 ease-(--ease-out) outline-none hover:bg-accent hover:text-foreground"
            >
              <ChevronUp className="size-2.5" aria-hidden />
            </button>
            <button
              type="button"
              aria-label="Decrease interval"
              tabIndex={-1}
              onClick={() => step(-1)}
              className="flex flex-1 items-center justify-center border-t border-input px-0.5 text-muted-foreground transition-[background-color,color] duration-100 ease-(--ease-out) outline-none hover:bg-accent hover:text-foreground"
            >
              <ChevronDown className="size-2.5" aria-hidden />
            </button>
          </span>
        </span>

        {/* Unit segmented control with sliding thumb */}
        <div
          role="radiogroup"
          aria-label="Repeat unit"
          onKeyDown={onUnitKeyDown}
          className="inline-flex h-7 items-center gap-0.5 rounded-md bg-muted p-0.5"
        >
          {UNITS.map((u) => {
            const on = u.id === freq;
            return (
              <button
                key={u.id}
                type="button"
                role="radio"
                aria-checked={on}
                tabIndex={on ? 0 : -1}
                onClick={() => setFreq(u.id)}
                className={cn(
                  "relative h-6 rounded-[5px] px-2 text-xs font-medium outline-none",
                  "transition-[color] duration-150 ease-(--ease-out)",
                  "focus-visible:ring-2 focus-visible:ring-ring",
                  on ? "text-foreground" : "text-muted-foreground hover:text-foreground",
                )}
              >
                {on && (
                  <motion.span
                    layoutId={`pg-recur-thumb-${uid}`}
                    aria-hidden
                    transition={
                      animate
                        ? { type: "spring", duration: 0.3, bounce: 0 }
                        : { duration: 0 }
                    }
                    className="absolute inset-0 rounded-[5px] bg-background shadow-border"
                  />
                )}
                <span className="relative tabular-nums">
                  {interval === 1 ? u.label : u.plural}
                </span>
              </button>
            );
          })}
        </div>
      </div>

      {/* Weekly pills / monthly ordinal — folds via grid-rows */}
      <div
        className={cn(
          "grid transition-[grid-template-rows] duration-200 ease-(--ease-out) motion-reduce:transition-none",
          freq === "daily" ? "grid-rows-[0fr]" : "grid-rows-[1fr]",
        )}
      >
        <div className="overflow-hidden">
          <div className="pt-2.5">
            <AnimatePresence mode="popLayout" initial={false}>
              {freq === "weekly" ? (
                <motion.div
                  key="weekly"
                  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.16, ease: [0.22, 1, 0.36, 1] }}
                  role="group"
                  aria-label="Repeat on weekdays"
                  className="flex items-center gap-1"
                >
                  {DAY_LETTERS.map((letter, d) => {
                    const on = byDay.includes(d);
                    return (
                      <motion.button
                        key={d}
                        type="button"
                        aria-pressed={on}
                        aria-label={DAY_NAMES[d]}
                        onClick={() => toggleDay(d)}
                        whileTap={animate ? { scale: 0.85 } : undefined}
                        transition={{ type: "spring", duration: 0.25, bounce: 0.15 }}
                        className={cn(
                          "relative flex size-7 items-center justify-center rounded-full text-[11px] font-medium outline-none",
                          "transition-[color] duration-150 ease-(--ease-out)",
                          "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
                          on ? "text-primary-foreground" : "text-muted-foreground hover:text-foreground",
                        )}
                      >
                        <span
                          aria-hidden
                          className={cn(
                            "absolute inset-0 rounded-full border transition-[background-color,border-color,scale] duration-150 ease-(--ease-out) motion-reduce:scale-100",
                            on
                              ? "scale-100 border-primary bg-primary"
                              : "scale-90 border-input bg-transparent",
                          )}
                        />
                        <span className="relative">{letter}</span>
                      </motion.button>
                    );
                  })}
                </motion.div>
              ) : freq === "monthly" ? (
                <motion.div
                  key="monthly"
                  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.16, ease: [0.22, 1, 0.36, 1] }}
                  className="flex items-center gap-2"
                >
                  <PopoverPrimitive.Root open={monthlyOpen} onOpenChange={setMonthlyOpen}>
                    <PopoverPrimitive.Trigger asChild>
                      <button
                        type="button"
                        aria-label={`Monthly pattern: ${monthly.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",
                          "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",
                          !isStatic && "active:scale-[0.97]",
                        )}
                      >
                        {monthly.label}
                        <ChevronDown
                          className="size-3 text-muted-foreground transition-transform duration-200 ease-(--ease-out) group-data-[state=open]:rotate-180"
                          aria-hidden
                        />
                      </button>
                    </PopoverPrimitive.Trigger>
                    <style href="paragon-recurrence-editor" precedence="paragon">
                      {recurrenceStyles}
                    </style>
                    <PopoverPrimitive.Portal>
                      <PopoverPrimitive.Content
                        align="start"
                        sideOffset={6}
                        collisionPadding={8}
                        className={cn(
                          "z-50 w-56 origin-(--radix-popover-content-transform-origin) rounded-lg bg-popover p-1 text-popover-foreground shadow-overlay outline-none",
                          "data-[state=open]:animate-[pg-recur-in_160ms_var(--ease-out)]",
                          "data-[state=closed]:animate-[pg-recur-out_90ms_var(--ease-exit)_forwards]",
                        )}
                      >
                        <div role="listbox" aria-label="Monthly pattern">
                          {modes.map((m) => (
                            <button
                              key={m.id}
                              type="button"
                              role="option"
                              aria-selected={m.id === monthlyId}
                              onClick={() => {
                                setMonthlyId(m.id);
                                setMonthlyOpen(false);
                              }}
                              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">
                                {m.label.charAt(0).toUpperCase() + m.label.slice(1)}
                              </span>
                              <span className="flex size-3.5 shrink-0 items-center justify-center">
                                {m.id === monthlyId && (
                                  <Check className="size-3.5" aria-hidden />
                                )}
                              </span>
                            </button>
                          ))}
                        </div>
                      </PopoverPrimitive.Content>
                    </PopoverPrimitive.Portal>
                  </PopoverPrimitive.Root>
                </motion.div>
              ) : null}
            </AnimatePresence>
          </div>
        </div>
      </div>

      {/* Live summary */}
      <div className="mt-3 flex h-5 items-center gap-1.5 border-t border-border pt-2.5">
        <Repeat2 className="size-3.5 shrink-0 text-muted-foreground" aria-hidden />
        <span className="relative min-w-0 flex-1 overflow-hidden">
          <span aria-live="polite" className="sr-only">
            {summary}
          </span>
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.span
              key={summary}
              aria-hidden
              initial={{
                opacity: 0,
                y: animate ? 4 : 0,
                filter: animate ? "blur(4px)" : "blur(0px)",
              }}
              animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
              exit={{
                opacity: 0,
                y: animate ? -4 : 0,
                filter: animate ? "blur(4px)" : "blur(0px)",
                transition: { duration: 0.1 },
              }}
              transition={{ duration: 0.16, ease: [0.22, 1, 0.36, 1] }}
              className="block truncate text-xs text-muted-foreground tabular-nums"
            >
              {summary}
            </motion.span>
          </AnimatePresence>
        </span>
      </div>
    </div>
  );
}