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