Widgets
Event reminders
Set when and how people are reminded of an event, by notification or email.
Adapted from Watermelon UI (MIT) and rewritten with keyboard support, labels and reduced motion.
Preview
Usage
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>
)
}Source
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
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/event-reminders.jsonWith the registry added (components.json)
npx shadcn@latest add @manniche/event-reminders