Skip to content

Business hours picker

A weekly schedule with time zone, per-day slots, copy to all and presets.

Status
Stable
Category
Inputs
Adoption
Not used yet
import { BusinessHoursPicker } from "@oration/canon/components/business-hours-picker";
packages/canon/src/components/business-hours-picker.tsx

Week at a glance

to
to
to
to
to

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.
  • WeekStrip on its own to show a schedule read-only, such as in a queue's summary.

When not to use

The Ink Fill Rule

Open ranges in the week strip are drawn in Ink 65 on a white track with a hairline. They are magnitudes of time, not status, so they stay ink.

The Label-Beside-Color Rule

The open-now dot is never alone: green reads Open now, closes at 5:00 PM and gray reads Closed now.

The Quiet Indigo Rule

Indigo marks the active preset (a 6% tint with a 22% inset ring) and the now marker on today's bar. Nothing else in the picker is indigo.

The Tabular Figures Rule

Every time, in the selects, the strip and its axis, is set in tabular figures so ranges line up from day to day.

Anatomy#

Week at a glance

to
to
to

Choose an end time after the start. For overnight hours, split them across two days.

to
to
to

Closed

Closed

  1. Time zone. A labelled TimezoneSelect. Every time on the page is in this zone.
  2. Time format. A segmented control, 12h or 24h, that changes display only.
  3. Presets. A group of 28px toggle buttons. The one matching the current days reads as pressed.
  4. 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.
  5. Day row. A switch and the day's name in a 144px column. Off reads Closed.
  6. Time range. Two 116px selects, opens and closes, joined by to. A remove button appears when the day has more than one.
  7. Add and copy. Ghost icon buttons: add a range (up to four) and copy this day to all weekdays or every day.
  8. 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

to
to
to
to
to

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.

to
to
to
to
to

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.

to
to
to
to
to

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#

to

Choose an end time after the start. For overnight hours, split them across two days.

to
to

These hours overlap 9:00 AM to 1:00 PM. Adjust one so they don't overlap.

to
to
to

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>    );}
States
StateTreatment
Open daySwitch on, one or more ranges.
Closed daySwitch off, Closed in place of the ranges. The ranges are kept, so turning it back on restores them.
Several rangesEach range gets a remove button. Add is disabled at four.
Backwards rangeAn end at or before the start. Both selects turn red and the day explains how to split overnight hours.
OverlapA range that starts before an earlier one ends. The later range is flagged and the message names the earlier one.
Preset activearia-pressed with the indigo tint and inset ring.
Open now and closed nowIn the strip, a green dot with the closing time, or a gray dot. Updated every minute.
Disableddisabled turns off the time zone, presets, switches, selects, add and copy.

Behavior#

  • Controlled: onChange receives the whole next BusinessHours, { 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-invalid and 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 hour12 and onHour12Change.
  • 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#

Do. Keep the time zone in view next to the hours, and say in the section description whose clock they follow.
Don't. Show hours without a zone, so a supplier in Lisbon and an agent in Chicago read them differently.
Do. Block saving while hasBusinessHoursErrors(value) is true, and say why in the save bar.
Don't. Let overlapping or backwards hours save. The queue closes when nobody expects it to.
Do. Name custom presets for the schedule they set: Weekdays 8 to 6, Month-end close.
Don't. Name presets after teams or people, such as Priya's hours, so nobody knows what they apply.

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 formatTime for 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-describedby and mark them aria-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.
Keyboard interactions
KeysAction
TabMoves through the zone, format, presets and each day's controls.
SpaceTurns a day on or off; presses a preset.
EnterOpens a time select or the copy menu.
↑↓Moves through times in an open select.
←→Switches between 12h and 24h.

Design tokens#

Design tokens
TokenUsed for
--mutedWeek strip well at 70%, preset rest fill
--foregroundOpen ranges at 65% (Ink 65)
--backgroundBar track
--borderTrack hairline and the dividers between days
--primaryNow marker; active preset tint at 6% and ring at 22%
--successOpen now dot
--subtle-foregroundClosed now dot
--destructiveError text and invalid select borders

API reference#

BusinessHoursPicker

The editor. Takes no other props; className lands on the root.

Props of BusinessHoursPicker
PropTypeDefaultDescription
valueRequiredBusinessHoursNo default{ timezone: string; days: Record<DayKey, DaySchedule> }.
onChangeRequired(value: BusinessHours) => voidNo defaultCalled with the whole next value.
presetsBusinessHoursPreset[]businessHoursPresets{ id, label, days }[]. Pass [] to hide the row.
hour12booleanNo defaultControls the 12 or 24-hour display. Uncontrolled, 12-hour first, when omitted.
onHour12Change(hour12: boolean) => voidNo defaultCalled when the format changes.
showStripbooleantrueShows the week strip.
disabledbooleanNo defaultDisables the editing controls.
classNamestringNo defaultClasses for the root.

WeekStrip

The read-only week, also used inside the picker.

Props of WeekStrip
PropTypeDefaultDescription
valueRequiredBusinessHoursNo defaultThe schedule to draw.
hour12RequiredbooleanNo defaultAxis labels and the closing time in 12 or 24-hour.
classNamestringNo defaultClasses for the well.

defaultBusinessHours

defaultBusinessHours(timezone?) returns a fresh weekdays 9-to-5 schedule.

Props of defaultBusinessHours
PropTypeDefaultDescription
timezonestring"America/Chicago"IANA zone.

hasBusinessHoursErrors

hasBusinessHoursErrors(value) is true when any open day has a backwards or overlapping range.

Props of hasBusinessHoursErrors
PropTypeDefaultDescription
valueRequiredBusinessHoursNo defaultThe 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.

Props of formatTime
PropTypeDefaultDescription
timeRequiredstringNo default"HH:MM".
hour12RequiredbooleanNo default12 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.

Props of businessHoursPresets
PropTypeDefaultDescription
businessHoursPresetsBusinessHoursPreset[]No defaultSpread 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.