A structured editor for a repeat: frequency and interval, the weekdays or the day-of-month beneath them, and a three-way ending — answering with an RFC 5545 RRULE string, with the rule written back out as a sentence and the next dates listed beside it so it can be checked. Reach for it wherever a form asks *how often* rather than *when*: a recurring appointment, class or booking; a reminder or task that repeats; recurring invoices, subscriptions and billing cycles; a scheduled report, digest or export; an on-call rota or shift pattern; a publishing or content calendar; medication and habit reminders; maintenance windows and payroll runs; a retention or backup policy stated for people rather than for cron; and the “Custom…” dialog behind every “Repeats: weekly” dropdown. Common asks it answers: “recurrence input react”, “rrule editor react”, “RRULE builder component”, “recurring event form react”, “repeat options component react”, “shadcn recurrence picker”, “shadcn repeat schedule input”, “react-rrule-generator alternative”, “rrule.js UI component”, “build a recurrence rule UI”, “custom recurrence dialog like Google Calendar”, “outlook recurrence dialog react”, “iCalendar RRULE form”, “FREQ BYDAY BYMONTHDAY editor”, “repeat weekly on selected days react”, “every second Tuesday of the month”, “last Friday of the month picker”, “monthly on the last day of the month”, “end after N occurrences”, “recurrence until date”, “human readable recurrence rule”, “describe an rrule in words”, “preview next occurrence dates”, “rrule skips february”, “monthly on the 31st skips months”, “why does my monthly rule miss a month”, “recurrence count does not match the end date”, “COUNT and UNTIL together rrule”, “BYDAY ordinal not allowed weekly”, “dtstart not synchronized with recurrence rule”, “rrule until must be UTC”, 繰り返し設定 UI, 定期予定の入力フォーム, 「毎月第2月曜」の選択, RRULE エディタ, 毎週 曜日 複数選択, 月末 繰り返し, 繰り返しルール コンポーネント, 終了条件 回数 期日. Official shadcn/ui has nothing of the kind, and the measurement says so precisely: across its whole registry today — 65 items in the catalogue, 62 fetchable (code-block, questionnaire and trace 404 on both style tracks), 223,287 bytes of files[].content concatenated — RRULE, recurrence, FREQ, BYDAY, BYMONTHDAY, BYSETPOS, recurring and interval are every one of them a zero hit. Its calendar is a react-day-picker wrapper that chooses one day and has no notion of repetition at all. Distinct from the neighbours, and the pair worth knowing is cron-expression: that one reads a machine's cron string and renders it, where this one is the editor a person drives, and the two vocabularies genuinely differ — cron has no COUNT, no UNTIL and no “last weekday of the month”. weekly-hours sets opening times across a fixed seven rows rather than a rule that grows; date-range-preset picks a one-off period to look at; repeatable-fields repeats a row of form fields; duration-input measures a length of time rather than a cadence. Five things go wrong in the hand-rolled version, and the first is the one that costs money. “Monthly on the 31st” is not monthly. RFC 5545 does not clamp: it says a rule may generate an instance with an invalid date such as February 30, and that such instances “MUST be ignored and MUST NOT be counted as part of the recurrence set”. So BYMONTHDAY=31 fires in seven months of the year, and because a skipped instance is not counted, pairing it with COUNT=12 does not end in a year — starting in January 2026 the twelfth firing is 31 August 2027. Nothing errors; the schedule a person reads back as “monthly for a year” is neither monthly nor a year. Several products clamp instead, and that is a different schedule which cannot be written as BYMONTHDAY=31 at all — it is BYMONTHDAY=-1, the last day of the month, which exists in every month. This component makes that a separate, named choice and shows how many months the other one skips, rather than guessing, because either guess is invisible. (RFC 7529's SKIP=BACKWARD, the standardised way to ask for clamping, is recognised on parse so a rule carrying it is not mistaken for a plain one and stripped of the part that made it clamp.) Second, changing the frequency does not just swap the panel: “on the 2nd Tuesday” cannot mean anything once a rule repeats weekly — RFC 5545 forbids a numbered weekday outside MONTHLY and YEARLY — so it is dropped, and frequencyChangeDrops exists so the form can say what went instead of silently keeping or silently losing it. Third, the ending is one choice of three, not a times box and a date box: the spec says UNTIL and COUNT “MUST NOT occur in the same 'recur'”, and a form with both live eventually emits both. Fourth, a rule you cannot read back is a rule you cannot check — “the 2nd” and “the second Monday” are different schedules that look identical in a dropdown — so the sentence and the next dates are part of the control, and the dates are computed without reading the clock, so the same props always render the same markup. Fifth, the zone: this component edits a calendar day and does not own DTSTART, and RFC 5545 requires UNTIL to match DTSTART's value type — UTC when DTSTART carries a TZID — so untilForm is an explicit option rather than a guess, and a start date the rule does not actually generate (RFC 5545 calls that combination undefined) is reported rather than papered over. The date arithmetic is cross-checked against rrule.js across 26 rules covering every BY* part this editor can produce, because matching the implementation the rest of the ecosystem is built on matters more than being defensibly different — a preview is only worth having if its dates are the ones the caller's calendar will later produce. That comparison is what settled the DTSTART question; it added no dependency here. What you get: RecurrenceInput, controlled with value + onValueChange or uncontrolled with defaultValue, taking dtstart for the preview and the field seeding, previewCount, a per-part classNames, disabled on a real fieldset so your own fields go with it, full labels for translation, and name to post the RRULE with a plain form or a server action. The hard decisions are exported as pure functions and can be asserted on their own: parseRRule (which refuses a sub-daily frequency and lists the parts it cannot edit instead of rewriting them), formatRRule, describeRecurrence, nextOccurrences (reporting how many instances were skipped and whether DTSTART is one of them), monthDaySkip, changeFrequency, frequencyChangeDrops, setMonthlyMode, toggleWeekday, setEnding and recurrenceIssues. Keyboard and screen reader throughout: a real fieldset and legend, radiogroups for the monthly shape and the ending, aria-pressed weekday toggles whose last remaining day is aria-disabled with the reason wired up rather than silently inert, and a permanently mounted polite live region carrying the sentence so a change is announced rather than only redrawn. Styled entirely with shadcn tokens (primary, input, accent, ring, muted, muted-foreground, destructive, border) so it follows light and dark mode, and it ships zero npm dependencies — no date library, no rrule library, no icon package.
pnpm dlx shadcn@latest add "https://pulld.pages.dev/r/recurrence-input.json"