Skip to content
Component installs need the registry setup
VegaStack Design

Recurrence Picker

The body of a repeat popover — presets, a custom editor (every n days, weeks, months or years; weekdays; month day; ends) and the host's next dates.

Status
stable
Since
0.39.0
Accessibility pattern
radio presets, named weekday toggles, polite preview

Last updated

Due
Mon 9 Nov
Repeat

Install

Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Recurrence Picker before installation. Retain the digest printed by this command.

pnpm exec vegastack-design verify recurrence-picker

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/recurrence-picker

Run the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.

The same command installs the registry items it composes: @vegastack/date-time, @vegastack/badge, @vegastack/date-picker, @vegastack/label, @vegastack/number-field, @vegastack/radio-group, @vegastack/select, @vegastack/separator, @vegastack/skeleton, @vegastack/time-field, @vegastack/toggle-group.

Usage

import {
  RecurrencePicker,
  type RecurrencePickerView,
  type RecurrenceValue,
} from "@/components/ui/recurrence-picker";

const [draft, setDraft] = useState<RecurrenceValue | null>(rule);
const [view, setView] = useState<RecurrencePickerView>("presets");
const next = useNextDates(draft); // the host's server call, debounced

<PopoverContent size={view === "custom" ? "lg" : "default"}>
  <PopoverHeader>
    <PopoverTitle>
      {view === "custom" ? "Custom repeat" : "Repeat"}
    </PopoverTitle>
  </PopoverHeader>
  <RecurrencePicker
    value={draft}
    onChange={setDraft}
    noneLabel="Doesn't repeat"
    presets={presets} // built from the record's own date: "Every week on Monday"
    view={view}
    onViewChange={setView}
    preview={next.dates}
    previewLoading={next.loading}
    previewError={next.error ? <RetryLine onRetry={next.retry} /> : undefined}
    timeZoneLabel={<TimeZoneLine />}
  />
  <div className="flex justify-end gap-2">
    <Button variant="outline" size="sm" onClick={cancel}>
      Cancel
    </Button>
    <Button size="sm" onClick={() => save(draft)}>
      {primaryLabel(draft, view)} {/* "Repeat weekly", "Save repeat" */}
    </Button>
  </div>
</PopoverContent>;

The picker is the popover's body: the host owns the popover (or ResponsiveDialog), the title and the footer buttons, and names the primary button after the choice ("Repeat weekly", "Save repeat"). The value is plain data, RecurrenceValue, and null means it doesn't repeat. The picker never computes a date: the host maps the value to its own rule format, formats the next dates and passes them back as preview.

A preset is selected while value equals its rule (weekday order aside); a rule no preset matches selects Custom…. Choosing Custom… calls onViewChange("custom") so the host can grow its popover to lg, starts a weekly rule when there was none, and moves focus to the interval; a host that offers a way back sets view to "presets" itself. The presets also tell the custom editor the record's weekday and day (the weekly preset with one day, the first monthly preset), so switching the unit to months starts on "day 10" rather than the 1st. With no such preset a new rule starts on the week's first day and the 1st.

The picker remembers the last end date, count and month day it saw, so a choice can come back. Give it a key per record if it stays mounted while the record changes.

Scope

One picker for anything that repeats: a task's Repeat property, a schedule (showTime), a reminder. Fields that belong to the host's model ("Create each task", "Create the next one") go in extra, inside the custom editor. Express "every weekday" as a weekly rule on Monday to Friday, which is what the custom editor shows. Picking one date is the DatePicker; a time of day alone is the TimeField.

Examples

Due
Mon 9 Nov
Repeat

Custom, with the app's fields

The custom editor at the popover's lg width. The weekdays are a multiple-choice toggle group named in full; extra holds the host's fields between Ends and the preview.

Custom repeat

On
Ends
times
Create the next one
Next:Mon 9 Nov, Thu 12 Nov, Mon 23 Nov
Times in Asia/Kolkata

Monthly

On a day, on the nth weekday, or on the last day. A day of 29–31 says that months without it use their last day. Editing a choice's field selects that choice.

Custom repeat

On

Months without a 31st use their last day

Ends
times
Next:Mon 30 Nov, Thu 31 Dec, Sun 31 Jan

States

The next dates loading (three chip skeletons, shown after a short delay so a fast answer never flashes), the host's error line with its retry (saving is still allowed), and disabled.

Loading

Next:
Times in Asia/Kolkata

Error

Couldn't load the next dates
Times in Asia/Kolkata

Disabled

Next:Mon 9 Nov, Mon 16 Nov, Mon 23 Nov

With a time

showTime adds a TimeField for value.time. A preset keeps the time already set, and presets match whatever the time.

Run on a schedule

Next:Mon 9 Nov, 09:00, Mon 16 Nov, 09:00, Mon 23 Nov, 09:00
Times in Asia/Kolkata

In a responsive dialog

Under 640px the host's popover becomes a bottom sheet: the same body inside ResponsiveDialog.

API Reference

PropTypeDefaultDescription
onChange*(value: RecurrenceValue | null) => void—Fires with the new rule, or null when "doesn't repeat" is chosen.
value*RecurrenceValue | null—The rule; null when it doesn't repeat. The picker remembers the last end date, count and month day it saw so a choice can come back: give it a key per record when it stays mounted across records.
classNamestring—Classes for the root.
disabledbooleanfalseDisable every control.
extraReact.ReactNode—The host's own fields, shown in the custom editor between Ends and the preview.
hourCycle12 | 24—The time field's clock: 12 or 24. Omit it and the locale decides.
labelsPartial<RecurrenceLabels>—Replace any of the strings (RecurrenceLabels).
localestring"en-US"BCP-47 locale for the end date and the time.
noneLabelReact.ReactNode—The first row, which sets null ("Doesn't repeat"). Left out, there is no such row.
onViewChange((view: RecurrencePickerView) => void)—Fires when the picker asks for the other body.
presetsreadonly RecurrencePreset[]—The preset rows, in order, followed by "Custom…". A row is selected while value equals its rule (weekday order aside; time counts unless showTime is on); a rule no preset matches selects "Custom…". Leave out both presets and noneLabel for the custom editor alone. The presets also seed the custom editor when its unit changes: the weekly preset with one day gives the weekday, the first monthly one the day. With neither, it starts on the week's first day and on the 1st.
previewreadonly string[]—The next dates, formatted by the host ("Mon 10 Nov"), shown as chips after "Next:", in a live region that is there from the first paint. A new set cross-fades in.
previewErrorReact.ReactNode—The next dates couldn't load: this line (with the host's "Try again") in place of the dates. It is announced politely, like the dates; saving the rule is still allowed.
previewLoadingbooleanfalseThe next dates are loading: three chip skeletons, after a short delay.
showTimebooleanfalseShow a time field for value.time (both bodies, while there is a rule). A preset keeps the time already set, and presets match whatever the time.
timeStepnumber15Minutes the time field steps by.
timeZoneLabelReact.ReactNode—A line under the preview: "Times in Asia/Kolkata · Change".
viewRecurrencePickerView—The body shown (controlled). Choosing "Custom…" asks for "custom", so the host can grow its popover; the picker never asks to go back, so a host offering that sets "presets" itself. Without presets it is always the custom editor.
weekStartsOn0 | 11The first day of the week: 1 Monday, 0 Sunday.

Data attributes and CSS variables on RecurrencePicker

AttributeValues
data-slot"recurrence-picker" | "recurrence-picker-custom" | "recurrence-picker-ends" | "recurrence-picker-extra" | "recurrence-picker-month" | "recurrence-picker-month-hint" | "recurrence-picker-presets" | "recurrence-picker-preview" | "recurrence-picker-preview-error" | "recurrence-picker-preview-loading" | "recurrence-picker-time-zone" | "recurrence-picker-unit" | "recurrence-picker-weekdays"
data-viewmirrors a prop or state value

RecurrenceValue

PropTypeDefaultDescription
ends*RecurrenceEnds—When it stops.
freq*RecurrenceFrequency—How often: every interval days, weeks, months or years.
interval*number—Every how many units (1 or more).
monthRecurrenceMonthRule—Monthly: the day of the month it falls on.
timestring—The time of day, "HH:mm" (24-hour), edited when showTime is on.
weekdaysWeekday[]—Weekly: the days it falls on.

RecurrencePreset

PropTypeDefaultDescription
id*string—A stable id.
label*React.ReactNode—The row's text.
value*RecurrenceValue—The rule it stands for; the row is selected while value equals it.

Accessibility

  • The presets are a radio group: focus lands on the selected preset when the popover opens, and the arrow keys choose. Arrowing onto Custom…, or onto an end date with no day yet, only moves focus, so browsing changes nothing; Space or a click opens it. The host's popover is the dialog, named by its title.
  • The weekdays are a toggle group whose buttons carry the full day name ("Monday"); the monthly and Ends choices are radio groups named by their row label, and each choice's field is labelled.
  • The next dates are a polite live region (role="status"): a new set is read once it arrives, and the region is busy while it loads. A new set cross-fades with the old one over 150ms, which reduced motion turns off.
KeyAction
↑ / ↓ on a choiceMove to the previous or next choice, and select it.
← / → on a choiceThe same, and between the weekdays.
↑ / ↓ in a number fieldStep the number. The arrow keys stay in a field.
TabMove between the groups and fields.
SpaceToggle a weekday; open Custom… or the end date.
Enter / Space on a menuOpen the unit, week or weekday menu.
EscClose an open menu or calendar, then the host's popup.
ContractStates tested
Behaviourpresets, custom, no-repeat, preset-selected, custom-selected, weekly, monthly-day, monthly-weekday, monthly-last-day, yearly, ends-never, ends-on, ends-after, with-time, with-extra, preview, preview-loading, preview-error, disabled
Accessibilitykeyboard, labeled, radio-group, live-region, focus-management, browser-accessibility-test
Visualdefault, custom, loading, error, disabled, dark

Do / Don't

Do
Build the presets from the record's own date and name the primary button after the choice: Repeat weekly.
Don't
Offer a fixed list that ignores the date, or a generic Save for every preset.
Do
Compute and format the next dates on the host and pass them as preview.
Don't
Leave out the preview, so nobody sees what the rule will do before saving it.

On this page