Business hours picker
A weekly schedule with time zone, per-day slots, copy to all and presets.
Week at a glance
Closed
Closed
import { BusinessHoursPicker, defaultBusinessHours } from "@oration/canon/components/business-hours-picker";import * as React from "react";export function Hero() { const [hours, setHours] = React.useState(() => defaultBusinessHours("America/Chicago"), ); return ( <div className="w-full max-w-2xl rounded-xl bg-card p-4 text-left shadow-border"> <BusinessHoursPicker value={hours} onChange={setHours} /> </div> );}Usage#
Business hours picker sets weekly opening hours for a queue, a phone number or an agent: a time zone, a 12 or 24-hour display, presets, and one to four ranges per day in 15-minute steps, with copy to other days and a week strip that shows the result and whether it's open now. It is controlled, so you hold a BusinessHours value and get a new one on every change. It flags backwards and overlapping ranges beside the day but doesn't stop them; call hasBusinessHoursErrors to block the save.
When to use
- To set when a support queue, a phone number, an agent or a callback window is open.
- When hours differ by day, or a day has a break and needs two ranges.
WeekStripon its own to show a schedule read-only, such as in a queue's summary.
When not to use
- For one time of day, such as a payment run cutoff. Use Select field
- For dates, holidays and closures. Use Calendar
- For a length of time, such as a hold timeout. Use Duration picker
- For a time zone on its own. Use Timezone select
The Ink Fill Rule
The Label-Beside-Color Rule
The Quiet Indigo Rule
The Tabular Figures Rule
Anatomy#
Week at a glance
Choose an end time after the start. For overnight hours, split them across two days.
Closed
Closed
- Time zone. A labelled
TimezoneSelect. Every time on the page is in this zone. - Time format. A segmented control, 12h or 24h, that changes display only.
- Presets. A group of 28px toggle buttons. The one matching the current days reads as pressed.
- Week strip. A Well Gray well with seven 24-hour bars, the open ranges filled, today's bar marked with the current time, and Open now or Closed now.
- Day row. A switch and the day's name in a 144px column. Off reads Closed.
- Time range. Two 116px selects, opens and closes, joined by to. A remove button appears when the day has more than one.
- Add and copy. Ghost icon buttons: add a range (up to four) and copy this day to all weekdays or every day.
- Error. A 13px red line with an alert icon under the day's ranges, naming what's wrong.
Examples#
In a settings section, with a save bar
The settings page wraps the picker in a card under an Open hours section and saves with the save bar. hasBusinessHoursErrors disables Save while any day is wrong; try an end time before the start.
Open hours
Hours are in the schedule's time zone, so a supplier calling from Lisbon hits the same cutoff as one in Chicago.
Week at a glance
Closed
Closed
import { BusinessHoursPicker, defaultBusinessHours, hasBusinessHoursErrors,} from "@oration/canon/components/business-hours-picker";import { SaveBar } from "@oration/canon/components/save-bar";import { SettingsSection } from "@oration/canon/components/settings-section";import { toast } from "@oration/canon/components/toast";import { useDirtyForm } from "@oration/canon/hooks/use-dirty-form";export function WithSaveBar() { const form = useDirtyForm( { hours: defaultBusinessHours("America/New_York") }, { onSave: () => { toast.add({ type: "success", title: "Open hours saved", description: "The supplier hotline follows them from now on.", }); }, }, ); const invalid = hasBusinessHoursErrors(form.values.hours); return ( <div className="relative h-[34rem] w-full overflow-y-auto rounded-xl bg-background p-5 shadow-border"> <SettingsSection title="Open hours" description="Hours are in the schedule's time zone, so a supplier calling from Lisbon hits the same cutoff as one in Chicago." > <div className="rounded-xl bg-card p-4 shadow-border"> <BusinessHoursPicker value={form.values.hours} onChange={(hours) => form.set("hours", hours)} /> </div> </SettingsSection> <SaveBar dirty={form.isDirty} message={ invalid ? "Fix the highlighted hours to save" : undefined } saveDisabled={invalid} saving={form.saving} onDiscard={form.reset} onSave={() => void form.save()} /> </div> );}Custom presets and a controlled format
Pass your own presets, starting from businessHoursPresets if you like. Here the format is controlled and starts at 24-hour for a Lisbon team, and the strip is off.
Closed
Closed
import { type BusinessHours, BusinessHoursPicker, type BusinessHoursPreset, businessHoursPresets, defaultBusinessHours,} from "@oration/canon/components/business-hours-picker";import * as React from "react";export function CustomPresets() { const [hour12, setHour12] = React.useState(false); const weekdays = (start: string, end: string) => ({ mon: { enabled: true, slots: [{ start, end }] }, tue: { enabled: true, slots: [{ start, end }] }, wed: { enabled: true, slots: [{ start, end }] }, thu: { enabled: true, slots: [{ start, end }] }, fri: { enabled: true, slots: [{ start, end }] }, }); const closed = { enabled: false, slots: [{ start: "09:00", end: "17:00" }], }; const presets: BusinessHoursPreset[] = [ businessHoursPresets[0] as BusinessHoursPreset, { id: "hotline", label: "Weekdays 8 to 6", days: { ...weekdays("08:00", "18:00"), sat: closed, sun: closed }, }, { id: "month-end", label: "Month-end close", days: { ...weekdays("07:00", "21:00"), sat: { enabled: true, slots: [{ start: "08:00", end: "14:00" }], }, sun: closed, }, }, ]; const [hours, setHours] = React.useState<BusinessHours>(() => ({ timezone: "Europe/Lisbon", days: structuredClone(presets[1]?.days ?? defaultBusinessHours().days), })); return ( <div className="w-full max-w-2xl rounded-xl bg-card p-4 shadow-border"> <BusinessHoursPicker value={hours} onChange={setHours} presets={presets} hour12={hour12} onHour12Change={setHour12} showStrip={false} /> </div> );}WeekStrip on its own
A read-only summary for a queue or a phone number: the week at a glance, a lunch break as two ranges, and whether it's open right now in the schedule's zone.
Supplier hotline
Central time
Week at a glance
import { type BusinessHours, defaultBusinessHours, WeekStrip } from "@oration/canon/components/business-hours-picker";import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";export function StripOnItsOwn() { const base = defaultBusinessHours("America/Chicago"); const hotline: BusinessHours = { ...base, days: { ...base.days, mon: { enabled: true, slots: [ { start: "08:00", end: "12:00" }, { start: "13:00", end: "18:00" }, ], }, fri: { enabled: true, slots: [{ start: "08:00", end: "15:00" }] }, sat: { enabled: true, slots: [{ start: "09:00", end: "13:00" }] }, }, }; return ( <div className="flex w-full max-w-md flex-col gap-3 rounded-xl bg-card p-4 shadow-border"> <div className="flex items-start justify-between gap-3"> <div className="min-w-0"> <p className="text-sm font-semibold">Supplier hotline</p> <p className="text-13 text-muted-foreground"> Central time </p> </div> <Button type="button" variant="outline" size="sm" onClick={() => toast.add({ title: "Opening open hours settings" }) } > Edit hours </Button> </div> <WeekStrip value={hotline} hour12 /> </div> );}Disabled until overridden
A queue that follows the workspace hours shows them disabled. Turn on the override to edit. presets={[]} hides the preset row.
Off, the Disputes queue follows the workspace hours.
Closed
Closed
import { BusinessHoursPicker, defaultBusinessHours } from "@oration/canon/components/business-hours-picker";import { Switch } from "@oration/canon/components/switch";import * as React from "react";export function Disabled() { const id = React.useId(); const [override, setOverride] = React.useState(false); const [hours, setHours] = React.useState(() => defaultBusinessHours("America/Chicago"), ); return ( <div className="flex w-full max-w-2xl flex-col gap-3 rounded-xl bg-card p-4 shadow-border"> <div className="flex items-center justify-between gap-4"> <div className="min-w-0"> <label htmlFor={`${id}-override`} className="text-sm font-medium" > Use different hours for this queue </label> <p className="text-13 text-muted-foreground"> Off, the Disputes queue follows the workspace hours. </p> </div> <Switch id={`${id}-override`} checked={override} onCheckedChange={setOverride} /> </div> <BusinessHoursPicker value={hours} onChange={setHours} disabled={!override} showStrip={false} presets={[]} /> </div> );}States#
Choose an end time after the start. For overnight hours, split them across two days.
These hours overlap 9:00 AM to 1:00 PM. Adjust one so they don't overlap.
Closed
Closed
Saving is blocked until these hours are fixed.
import { type BusinessHours, BusinessHoursPicker, defaultBusinessHours, hasBusinessHoursErrors,} from "@oration/canon/components/business-hours-picker";import * as React from "react";export function ErrorStates() { const base = defaultBusinessHours("America/Chicago"); const [hours, setHours] = React.useState<BusinessHours>({ ...base, days: { ...base.days, mon: { enabled: true, slots: [{ start: "17:00", end: "09:00" }] }, tue: { enabled: true, slots: [ { start: "09:00", end: "13:00" }, { start: "12:00", end: "17:00" }, ], }, }, }); return ( <div className="flex w-full flex-col gap-2"> <div className="rounded-xl bg-card p-4 shadow-border"> <BusinessHoursPicker value={hours} onChange={setHours} showStrip={false} presets={[]} /> </div> <p className="text-13 text-muted-foreground" aria-live="polite"> {hasBusinessHoursErrors(hours) ? "Saving is blocked until these hours are fixed." : "These hours can be saved."} </p> </div> );}| State | Treatment |
|---|---|
| Open day | Switch on, one or more ranges. |
| Closed day | Switch off, Closed in place of the ranges. The ranges are kept, so turning it back on restores them. |
| Several ranges | Each range gets a remove button. Add is disabled at four. |
| Backwards range | An end at or before the start. Both selects turn red and the day explains how to split overnight hours. |
| Overlap | A range that starts before an earlier one ends. The later range is flagged and the message names the earlier one. |
| Preset active | aria-pressed with the indigo tint and inset ring. |
| Open now and closed now | In the strip, a green dot with the closing time, or a gray dot. Updated every minute. |
| Disabled | disabled turns off the time zone, presets, switches, selects, add and copy. |
Behavior#
- Controlled:
onChangereceives the whole nextBusinessHours,{ timezone, days }, where each day is{ enabled, slots: { start, end }[] }and times are"HH:MM". An end of"24:00"means midnight at the end of the day. - Opening times run from 12:00 AM to 11:45 PM and closing times from 12:15 AM to Midnight, every 15 minutes.
- Add hours appends a three-hour range starting an hour after the day's latest close, clamped to the end of the day, and turns the day on.
- Copy offers Copy to all weekdays and Copy to every day, which copy the day's switch and ranges.
- A preset replaces the days and keeps the time zone. It reads as pressed whenever the days match it, whether you picked it or typed the same hours.
- Errors are checked per day after sorting by opening time. Only the first message shows; every invalid range's selects get
aria-invalidand point at it. - Hours can't cross midnight. Split overnight hours across two days: Friday 10:00 PM to Midnight and Saturday 12:00 AM to 6:00 AM.
- The 12 or 24-hour display is uncontrolled, starting at 12-hour, unless you pass
hour12andonHour12Change. - The strip reads the current time in the schedule's zone after mount and every minute, so server and client markup match.
Do and don't#
hasBusinessHoursErrors(value) is true, and say why in the save bar.Content#
- Preset labels describe the schedule in sentence case: Weekdays 9 to 5, 24/7, Extended support hours.
- The section around the picker is titled Open hours, and its description says which zone applies and to whom.
- Times read 9:00 AM and Midnight in 12-hour, 09:00 and 24:00 in 24-hour. Use
formatTimefor the same strings elsewhere. - The built-in errors say what to do. Don't add a second message beside them.
Accessibility#
- Each day is a
role="group"labelled by its day name, and the switch is labelled by the same<label>. - Every time select has its own name: Monday opens, Monday closes, range 2.
- Errors are linked to their selects with
aria-describedbyand mark themaria-invalid, so they're read when a select is focused. - Presets are toggle buttons with
aria-pressed, in a group labelled Presets. The time format is a radio group labelled Time format. - Add and remove are icon buttons with names such as Add hours on Monday and tooltips. Copy is named Copy Monday's hours.
- The week strip is
role="img"with the whole week as text: Monday 9:00 AM to 5:00 PM. Saturday closed.
| Keys | Action |
|---|---|
| Tab | Moves through the zone, format, presets and each day's controls. |
| Space | Turns a day on or off; presses a preset. |
| Enter | Opens a time select or the copy menu. |
| ↑↓ | Moves through times in an open select. |
| ←→ | Switches between 12h and 24h. |
Design tokens#
| Token | Used for |
|---|---|
--muted | Week strip well at 70%, preset rest fill |
--foreground | Open ranges at 65% (Ink 65) |
--background | Bar track |
--border | Track hairline and the dividers between days |
--primary | Now marker; active preset tint at 6% and ring at 22% |
--success | Open now dot |
--subtle-foreground | Closed now dot |
--destructive | Error text and invalid select borders |
API reference#
BusinessHoursPicker
The editor. Takes no other props; className lands on the root.
| Prop | Type | Default | Description |
|---|---|---|---|
valueRequired | BusinessHours | No default | { timezone: string; days: Record<DayKey, DaySchedule> }. |
onChangeRequired | (value: BusinessHours) => void | No default | Called with the whole next value. |
presets | BusinessHoursPreset[] | businessHoursPresets | { id, label, days }[]. Pass [] to hide the row. |
hour12 | boolean | No default | Controls the 12 or 24-hour display. Uncontrolled, 12-hour first, when omitted. |
onHour12Change | (hour12: boolean) => void | No default | Called when the format changes. |
showStrip | boolean | true | Shows the week strip. |
disabled | boolean | No default | Disables the editing controls. |
className | string | No default | Classes for the root. |
WeekStrip
The read-only week, also used inside the picker.
| Prop | Type | Default | Description |
|---|---|---|---|
valueRequired | BusinessHours | No default | The schedule to draw. |
hour12Required | boolean | No default | Axis labels and the closing time in 12 or 24-hour. |
className | string | No default | Classes for the well. |
defaultBusinessHours
defaultBusinessHours(timezone?) returns a fresh weekdays 9-to-5 schedule.
| Prop | Type | Default | Description |
|---|---|---|---|
timezone | string | "America/Chicago" | IANA zone. |
hasBusinessHoursErrors
hasBusinessHoursErrors(value) is true when any open day has a backwards or overlapping range.
| Prop | Type | Default | Description |
|---|---|---|---|
valueRequired | BusinessHours | No default | The schedule to check. |
formatTime
formatTime(time, hour12) returns 9:00 AM or 09:00, and Midnight or 24:00 for the end of the day.
| Prop | Type | Default | Description |
|---|---|---|---|
timeRequired | string | No default | "HH:MM". |
hour12Required | boolean | No default | 12 or 24-hour. |
businessHoursPresets
The default presets: Weekdays 9 to 5, 24/7, and Extended support hours (weekdays 7 to 9, Saturday 9 to 5). Also exported: the BusinessHours, BusinessHoursPreset, DaySchedule, TimeSlot and DayKey types.
| Prop | Type | Default | Description |
|---|---|---|---|
businessHoursPresets | BusinessHoursPreset[] | No default | Spread it to add your own: [...businessHoursPresets, mine]. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
disabled doesn't reach the 12h and 24h control, which stays interactive.
Add and remove have tooltips; the copy button has a name but no tooltip.
Errors appear without being announced. They're only read when someone focuses one of the flagged selects.