Mikkel Manniche
Dansk English Deutsch

Widgets

Event reminders

Vælg, hvornår og hvordan folk bliver mindet om en begivenhed, med notifikation eller e-mail.

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

Forhåndsvisning

Brug

event-reminders-demo.tsx
import { EventReminders } from '@/components/event-reminders'

export default function EventRemindersDemo() {
  return (
    <div className="flex justify-center">
      <EventReminders
        title="Spring sale opens"
        when="Thursday 9 October, 10:00"
        defaultValue={[
          { id: 'a', channel: 'notification', amount: 30, unit: 'minutes' },
          { id: 'b', channel: 'email', amount: 1, unit: 'days' },
        ]}
      />
    </div>
  )
}

Kilde

event-reminders.tsx
// Based on Watermelon UI's “Event reminders” (MIT, © 2026 Watermelon Platform Contributors,
// github.com/WatermelonCorp/watermelon-platform). Rewritten as a controlled list with labelled controls.
import { Bell, Mail, Minus, Plus, X } from 'lucide-react'
import { AnimatePresence, MotionConfig, motion } from 'motion/react'
import { useState } from 'react'
import { RollingNumber } from '@/registry/manniche/rolling-number/rolling-number'
import { cn } from '@/lib/utils'

export type ReminderChannel = 'notification' | 'email'
export type ReminderUnit = 'minutes' | 'hours' | 'days'
export type Reminder = { id: string; channel: ReminderChannel; amount: number; unit: ReminderUnit }

export type EventRemindersProps = {
  title: string
  /** Already formatted, e.g. “Thursday 9 October, 14:00”. */
  when: string
  /** Controlled list. Leave it out and the card keeps its own. */
  value?: Reminder[]
  defaultValue?: Reminder[]
  onChange?: (reminders: Reminder[]) => void
  /** Most reminders one event can have. */
  max?: number
  labels?: Partial<Record<ReminderChannel | ReminderUnit | 'before' | 'add' | 'remove' | 'more' | 'fewer', string>>
  className?: string
}

const UNITS: ReminderUnit[] = ['minutes', 'hours', 'days']
const LIMIT: Record<ReminderUnit, number> = { minutes: 59, hours: 23, days: 30 }
const EN = { notification: 'Notification', email: 'Email', minutes: 'minutes', hours: 'hours', days: 'days', before: 'before', add: 'Add reminder', remove: 'Remove reminder', more: 'More', fewer: 'Less' }

/** A card where people set when and how they are reminded of an event. */
export function EventReminders({ title, when, value, defaultValue = [], onChange, max = 5, labels = {}, className }: EventRemindersProps) {
  const [own, setOwn] = useState(defaultValue)
  const list = value ?? own
  const t = { ...EN, ...labels }

  const set = (next: Reminder[]) => {
    setOwn(next)
    onChange?.(next)
  }
  const patch = (id: string, p: Partial<Reminder>) => set(list.map((r) => (r.id === id ? { ...r, ...p } : r)))

  const pill = 'inline-flex min-h-10 items-center gap-1.5 rounded-full bg-muted px-3 text-sm font-medium transition-colors duration-150 hover:bg-accent'
  const round = 'grid size-8 place-items-center rounded-full text-muted-foreground transition-colors duration-150 hover:bg-background hover:text-foreground disabled:opacity-30'

  return (
    <MotionConfig reducedMotion="user" transition={{ type: 'spring', bounce: 0, duration: 0.45 }}>
      <motion.section layout className={cn('w-full max-w-md rounded-3xl border bg-card p-5 text-card-foreground shadow-sm', className)}>
        <header className="pb-4">
          <h2 className="text-lg font-semibold text-balance">{title}</h2>
          <p className="mt-1 text-sm text-muted-foreground">{when}</p>
        </header>

        <ul className="space-y-2 border-t border-dashed pt-3">
          <AnimatePresence mode="popLayout" initial={false}>
            {list.map((r) => {
              const Icon = r.channel === 'email' ? Mail : Bell
              return (
                <motion.li
                  key={r.id}
                  layout
                  initial={{ opacity: 0, y: 8 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={{ opacity: 0, scale: 0.96 }}
                  className="flex flex-wrap items-center gap-1.5"
                >
                  <button type="button" className={pill} onClick={() => patch(r.id, { channel: r.channel === 'email' ? 'notification' : 'email' })}>
                    <Icon className="size-4" aria-hidden />
                    {t[r.channel]}
                  </button>
                  <span className="inline-flex min-h-10 items-center rounded-full bg-muted px-1">
                    <button type="button" className={round} aria-label={t.fewer} disabled={r.amount <= 1} onClick={() => patch(r.id, { amount: r.amount - 1 })}>
                      <Minus className="size-3.5" aria-hidden />
                    </button>
                    <RollingNumber value={r.amount} className="min-w-6 justify-center text-sm font-semibold" />
                    <button
                      type="button"
                      className={round}
                      aria-label={t.more}
                      disabled={r.amount >= LIMIT[r.unit]}
                      onClick={() => patch(r.id, { amount: r.amount + 1 })}
                    >
                      <Plus className="size-3.5" aria-hidden />
                    </button>
                  </span>
                  <button
                    type="button"
                    className={pill}
                    onClick={() => {
                      const unit = UNITS[(UNITS.indexOf(r.unit) + 1) % UNITS.length]
                      patch(r.id, { unit, amount: Math.min(r.amount, LIMIT[unit]) })
                    }}
                  >
                    {t[r.unit]}
                  </button>
                  <span className="px-1 text-sm text-muted-foreground">{t.before}</span>
                  <button type="button" className={cn(round, 'ml-auto size-10')} aria-label={t.remove} onClick={() => set(list.filter((x) => x.id !== r.id))}>
                    <X className="size-4" aria-hidden />
                  </button>
                </motion.li>
              )
            })}
          </AnimatePresence>
        </ul>

        {list.length < max && (
          <motion.button
            layout
            type="button"
            onClick={() => set([...list, { id: crypto.randomUUID(), channel: 'notification', amount: 10, unit: 'minutes' }])}
            className="mt-3 inline-flex min-h-11 w-full items-center justify-center gap-2 rounded-2xl border border-dashed text-sm font-medium text-muted-foreground transition-colors duration-150 hover:bg-muted hover:text-foreground"
          >
            <Plus className="size-4" aria-hidden />
            {t.add}
          </motion.button>
        )}
      </motion.section>
    </MotionConfig>
  )
}
rolling-number.tsx
// Based on the rolling digits in Watermelon UI's “Stepper” and “Pagination”
// (MIT, © 2026 Watermelon Platform Contributors, github.com/WatermelonCorp/watermelon-platform).
import { AnimatePresence, MotionConfig, motion } from 'motion/react'
import { useState } from 'react'
import { cn } from '@/lib/utils'

export type RollingNumberProps = {
  value: number
  /** Passed to Intl.NumberFormat, e.g. "da-DK". Leave it out for plain digits. */
  locale?: string
  className?: string
}

/** A number whose changed digits roll up or down. Screen readers get the whole number once. */
export function RollingNumber({ value, locale, className }: RollingNumberProps) {
  // Remember the last value in state (not a ref), so the direction survives a double render.
  const [last, setLast] = useState({ value, dir: 1 })
  const dir = last.value === value ? last.dir : value > last.value ? 1 : -1
  if (last.value !== value) setLast({ value, dir })
  const text = locale ? new Intl.NumberFormat(locale).format(value) : String(value)
  const chars = text.split('')

  return (
    <MotionConfig reducedMotion="user">
      <span className={cn('inline-flex tabular-nums', className)}>
        <span className="sr-only">{text}</span>
        <span aria-hidden className="inline-flex">
          {chars.map((ch, i) => {
            // Count from the right, so the ones stay the ones when the number grows a digit.
            const place = chars.length - i
            return (
              <span key={place} className="relative inline-flex min-w-[1ch] justify-center overflow-hidden">
                <AnimatePresence mode="popLayout" initial={false} custom={dir}>
                  <motion.span
                    key={ch}
                    initial={{ y: `${dir * 70}%`, opacity: 0, filter: 'blur(2px)' }}
                    animate={{ y: 0, opacity: 1, filter: 'blur(0px)' }}
                    exit={{ y: `${dir * -70}%`, opacity: 0, filter: 'blur(2px)' }}
                    transition={{ type: 'spring', stiffness: 260, damping: 32 }}
                    className="inline-block"
                  >
                    {ch}
                  </motion.span>
                </AnimatePresence>
              </span>
            )
          })}
        </span>
      </span>
    </MotionConfig>
  )
}

Installér

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/event-reminders.json

Med registret tilføjet (components.json)

npx shadcn@latest add @manniche/event-reminders

Filer

  • event-reminders.tsx
  • rolling-number.tsx