Repeat Picker
A recurrence picker: frequency, interval, weekday toggles and an end rule, summarised in a plain sentence.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/repeat-picker.json'use client'
import { useState } from 'react'
import './repeat-picker.css'
export type Freq = 'daily' | 'weekly' | 'monthly'
export interface Recurrence { freq: Freq; interval: number; days: number[]; end: { type: 'never' } | { type: 'count'; count: number } | { type: 'until'; date: string } }
export interface RepeatPickerProps {
defaultValue?: Partial<Recurrence>
onValueChange?: (r: Recurrence) => void
fill?: 'dark' | 'light'
className?: string
}
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const UNIT: Record<Freq, string> = { daily: 'day', weekly: 'week', monthly: 'month' }
export function describe(r: Recurrence) {
let s = r.interval === 1 ? `Every ${UNIT[r.freq]}` : `Every ${r.interval} ${UNIT[r.freq]}s`
if (r.freq === 'weekly' && r.days.length) {
const d = [...r.days].sort().map((i) => DAYS[i])
s += r.days.length === 5 && !r.days.includes(5) && !r.days.includes(6) ? ' on weekdays' : ` on ${d.length > 1 ? `${d.slice(0, -1).join(', ')} and ${d[d.length - 1]}` : d[0]}`
}
if (r.end.type === 'count') s += `, ${r.end.count} time${r.end.count > 1 ? 's' : ''}`
if (r.end.type === 'until' && r.end.date) s += `, until ${new Date(r.end.date + 'T00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`
return s
}
/** Recurrence builder with a plain-language summary. */
export function RepeatPicker({ defaultValue, onValueChange, fill = 'dark', className }: RepeatPickerProps) {
const [r, setR] = useState<Recurrence>({ freq: 'weekly', interval: 1, days: [0, 3], end: { type: 'never' }, ...defaultValue })
const up = (p: Partial<Recurrence>) => { const n = { ...r, ...p }; setR(n); onValueChange?.(n) }
const text = describe(r)
return (
<div className={['sd-rpt', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-rpt__sum" aria-live="polite"><span key={text}>{text}</span></div>
<div className="sd-rpt__lab" id="rpt-f">Repeats</div>
<div className="sd-rpt__seg" role="radiogroup" aria-labelledby="rpt-f">
{(['daily', 'weekly', 'monthly'] as Freq[]).map((f) => <button key={f} type="button" role="radio" aria-checked={r.freq === f} onClick={() => up({ freq: f })}>{f[0].toUpperCase() + f.slice(1)}</button>)}
</div>
<div className="sd-rpt__lab">Every</div>
<div className="sd-rpt__int">
<button type="button" className="sd-rpt__st" aria-label="Decrease interval" onClick={() => up({ interval: Math.max(1, r.interval - 1) })}>−</button>
<span className="sd-rpt__n">{r.interval}</span>
<button type="button" className="sd-rpt__st" aria-label="Increase interval" onClick={() => up({ interval: Math.min(30, r.interval + 1) })}>+</button>
<span>{UNIT[r.freq]}{r.interval > 1 ? 's' : ''}</span>
</div>
{r.freq === 'weekly' && (
<>
<div className="sd-rpt__lab">On</div>
<div className="sd-rpt__days">
{DAYS.map((d, i) => (
<button key={d} type="button" className="sd-rpt__day" aria-label={d} aria-pressed={r.days.includes(i)} onClick={() => up({ days: r.days.includes(i) ? (r.days.length > 1 ? r.days.filter((x) => x !== i) : r.days) : [...r.days, i] })}>{d[0]}</button>
))}
</div>
</>
)}
<div className="sd-rpt__lab">Ends</div>
<div className="sd-rpt__end" role="radiogroup" aria-label="Ends">
<label><input type="radio" name="rpt-end" checked={r.end.type === 'never'} onChange={() => up({ end: { type: 'never' } })} />Never</label>
<label><input type="radio" name="rpt-end" checked={r.end.type === 'count'} onChange={() => up({ end: { type: 'count', count: 10 } })} />After
<input type="number" min={1} max={999} aria-label="Occurrences" value={r.end.type === 'count' ? r.end.count : 10} onFocus={() => r.end.type !== 'count' && up({ end: { type: 'count', count: 10 } })} onChange={(e) => up({ end: { type: 'count', count: Math.max(1, Number(e.target.value) || 1) } })} />times</label>
<label><input type="radio" name="rpt-end" checked={r.end.type === 'until'} onChange={() => up({ end: { type: 'until', date: '' } })} />On
<input type="date" aria-label="End date" value={r.end.type === 'until' ? r.end.date : ''} onChange={(e) => up({ end: { type: 'until', date: e.target.value } })} /></label>
</div>
</div>
)
}.sd-rpt { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-rpt[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-rpt button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-rpt :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-rpt .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-rpt { width: min(100%, 320px); padding: 16px; border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-rpt__sum { font-size: 14.5px; font-weight: 600; line-height: 1.4; min-height: 42px; }
.sd-rpt__sum span { display: inline; animation: sd-rpt-in 200ms ease; }
@keyframes sd-rpt-in { from { opacity: 0.2; } }
.sd-rpt__lab { margin: 14px 0 6px; font-size: 11.5px; color: var(--mute); text-transform: uppercase; letter-spacing: 0.06em; }
.sd-rpt__seg { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; padding: 3px; border-radius: 11px; background: var(--soft); }
.sd-rpt__seg button { height: 32px; border-radius: 8px; font-size: 13px; color: var(--mute); transition: background-color 180ms, color 180ms; }
.sd-rpt__seg button[aria-checked='true'] { background: var(--bg) !important; color: var(--fg); box-shadow: inset 0 0 0 1px var(--line); font-weight: 600; }
.sd-rpt__int { display: flex; align-items: center; gap: 8px; font-size: 13.5px; }
.sd-rpt__st { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 9px; box-shadow: inset 0 0 0 1px var(--line); font-size: 15px; }
.sd-rpt__n { min-width: 22px; text-align: center; font-weight: 650; font-variant-numeric: tabular-nums; }
.sd-rpt__days { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; animation: sd-rpt-in 220ms ease; }
.sd-rpt__day { height: 34px; border-radius: 99px; font-size: 12.5px; font-weight: 600; box-shadow: inset 0 0 0 1px var(--line); transition: background-color 160ms, color 160ms, transform 120ms; }
.sd-rpt__day:active { transform: scale(0.9); }
.sd-rpt__day[aria-pressed='true'] { background: var(--strong) !important; color: var(--on); box-shadow: none; }
.sd-rpt__end { display: grid; gap: 6px; }
.sd-rpt__end label { display: flex; align-items: center; gap: 8px; font-size: 13.5px; min-height: 32px; cursor: pointer; }
.sd-rpt__end input[type='radio'] { accent-color: currentColor; color: var(--fg); margin: 0; }
.sd-rpt__end input[type='number'], .sd-rpt__end input[type='date'] { height: 30px; border: 0; border-radius: 8px; padding: 0 8px; background: var(--soft); color: var(--fg); font: inherit; font-size: 13px; color-scheme: dark; }
.sd-rpt[data-fill='light'] .sd-rpt__end input { color-scheme: light; }
.sd-rpt__end input[type='number'] { width: 56px; }
@media (prefers-reduced-motion: reduce) { .sd-rpt *, .sd-rpt *::before, .sd-rpt *::after { animation: none !important; transition: none !important; } }Overview
Choose daily, weekly or monthly and an interval with a stepper; weekly reveals weekday toggles. The end rule is never, after N times or on a date.
A sentence at the top always says what you have built, like “Every 2 weeks on Mon and Thu, 10 times”.
When to use
- Recurring events, reminders, reports, payments.
When not to use
- Complex rules (the 2nd Tuesday, except holidays): use a full RRULE editor.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/repeat-picker.json$
npx shadcn@latest add https://sundaeui.dev/r/repeat-picker.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/repeat-picker.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/repeat-picker.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy repeat-picker.tsx and repeat-picker.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { RepeatPicker } from '@/components/sundae/repeat-picker'
<RepeatPicker />Props
| Prop | Type | Default |
|---|---|---|
value / defaultValue{ freq, interval, days, end }. | Recurrence | — |
onValueChangeCalled on change. | (r: Recurrence) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Frequency is a radiogroup, weekdays are toggle buttons with full day names, and the summary is a polite live region.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-rpt; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Repeat Picker free?
- Yes. Repeat Picker is free for personal and commercial projects, with full source on this page.
- How do I install Repeat Picker?
- Run npx shadcn@latest add https://sundaeui.dev/r/repeat-picker.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Repeat Picker support keyboard and screen readers?
- Frequency is a radiogroup, weekdays are toggle buttons with full day names, and the summary is a polite live region. Reduced motion removes the movement and keeps every state change.
- Does Repeat Picker respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →Time Slots
Forms · RefinedWeek Strip
Forms · RefinedOTP Field
Forms · Refined- Pro
Drop Zone
Forms · Refined Date Picker
Forms · Understated- Pro
Onboarding Flow
Forms · Refined