Widgets
Opening hours
Opening hours or availability per weekday, with more than one range a day.
Adapted from Watermelon UI (MIT) and rewritten with keyboard support, labels and reduced motion.
Preview
Usage
opening-hours-demo.tsx
import { OpeningHours } from '@/components/opening-hours'
export default function OpeningHoursDemo() {
return (
<div className="flex justify-center">
<OpeningHours
defaultValue={[
{ id: 'mon', label: 'Monday', open: true, ranges: [{ id: 'm1', from: '10:00', to: '18:00' }] },
{ id: 'tue', label: 'Tuesday', open: true, ranges: [{ id: 't1', from: '10:00', to: '13:00' }, { id: 't2', from: '14:00', to: '18:00' }] },
{ id: 'sat', label: 'Saturday', open: false, ranges: [] },
{ id: 'sun', label: 'Sunday', open: false, ranges: [] },
]}
/>
</div>
)
}Source
opening-hours.tsx
// Based on Watermelon UI's “Slot picker” (MIT, © 2026 Watermelon Platform Contributors,
// github.com/WatermelonCorp/watermelon-platform). Rewritten with native time inputs and a real switch.
import { Plus, X } from 'lucide-react'
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'motion/react'
import { useId, useState } from 'react'
import { Switch } from '@/registry/manniche/switch/switch'
import { cn } from '@/lib/utils'
export type TimeRange = { id: string; from: string; to: string }
export type OpeningDay = { id: string; label: string; open: boolean; ranges: TimeRange[] }
export type OpeningHoursProps = {
/** Controlled week. Times are "HH:MM", as a time input gives them. */
value?: OpeningDay[]
defaultValue?: OpeningDay[]
onChange?: (days: OpeningDay[]) => void
labels?: { from?: string; to?: string; add?: string; remove?: string }
className?: string
}
/** Opening hours or availability per weekday, with more than one range a day if needed. */
export function OpeningHours({ value, defaultValue = [], onChange, labels = {}, className }: OpeningHoursProps) {
const [own, setOwn] = useState(defaultValue)
const days = value ?? own
const group = useId()
const { from = 'From', to = 'To', add = 'Add hours', remove = 'Remove hours' } = labels
const set = (next: OpeningDay[]) => {
setOwn(next)
onChange?.(next)
}
const patch = (id: string, f: (d: OpeningDay) => OpeningDay) => set(days.map((d) => (d.id === id ? f(d) : d)))
const range = (from: string, to: string): TimeRange => ({ id: crypto.randomUUID(), from, to })
const time = 'h-10 rounded-xl border bg-background px-2 text-sm tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-ring'
return (
<MotionConfig reducedMotion="user" transition={{ type: 'spring', stiffness: 420, damping: 34 }}>
<LayoutGroup id={group}>
<div className={cn('flex w-full max-w-sm flex-col gap-2', className)}>
{days.map((day) => (
<motion.section
key={day.id}
layout
aria-label={day.label}
style={{ borderRadius: 22 }}
className={cn('overflow-hidden border transition-colors duration-300', day.open ? 'bg-card shadow-sm' : 'border-transparent bg-muted')}
>
<motion.div layout="position" className="flex h-14 items-center justify-between px-4">
<span className="font-medium">{day.label}</span>
<Switch
aria-label={day.label}
checked={day.open}
onChange={(open) => patch(day.id, (d) => ({ ...d, open, ranges: open && d.ranges.length === 0 ? [range('09:00', '17:00')] : d.ranges }))}
/>
</motion.div>
<AnimatePresence initial={false}>
{day.open && (
<motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }} exit={{ height: 0, opacity: 0 }}>
<ul className="space-y-2 px-4 pb-2">
<AnimatePresence initial={false}>
{day.ranges.map((r) => (
<motion.li key={r.id} layout initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="flex items-center gap-2">
<input type="time" aria-label={`${day.label}, ${from}`} value={r.from} className={time}
onChange={(e) => patch(day.id, (d) => ({ ...d, ranges: d.ranges.map((x) => (x.id === r.id ? { ...x, from: e.target.value } : x)) }))} />
<span className="text-muted-foreground" aria-hidden>–</span>
<input type="time" aria-label={`${day.label}, ${to}`} value={r.to} className={time}
onChange={(e) => patch(day.id, (d) => ({ ...d, ranges: d.ranges.map((x) => (x.id === r.id ? { ...x, to: e.target.value } : x)) }))} />
<button
type="button"
aria-label={remove}
onClick={() => patch(day.id, (d) => {
const ranges = d.ranges.filter((x) => x.id !== r.id)
return { ...d, ranges, open: ranges.length > 0 }
})}
className="ml-auto grid size-10 place-items-center rounded-xl text-muted-foreground transition-colors duration-150 hover:bg-muted hover:text-foreground"
>
<X className="size-4" aria-hidden />
</button>
</motion.li>
))}
</AnimatePresence>
</ul>
<button
type="button"
onClick={() => patch(day.id, (d) => ({ ...d, ranges: [...d.ranges, range(d.ranges.at(-1)?.to ?? '09:00', '18:00')] }))}
className="mx-4 mb-3 inline-flex min-h-10 items-center gap-1.5 rounded-xl px-2 text-sm text-muted-foreground transition-colors duration-150 hover:bg-muted hover:text-foreground"
>
<Plus className="size-4" aria-hidden />
{add}
</button>
</motion.div>
)}
</AnimatePresence>
</motion.section>
))}
</div>
</LayoutGroup>
</MotionConfig>
)
}switch.tsx
import { MotionConfig, motion } from 'motion/react'
import { useId, useState } from 'react'
import { cn } from '@/lib/utils'
export type SwitchProps = {
/** Controlled state. */
checked?: boolean
defaultChecked?: boolean
onChange?: (checked: boolean) => void
/** Visible label. Leave it out and pass `aria-label` instead. */
label?: string
'aria-label'?: string
disabled?: boolean
className?: string
}
/** A switch whose knob stretches as you press it and springs across, like the one on a phone. */
export function Switch({ checked, defaultChecked = false, onChange, label, disabled, className, ...aria }: SwitchProps) {
const [own, setOwn] = useState(defaultChecked)
const on = checked ?? own
const id = useId()
const flip = () => {
setOwn(!on)
onChange?.(!on)
}
return (
<MotionConfig reducedMotion="user">
<span className={cn('inline-flex items-center gap-3', className)}>
<motion.button
id={id}
type="button"
role="switch"
aria-checked={on}
aria-label={aria['aria-label']}
disabled={disabled}
onClick={flip}
whileTap="pressed"
className={cn(
'relative flex h-8 w-[52px] shrink-0 items-center rounded-full p-[3px] transition-colors duration-200 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-40',
on ? 'justify-end bg-emerald-500' : 'justify-start bg-muted',
)}
>
<motion.span
layout
// Pressed, the knob stretches towards the side it is about to move to.
variants={{ pressed: { scaleX: 1.2 } }}
style={{ originX: on ? 1 : 0 }}
transition={{ type: 'spring', stiffness: 520, damping: 46 }}
className="block h-[26px] w-[26px] rounded-full bg-white shadow-[0_2px_6px_rgb(0_0_0/0.18),0_0_0_0.5px_rgb(0_0_0/0.06)]"
/>
</motion.button>
{label && (
<label htmlFor={id} className="text-sm font-medium select-none">
{label}
</label>
)}
</span>
</MotionConfig>
)
}Install
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/opening-hours.jsonWith the registry added (components.json)
npx shadcn@latest add @manniche/opening-hours