Mikkel Manniche
Dansk English Deutsch

Widgets

Opening hours

Åbningstider eller tilgængelighed pr. ugedag, med flere tidsrum samme dag.

Tilpasset fra Watermelon UI (MIT) og skrevet om med tastatur, etiketter og reduceret bevægelse.

Forhåndsvisning

Brug

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>
  )
}

Kilde

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ér

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/opening-hours.json

Med registret tilføjet (components.json)

npx shadcn@latest add @manniche/opening-hours

Filer

  • opening-hours.tsx
  • switch.tsx