Cron Builder
Build a cron schedule from plain choices, see the expression and a human sentence, and preview the next five runs.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/cron-builder.json'use client'
import { useMemo, useState } from 'react'
import './cron-builder.css'
type Freq = 'minutes' | 'hourly' | 'daily' | 'weekly' | 'monthly'
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
function field(s: string, lo: number, hi: number): number[] | null {
const out = new Set<number>()
for (const part of s.split(',')) {
const m = part.match(/^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/); if (!m) return null
const step = m[2] ? +m[2] : 1
const [a, b] = m[1] === '*' ? [lo, hi] : m[1].includes('-') ? m[1].split('-').map(Number) : [+m[1], m[2] ? hi : +m[1]]
if (a < lo || b > hi || a > b) return null
for (let i = a; i <= b; i += step) out.add(i)
}
return [...out].sort((x, y) => x - y)
}
function parse(e: string) {
const f = e.trim().split(/\s+/); if (f.length !== 5) return null
const r = [field(f[0], 0, 59), field(f[1], 0, 23), field(f[2], 1, 31), field(f[3], 1, 12), field(f[4].replace('7', '0'), 0, 6)]
return r.some((x) => !x) ? null : { min: r[0]!, hr: r[1]!, dom: r[2]!, mon: r[3]!, dow: r[4]!, domAny: f[2] === '*', dowAny: f[4] === '*' }
}
function next(e: string, from: Date, n = 5) {
const p = parse(e); if (!p) return []
const out: Date[] = [], d = new Date(from); d.setSeconds(0, 0); d.setMinutes(d.getMinutes() + 1)
for (let guard = 0; out.length < n && guard < 600000; guard++) {
const dayOk = p.domAny && p.dowAny ? true : p.domAny ? p.dow.includes(d.getDay()) : p.dowAny ? p.dom.includes(d.getDate()) : p.dom.includes(d.getDate()) || p.dow.includes(d.getDay())
if (!p.mon.includes(d.getMonth() + 1) || !dayOk) { d.setDate(d.getDate() + 1); d.setHours(0, 0); continue }
if (!p.hr.includes(d.getHours())) { d.setHours(d.getHours() + 1, 0); continue }
if (!p.min.includes(d.getMinutes())) { d.setMinutes(d.getMinutes() + 1); continue }
out.push(new Date(d)); d.setMinutes(d.getMinutes() + 1)
}
return out
}
const t12 = (h: number, m: number) => `${h % 12 || 12}:${String(m).padStart(2, '0')} ${h < 12 ? 'am' : 'pm'}`
export interface CronBuilderProps {
value?: string
onValueChange?: (v: string) => void
now?: Date
fill?: 'dark' | 'light'
className?: string
}
/** Cron expression builder with sentence and next runs. */
export function CronBuilder({ value = '0 9 * * 1-5', onValueChange, now, fill = 'dark', className }: CronBuilderProps) {
const [freq, setFreq] = useState<Freq>(() => (/^\*\/\d+ \* \* \* \*$/.test(value) ? 'minutes' : / \d+ \* \*$/.test(value) && !/^\d+ \d+ \* \* \*$/.test(value) ? 'monthly' : value.split(' ')[4] !== '*' ? 'weekly' : 'daily'))
const [every, setEvery] = useState(() => Number(value.match(/^\*\/(\d+)/)?.[1] ?? 15))
const [hr, setHr] = useState(() => Number(value.split(' ')[1]) || 9)
const [min, setMin] = useState(() => Number(value.split(' ')[0]) || 0)
const [days, setDays] = useState<number[]>(() => parse(value)?.dow.length !== 7 ? parse(value)?.dow ?? [1, 2, 3, 4, 5] : [1, 2, 3, 4, 5])
const [dom, setDom] = useState(() => Number(value.split(' ')[2]) || 1)
const [raw, setRaw] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const built = freq === 'minutes' ? `*/${every} * * * *` : freq === 'hourly' ? `${min} * * * *` : freq === 'daily' ? `${min} ${hr} * * *`
: freq === 'weekly' ? `${min} ${hr} * * ${days.length ? compress(days) : '*'}` : `${min} ${hr} ${dom} * *`
const expr = raw ?? built
const ok = !!parse(expr)
const say = !ok ? 'Not a valid five-field expression' : raw ? 'Custom expression'
: freq === 'minutes' ? `Every ${every} minutes` : freq === 'hourly' ? `Every hour at :${String(min).padStart(2, '0')}`
: freq === 'daily' ? `Every day at ${t12(hr, min)}` : freq === 'weekly' ? `${days.length === 5 && !days.includes(0) && !days.includes(6) ? 'Weekdays' : days.map((d) => DAYS[d]).join(', ') || 'Every day'} at ${t12(hr, min)}`
: `On day ${dom} of every month at ${t12(hr, min)}`
const runs = useMemo(() => next(expr, now ?? new Date()), [expr, now])
const set = <T,>(f: (v: T) => void) => (v: T) => { setRaw(null); f(v); setTimeout(() => onValueChange?.(built), 0) }
return (
<div className={['sd-crn', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-crn__seg" role="radiogroup" aria-label="Frequency">
{(['minutes', 'hourly', 'daily', 'weekly', 'monthly'] as Freq[]).map((f) => <button key={f} type="button" role="radio" aria-checked={!raw && freq === f} onClick={() => set(setFreq)(f)}>{f === 'minutes' ? 'Minutes' : f[0].toUpperCase() + f.slice(1)}</button>)}
</div>
{freq === 'minutes' && <div className="sd-crn__row">Every <select aria-label="Interval" value={every} onChange={(e) => set(setEvery)(+e.target.value)}>{[1, 5, 10, 15, 20, 30].map((n) => <option key={n} value={n}>{n}</option>)}</select> minutes</div>}
{freq === 'monthly' && <div className="sd-crn__row">On day <select aria-label="Day of month" value={dom} onChange={(e) => set(setDom)(+e.target.value)}>{Array.from({ length: 28 }, (_, i) => <option key={i} value={i + 1}>{i + 1}</option>)}</select></div>}
{freq === 'weekly' && (
<div className="sd-crn__days" role="group" aria-label="Days">
{[1, 2, 3, 4, 5, 6, 0].map((d) => <button key={d} type="button" aria-pressed={days.includes(d)} aria-label={DAYS[d]} onClick={() => set(setDays)(days.includes(d) ? days.filter((x) => x !== d) : [...days, d].sort())}>{DAYS[d].slice(0, 2)}</button>)}
</div>
)}
{freq !== 'minutes' && (
<div className="sd-crn__row">
{freq === 'hourly' ? 'At minute' : 'At'}
{freq !== 'hourly' && <select aria-label="Hour" value={hr} onChange={(e) => set(setHr)(+e.target.value)}>{Array.from({ length: 24 }, (_, i) => <option key={i} value={i}>{String(i).padStart(2, '0')}</option>)}</select>}
{freq !== 'hourly' && ':'}
<select aria-label="Minute" value={min} onChange={(e) => set(setMin)(+e.target.value)}>{[0, 5, 10, 15, 20, 30, 45].map((i) => <option key={i} value={i}>{String(i).padStart(2, '0')}</option>)}</select>
</div>
)}
<div className="sd-crn__expr" data-bad={!ok || undefined}>
<input aria-label="Cron expression" spellCheck={false} value={expr} onChange={(e) => setRaw(e.target.value)} aria-invalid={!ok} />
<button type="button" className="sd-crn__cp" onClick={() => { navigator.clipboard?.writeText(expr).catch(() => {}); setCopied(true); setTimeout(() => setCopied(false), 1200) }}>{copied ? 'Copied' : 'Copy'}</button>
</div>
<p className="sd-crn__say" aria-live="polite">{say}</p>
<ol className="sd-crn__next" aria-label="Next runs">
{runs.map((d, i) => <li key={expr + i} style={{ animationDelay: `${i * 30}ms` }}><span>{d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })}</span><span>{t12(d.getHours(), d.getMinutes())}</span></li>)}
</ol>
</div>
)
}
function compress(d: number[]) {
const s = [...d].sort((a, b) => a - b), out: string[] = []
for (let i = 0; i < s.length; i++) { let j = i; while (j + 1 < s.length && s[j + 1] === s[j] + 1) j++; out.push(j - i >= 2 ? `${s[i]}-${s[j]}` : s.slice(i, j + 1).join(',')); i = j }
return out.join(',')
}.sd-crn { --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-crn[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-crn :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-crn :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-crn .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-crn { width: min(100%, 340px); padding: 16px; border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-crn__seg { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 12px; }
.sd-crn__seg button { padding: 5px 10px; border-radius: 99px; font-size: 12.5px; box-shadow: inset 0 0 0 1px var(--line); color: var(--mute) !important; }
.sd-crn__seg button[aria-checked='true'] { background: var(--strong) !important; color: var(--on) !important; box-shadow: none; }
.sd-crn__row { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; font-size: 13.5px; }
.sd-crn__row select, .sd-crn__row input { height: 32px; padding: 0 8px; border: 0; border-radius: 8px; background: var(--soft); color: var(--fg); font: inherit; font-size: 13px; outline: 0; }
.sd-crn__row select:focus, .sd-crn__row input:focus { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-crn__days { display: flex; gap: 4px; margin-bottom: 10px; }
.sd-crn__days button { flex: 1; height: 32px; border-radius: 8px; font-size: 12px; font-weight: 600; box-shadow: inset 0 0 0 1px var(--line); color: var(--mute) !important; }
.sd-crn__days button[aria-pressed='true'] { background: var(--strong) !important; color: var(--on) !important; box-shadow: none; }
.sd-crn__expr { display: flex; align-items: center; gap: 6px; margin-top: 6px; padding: 4px 4px 4px 12px; border-radius: 10px; background: var(--soft); }
.sd-crn__expr input { flex: 1; min-width: 0; height: 32px; border: 0; background: none; color: var(--fg); font: 600 15px/1 ui-monospace, monospace; letter-spacing: 0.04em; outline: 0; }
.sd-crn__expr[data-bad] { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-crn__cp { padding: 6px 10px; border-radius: 8px; background: var(--bg) !important; font-size: 12px; font-weight: 600; }
.sd-crn__say { margin: 10px 2px; font-size: 14px; font-weight: 500; }
.sd-crn__next { margin: 0; padding: 0; list-style: none; border-top: 1px solid var(--line); }
.sd-crn__next li { display: flex; justify-content: space-between; padding: 7px 2px; border-bottom: 1px solid var(--line); font-size: 12.5px; font-variant-numeric: tabular-nums; animation: sd-crn-in 200ms ease-out backwards; }
.sd-crn__next li span:last-child { color: var(--mute); }
@keyframes sd-crn-in { from { opacity: 0; transform: translateX(-4px); } }
@media (prefers-reduced-motion: reduce) { .sd-crn *, .sd-crn *::before, .sd-crn *::after { animation: none !important; transition: none !important; } }Overview
Pick a frequency (every N minutes, hourly, daily, weekly, monthly) and the relevant fields; the five-field expression updates and can be copied.
Or paste an expression to read it back. The next five run times are computed locally so you can sanity-check the schedule.
When to use
- Job schedulers, report emails, backups, CI.
When not to use
- End-user reminders: use a recurrence picker.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/cron-builder.json$
npx shadcn@latest add https://sundaeui.dev/r/cron-builder.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/cron-builder.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/cron-builder.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 cron-builder.tsx and cron-builder.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 { CronBuilder } from '@/components/sundae/cron-builder'
<CronBuilder />Props
| Prop | Type | Default |
|---|---|---|
valueCron expression. | string | '0 9 * * 1-5' |
onValueChangeCalled on change. | (cron: string) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Every choice is a native select or toggle with a label.
- The sentence and next runs are a live region.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-crn; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Cron Builder free?
- Yes. Cron Builder is free for personal and commercial projects, with full source on this page.
- How do I install Cron Builder?
- Run npx shadcn@latest add https://sundaeui.dev/r/cron-builder.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Cron Builder support keyboard and screen readers?
- Every choice is a native select or toggle with a label. The sentence and next runs are a live region.
- Does Cron Builder respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →Time Slots
Forms · RefinedWeek Strip
Forms · RefinedRepeat Picker
Forms · Refined- Pro
Feature Flag
Forms · Refined Env Editor
Forms · RefinedCommit Composer
Forms · Refined