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.jsonMed registret tilføjet (components.json)
npx shadcn@latest add @manniche/event-reminders