Quick capture
StableOne field that reads a date, time or repeat out of plain English and shows what it understood before you save.
Type “Call Ana tomorrow at 9”: the date words are marked in the field, a chip under it reads “Thu, Oct 22 · 9:00 AM”, and every guess is spelled out (“Read “at 9” as 9:00 AM. Add am or pm to change it”). Enter saves the reminder without the date words, and Keep as text turns the reading off for titles like “Plan next week”. parseReminder is the pure, dependency-free parser underneath.
Capture a reminder
import { useState } from 'react'import { QuickCapture, type NewReminder } from '@rchursin/product/notes'import { Icon } from '@rchursin/product/icons/Icon'import { formatDay, formatTime } from '@rchursin/product/lib/dates'
/** Every relative word is measured from `now`. A fixed sample moment keeps this demo the same on any day: */const now = new Date(2026, 9, 21, 10, 0) // Wednesday, Oct 21, 2026, 10:00 AM
/** * Type it the way you'd say it. The words it understood are marked in the field, the reading sits * under it, and Enter saves the reminder without the date words. Try "Call Ana tomorrow at 9". */export default function QuickCaptureDemo() { const [added, setAdded] = useState<NewReminder[]>([])
return ( <div className="flex w-full max-w-xl flex-col gap-4"> <p className="font-mono text-[11px] tracking-[0.12em] text-fg-3 uppercase">Sample clock: Wed, Oct 21 · 10:00 AM</p> <QuickCapture now={now} onAdd={(r) => setAdded((list) => [r, ...list].slice(0, 4))} /> {added.length > 0 && ( <ul aria-label="Added in this demo (not saved)" className="flex flex-col gap-1.5 border-t border-line pt-3 text-[13px]"> {added.map((r, i) => ( <li key={i} className="flex min-w-0 items-center gap-2"> <Icon name={r.repeat ? 'repeat' : 'bell'} className="size-3.5 shrink-0 text-fg-3" /> <span className="min-w-0 flex-1 truncate">{r.text}</span> <span className="shrink-0 text-xs text-fg-3 tabular-nums"> {r.repeat ?? formatDay(r.at)} · {formatTime(r.at)} </span> </li> ))} </ul> )} </div> )}Usage
import { QuickCapture, CaptureReading, readCapture, parseReminder } from '@rchursin/product/notes'// now: the moment "tomorrow" and "in 2 hours" are measured from<QuickCapture now={now} onAdd={(r) => save(r.text, r.at, r.repeat)} />
parseReminder('Call Ana tomorrow at 9', { now })// { text: 'Call Ana', at: <Thu 9:00>, repeat: null, notes: ['bare-hour'], … }Examples
Four readings
A bare hour (guessed, and said so), a repeat with a time, an offset, and a date with no time (9:00 AM assumed).
import { CaptureReading, readCapture } from '@rchursin/product/notes'
const now = new Date(2026, 9, 21, 10, 0) // Wednesday, Oct 21, 2026, 10:00 AM (sample)
const typed: [kind: string, text: string][] = [ ['A bare hour', 'Call Ana tomorrow at 9'], ['Repeating', 'Every Friday 4pm review'], ['Relative', 'Check the logs in 2 hours'], ['Date only', 'Renew the domain Nov 3'],]
/** * What it reads from four typed reminders, measured from Wed, Oct 21, 10:00 AM. Every guess is * spelled out under the chip, and "Saves as" shows the text without the date words. */export default function QuickCaptureReadings() { return ( <ul className="flex w-full max-w-xl flex-col divide-y divide-line"> {typed.map(([kind, text]) => ( <li key={text} className="flex flex-col gap-2 py-4 first:pt-0 last:pb-0"> <span className="font-mono text-[11px] tracking-[0.12em] text-fg-3 uppercase">{kind}</span> <p className="text-[14px] font-medium">“{text}”</p> <CaptureReading reading={readCapture(text, false, now)} now={now} keepText={false} /> </li> ))} </ul> )}What parseReminder understands
Days, dates, times, offsets and repeats, and the words it leaves alone on purpose (“monthly”, “Tom”, a bare “sat”). Pick a phrase or type your own.
import { useId, useState } from 'react'import { parseReminder, repeatLabel } from '@rchursin/product/notes'import { TextInput } from '@rchursin/product/ui'
const now = new Date(2026, 9, 21, 10, 0) // Wednesday, Oct 21, 2026, 10:00 AM (sample)
/** Phrases by kind. The last group is left alone on purpose: those words are too common in titles. */const tries: [kind: string, phrases: string[]][] = [ ['Days', ['Ship v2 next friday', 'Clean the inbox this weekend', 'Back up the repo tmrw morning']], ['Dates', ['Book flights Oct 5', 'Send the invoice on the 1st', 'Review 10/11']], ['Times', ['Stretch at 9am', 'Draft the post tonight', 'Call Ana tomorrow at 3']], ['Offsets', ['Check the logs in 2 hours', 'Take a break in half an hour']], ['Repeats', ['Standup every weekday at 9:30am', 'Water plants every other day', 'Pay rent every month']], ['Kept as text', ['Create monthly report', 'Call Tom 3 times', 'Lunch sat']],]
const when = (d: Date) => new Intl.DateTimeFormat('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' }).format(d)
/** * parseReminder on its own: pure, no libraries, every relative word resolved against the `now`API
QuickCapture props
| Prop | Type | Default | Description |
|---|---|---|---|
onAddrequired | (reminder: NewReminder) => void | – | Enter or Add: { text, at, repeat }. The text has the date words taken out; repeat is a label like “Every Friday” or null. |
nowrequired | Date | – | What every relative word is measured from. Pass a fixed date for specimens. |
compact | boolean | – | Smaller, for widgets: no Add button, no notes under the chip. |
inputRef | Ref<HTMLInputElement> | – | The input, for focusing it from a shortcut. |
defaultValue | string | '' | Start with this text. |
className | string | – | Classes on the form. |
CaptureReading props
| Prop | Type | Default | Description |
|---|---|---|---|
readingrequired | ReturnType<typeof readCapture> | – | From readCapture(text, keepText, now). |
nowrequired | Date | – | For “in 2 hours”, “tomorrow” next to the chip. |
keepTextrequired | boolean | – | The reading is switched off: the chip says “Kept as text”. |
onKeepTextChange | (keep: boolean) => void | – | Shows the Keep as text toggle when something was understood. |
id | string | – | For aria-describedby on the field. |
compact | boolean | – | Only the chip line. |
parseReminder(input, options)
| Prop | Type | Default | Description |
|---|---|---|---|
inputrequired | string | – | What was typed. English only; any case, whole words only. |
options.nowrequired | Date | – | The moment relative words resolve against. The parser never reads the clock itself. |
options.dayFirst | boolean | false | Read 3/4 as 3 April. QuickCapture sets it from the browser language once the page runs in the browser (the US, Canada, the Philippines and East Asia read month first; so does the prerendered page). |
returns | ParseResult | – | { text, at, timeGiven, repeat, matches, notes }; at is null when nothing was understood. |
ParseResult notes (what it assumed)
| Prop | Type | Default | Description |
|---|---|---|---|
'default-time' | ParseNote | – | A date with no time gets 9:00 AM. |
'next-hour' | ParseNote | – | No time, the day is today and 9:00 has passed: the next full hour. |
'rolled' | ParseNote | – | A time with no date that has passed today means tomorrow. |
'next-year' | ParseNote | – | A month and day that already passed this year means next year. |
'bare-hour' | ParseNote | – | “at 3” with no am/pm: 1–6 is afternoon, 7–11 morning, 12 noon (PM next to tonight, evening or night). |
'part-of-day' | ParseNote | – | morning 9:00, afternoon 13:00, evening 19:00, tonight or night 20:00. |
'month-first' / 'day-first' | ParseNote | – | A date like 10/11 could be read both ways; says which one it took. |
'past' | ParseNote | – | A date and time you typed is already in the past. Kept, and flagged. |
Helpers
| Prop | Type | Default | Description |
|---|---|---|---|
readCapture | (value: string, keepText: boolean, now: Date, dayFirst?: boolean) => { parsed, used, source, text, at, repeat } | – | What QuickCapture will save: the parse, the text, when (tomorrow 9:00 AM when no date), and the repeat label. dayFirst defaults to the browser language; pass it yourself when rendering on a server. |
repeatLabel | (rule: RepeatRule) => string | – | “Every Friday”, “Every other week”, “Every 3 days”. parseRepeat reads it back. |
parseRepeat | (label: string) => RepeatRule | null | – | A stored repeat label back into a rule. |
nextOccurrence | (rule: RepeatRule, scheduled: Date, now: Date) => Date | – | When a repeating reminder fires next after it is checked off, skipping any missed in between. |
Accessibility
Keyboard
| Key | What it does |
|---|---|
| Enter | Saves the reminder (with the date words taken out). |
| Esc | Clears the field. |
| Tab | On to Add, then Keep as text when something was understood. |
- The input is named “New reminder”; its description is the hint while empty and the reading once you type.
- The reading is announced politely as one sentence once typing pauses (“Thursday, October 22, 2026 at 9:00 AM. Read “at 9” as 9:00 AM …”), not on every key.
- The highlight is a copy of the text under a transparent input, hidden from screen readers, so the real input keeps its caret, selection and autocorrect.
- Keep as text is a toggle button (aria-pressed). Saving only date words shows and announces “Add a few words about what to remind you of.” and sets aria-invalid.
Guidelines
Do
- Put a visible heading or question near the field; the placeholder is only an example.
- Pass one now and refresh it every minute (useNow), so “in 2 hours” stays true.
- Show the reading before saving, as this does; never save a guess silently.
Don’t
- Don’t parse other languages with it; it reads English only.
- Don’t store the typed sentence as the time; store at and the repeat label.
In use
Related
- RemindersReminders grouped from Overdue to Done, checked off, snoozed or deleted with Undo, plus a compact widget for a dashboard.
- NotesNotes and reminders in one window: a searchable list beside a block editor, and reminders typed in plain words.
- Screen-reader announcementsuseAnnounce makes screen readers say a result like “Moved to Done”, politely or, for urgent things, at once. Nothing shows on screen.
- Date pickerA field that shows the chosen date and opens a month grid in a popover. Values are ISO date strings.
Source
The component’s own files, exactly as they run on this site.
/** * Quick capture: one field that reads the date out of what you type and shows what it understood * before you save. The understood words are highlighted in the field, the result sits under it * as a chip ("Thu, Oct 15 · 9:00 AM", "Every Friday · 9:00 AM") with any assumption spelled out, * and "Keep as text" turns the reading off (for titles like "Plan next week's demo"). * Saving takes the date words out of the reminder text. * * The highlight layer is a copy of the text under a transparent input (same box, same type), so * the real input keeps its caret, selection, autocorrect and screen-reader behaviour. * Screen readers hear the reading once typing pauses (polite), and it is the field's description. */import { useEffect, useId, useMemo, useRef, useState, useSyncExternalStore, type SubmitEvent, type ReactNode,