Alarm List
Phone-style alarms: toggle each one, repeat days as letter chips, delete in edit mode, and the time to the next alarm.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/alarm-list.json'use client'
import { useState } from 'react'
import './alarm-list.css'
export interface Alarm { id: string; h: number; m: number; label?: string; days: number[]; on: boolean }
export interface AlarmListProps {
alarms: Alarm[]
now?: Date
h24?: boolean
onChange?: (a: Alarm[]) => void
fill?: 'dark' | 'light'
className?: string
}
const D = ['S', 'M', 'T', 'W', 'T', 'F', 'S'], DN = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
const days = (d: number[]) => d.length === 7 ? 'Every day' : d.length === 0 ? 'Once' : d.join() === '1,2,3,4,5' ? 'Weekdays' : d.join() === '0,6' ? 'Weekends' : d.map((x) => DN[x].slice(0, 3)).join(' ')
/** Alarm list with repeat days and next-alarm countdown. */
export function AlarmList({ alarms, now = new Date(2026, 10, 4, 22, 10), h24 = false, onChange, fill = 'dark', className }: AlarmListProps) {
const [list, setList] = useState(alarms)
const [edit, setEdit] = useState(false)
const [open, setOpen] = useState<string | null>(null)
const commit = (n: Alarm[]) => { setList(n); onChange?.(n) }
const upd = (id: string, f: (a: Alarm) => Alarm) => commit(list.map((a) => (a.id === id ? f(a) : a)))
const nowMin = now.getDay() * 1440 + now.getHours() * 60 + now.getMinutes()
const next = list.filter((a) => a.on).map((a) => {
const ds = a.days.length ? a.days : [0, 1, 2, 3, 4, 5, 6]
return Math.min(...ds.map((d) => { let t = d * 1440 + a.h * 60 + a.m - nowMin; if (t <= 0) t += 7 * 1440; return t }))
}).sort((a, b) => a - b)[0]
const fmt = (a: Alarm) => h24 ? [`${String(a.h).padStart(2, '0')}:${String(a.m).padStart(2, '0')}`, ''] : [`${a.h % 12 || 12}:${String(a.m).padStart(2, '0')}`, a.h < 12 ? 'AM' : 'PM']
return (
<div className={['sd-alm', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-alm__h"><div><b>Alarms</b><small aria-live="polite">{next === undefined ? 'No alarms on' : `Next in ${Math.floor(next / 60)}h ${next % 60}m`}</small></div><button type="button" onClick={() => { setEdit((e) => !e); setOpen(null) }}>{edit ? 'Done' : 'Edit'}</button></div>
<ul>
{list.map((a) => { const [t, ap] = fmt(a); return (
<li key={a.id} data-off={!a.on || undefined}>
<div className="sd-alm__r">
{edit && <button type="button" className="sd-alm__del" aria-label={`Delete ${t} ${ap} alarm`} onClick={() => commit(list.filter((x) => x.id !== a.id))}>−</button>}
<div role={edit ? 'button' : undefined} tabIndex={edit ? 0 : undefined} aria-expanded={edit ? open === a.id : undefined} onClick={() => edit && setOpen(open === a.id ? null : a.id)} onKeyDown={(e) => edit && e.key === 'Enter' && setOpen(open === a.id ? null : a.id)} style={{ cursor: edit ? 'pointer' : undefined }}>
<div className="sd-alm__t">{t}<small>{ap}</small></div>
<div className="sd-alm__l">{a.label ? `${a.label}, ` : ''}{days(a.days)}</div>
</div>
{!edit && <button type="button" role="switch" className="sd-alm__sw" aria-checked={a.on} aria-label={`${t} ${ap}${a.label ? ` ${a.label}` : ''}`} onClick={() => upd(a.id, (x) => ({ ...x, on: !x.on }))} />}
</div>
{edit && open === a.id && <div className="sd-alm__days">{D.map((d, i) => <button key={i} type="button" aria-label={DN[i]} aria-pressed={a.days.includes(i)} onClick={() => upd(a.id, (x) => ({ ...x, days: x.days.includes(i) ? x.days.filter((y) => y !== i) : [...x.days, i].sort() }))}>{d}</button>)}</div>}
</li>) })}
</ul>
{edit && <button type="button" className="sd-alm__add" onClick={() => { const id = `a${Date.now()}`; commit([...list, { id, h: 7, m: 0, days: [1, 2, 3, 4, 5], on: true, label: 'New alarm' }]); setOpen(id) }}>+ Add alarm</button>}
</div>
)
}.sd-alm { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-alm[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-alm :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-alm :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-alm .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-alm { width: min(100%, 320px); padding: 14px; grid-template-columns: minmax(0, 1fr); border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); display: grid; gap: 8px; }
.sd-alm__h { display: flex; align-items: flex-end; justify-content: space-between; padding: 0 2px 4px; }
.sd-alm__h b { display: block; font-size: 15px; }
.sd-alm__h small { color: var(--mute); font-size: 12px; }
.sd-alm__h button { font-size: 13px; font-weight: 600; }
.sd-alm ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
.sd-alm li { padding: 10px 12px; border-radius: 14px; background: var(--soft); animation: sd-alm-in 200ms ease-out; }
@keyframes sd-alm-in { from { opacity: 0; transform: translateY(-4px); } }
.sd-alm__r { display: flex; align-items: center; gap: 10px; }
.sd-alm__r > div { flex: 1; min-width: 0; text-align: left; }
.sd-alm__t { font-size: 30px; font-weight: 300; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; line-height: 1.1; }
.sd-alm__t small { font-size: 14px; margin-left: 3px; font-weight: 500; }
.sd-alm li[data-off] .sd-alm__t, .sd-alm li[data-off] .sd-alm__l { color: var(--mute); }
.sd-alm__l { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sd-alm__del { width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; background: var(--strong) !important; color: var(--on) !important; font-size: 14px; flex: none; }
.sd-alm__days { display: flex; gap: 4px; margin-top: 8px; }
.sd-alm__days button { flex: 1; height: 28px; border-radius: 8px; font-size: 11.5px; font-weight: 600; box-shadow: inset 0 0 0 1px var(--line); color: var(--mute) !important; }
.sd-alm__days button[aria-pressed='true'] { background: var(--strong) !important; color: var(--on) !important; box-shadow: none; }
.sd-alm__add { height: 38px; border-radius: 12px; font-size: 13px; font-weight: 600; box-shadow: inset 0 0 0 1px var(--line); }
.sd-alm__sw { position: relative; width: 40px; height: 24px; flex: none; border-radius: 99px; background: var(--line) !important; transition: background-color 160ms; }
.sd-alm__sw::after { content: ''; position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%; background: var(--fg); transition: transform 200ms cubic-bezier(0.3, 1.3, 0.5, 1); }
.sd-alm__sw[aria-checked='true'] { background: var(--fg) !important; }
.sd-alm__sw[aria-checked='true']::after { transform: translateX(16px); background: var(--bg); }
@media (prefers-reduced-motion: reduce) { .sd-alm *, .sd-alm *::before, .sd-alm *::after { animation: none !important; transition: none !important; } }Overview
Each alarm shows its time large, its label and repeat days. The header states how long until the next active alarm, computed from a fixed now.
Edit mode reveals delete buttons; tapping an alarm in edit mode expands its day chips.
When to use
- Clock and wellness apps, reminder settings.
When not to use
- Calendar events: use an agenda.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/alarm-list.json$
npx shadcn@latest add https://sundaeui.dev/r/alarm-list.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/alarm-list.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/alarm-list.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy alarm-list.tsx and alarm-list.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { AlarmList } from '@/components/sundae/alarm-list'
<AlarmList />Props
| Prop | Type | Default |
|---|---|---|
alarms*Alarms (days 0=Sun). | { id: string; h: number; m: number; label?: string; days: number[]; on: boolean }[] | — |
nowReference time. | Date | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Each alarm has a labelled switch stating its time and label.
- Day chips are toggle buttons with full day names.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-alm; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Alarm List free?
- Yes. Alarm List is free for personal and commercial projects, with full source on this page.
- How do I install Alarm List?
- Run npx shadcn@latest add https://sundaeui.dev/r/alarm-list.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Alarm List support keyboard and screen readers?
- Each alarm has a labelled switch stating its time and label. Day chips are toggle buttons with full day names.
- Does Alarm List respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Grid / List View Switcher
Components · RefinedWorld Clocks
Components · Refined- Pro
Focus Timer
Components · Refined - Pro
Swipe Actions
Components · Refined Follow Button
Components · RefinedBookmark Button
Components · Refined