Mikkel Manniche
Dansk English Deutsch

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.json

With the registry added (components.json)

npx shadcn@latest add @manniche/opening-hours

Files

  • opening-hours.tsx
  • switch.tsx