NotificationList
Notifications grouped by day (Today, Yesterday, then dates), read or unread, with links, mark-read and dismiss buttons and Mark all as read.
Demo
Notifications3
Today
Unread: @ada mentioned you in #design
Yesterday
Your export is ready
orders-2026-10.csv, 2.4 MB
Unread: Card ending 4242 expires next month
Tuesday, Oct 20
Sign-in from a new device
Chrome on Windows, Lisbon
Nothing done yet.
Mentions
You're all caught up.
Get it
It needs theme.qwe, as every component does, and avatar.qwe; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/avatar.qwe$ curl -O https://quillolang.org/components/ui/notification_list.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/notification_list.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/notification_list.qwe
let notification_list_demo_items = [ { id: 1, title: "Grace Hopper commented on Q3 report", text: "“Looks good, one question on page 4.”", time: "2026-10-28T09:12:00Z", actor: { name: "Grace Hopper", src: "https://i.pravatar.cc/64?img=47" }, href: "#notification_list", actions: [{ label: "Reply", href: "#notification_list" }, { label: "Open report", href: "#notification_list" }] }, { id: 2, title: "@ada mentioned you in #design", kind: "mention", time: "2026-10-28T08:05:00Z" }, { id: 3, title: "Your export is ready", text: "orders-2026-10.csv, 2.4 MB", kind: "success", time: "2026-10-27T18:40:00Z", read: true }, { id: 4, title: "Card ending 4242 expires next month", kind: "warning", time: "2026-10-27T07:00:00Z" }, { id: 5, title: "Sign-in from a new device", text: "Chrome on Windows, Lisbon", kind: "danger", time: "2026-10-20T22:31:00Z", read: true },]component NotificationListDemoLive() { state items = notification_list_demo_items state last = "Nothing done yet." <div.demo-stack> <NotificationList id="nl-live" today="2026-10-28" items={items} on_read={(nid) => { items = items | map((n) => n.id == nid ? set(n, "read", true) : n); last = `Read ${nid}.` }} on_dismiss={(nid) => { items = items | filter((n) => n.id != nid); last = `Dismissed ${nid}.` }} on_read_all={() => { items = items | map((n) => set(n, "read", true)); last = "All read." }}/> <p.demo-note>{last}</p> </div>}view NotificationListDemo() = <div.demo-stack> <NotificationListDemoLive/> <form method="post" action="/notifications" style="max-width: 520px"> <NotificationList id="nl-form" title="Alerts" items={take(notification_list_demo_items, 2)}/> </form> <NotificationList id="nl-empty" title="Mentions" items={[]}/> </div>Source
ui/notification_list.qwe
// NotificationList: a person's notifications, grouped by day (Today, Yesterday,// then the date), each read or unread, with its own links and buttons to mark it// read or dismiss it, and "Mark all as read" at the top.//// <NotificationList today="2026-10-28" items={[// { id: 1, title: "Grace commented on Q3 report", text: "Looks good", time: "2026-10-28T09:12:00Z",// actor: { name: "Grace Hopper" }, href: "/docs/q3", actions: [{ label: "Reply", href: "/docs/q3#c9" }] },// { id: 2, title: "Your export is ready", kind: "success", time: "2026-10-27T18:40:00Z", read: true },// ]}/>//// items: maps of { id, title, text?, time, read?, kind?, actor?, href?, actions? },// newest first: time an ISO date-time (its time shows as written, "09:12"); kind// an icon, "info" (default), "success", "warning", "danger" or "mention"; actor// { name, src? } shows their picture instead; href makes the title a link;// actions a list of { label, href } links under it. today: an ISO date, so days// read "Today" and "Yesterday" (there is no clock here; pass date.today()).// title: the heading ("Notifications"). empty: what it says with none.// Inside one of your components: on_read(id), on_dismiss(id) and on_read_all()// are called by the buttons. Otherwise the buttons submit a form around the list:// `read` = the id, `dismiss` = the id, or `read_all` = "1". id: its id.// Needs avatar.qwe.import ui/avatar.qwe use Avatarimport ui/theme.qwe use ui_clslet notification_list_css = css`.ui-notification-list { display: grid; gap: 12px; min-width: 0; font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-notification-list-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }.ui-notification-list-title { display: flex; align-items: center; gap: 8px; margin: 0; font-size: 16px; font-weight: 650; }.ui-notification-list-count { min-width: 20px; padding: 1px 7px; border-radius: 999px; background: var(--ui-accent); color: var(--ui-accent-fg); font-size: 12px; font-weight: 700; text-align: center; font-variant-numeric: tabular-nums; }.ui-notification-list-all { padding: 4px 8px; border: 0; border-radius: var(--ui-radius-sm); background: none; color: var(--ui-accent); font: 500 13px/1.2 var(--ui-font); cursor: pointer; }.ui-notification-list-all:hover { background: var(--ui-accent-soft); }.ui-notification-list-all:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-notification-list-day { margin: 4px 0 0; color: var(--ui-muted); font-size: 12px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase; }.ui-notification-list-items { display: grid; margin: 0; padding: 0; list-style: none; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); overflow: hidden; }.ui-notification-list-item { position: relative; display: grid; grid-template-columns: auto 1fr auto; gap: 12px; padding: 12px 14px; background: var(--ui-surface); }.ui-notification-list-item + .ui-notification-list-item { border-top: 1px solid var(--ui-border); }.ui-notification-list-unread { background: color-mix(in srgb, var(--ui-accent-soft) 55%, var(--ui-surface)); }.ui-notification-list-unread::before { content: ""; position: absolute; left: 5px; top: 50%; width: 6px; height: 6px; margin-top: -3px; border-radius: 50%; background: var(--ui-accent); }.ui-notification-list-icon { --t: var(--ui-info); display: grid; place-items: center; width: 32px; height: 32px; border-radius: 50%; background: color-mix(in srgb, var(--t) 15%, transparent); color: var(--t); }.ui-notification-list-icon svg { width: 16px; height: 16px; }.ui-notification-list-success { --t: var(--ui-success); } .ui-notification-list-warning { --t: var(--ui-warning); }.ui-notification-list-danger { --t: var(--ui-danger); } .ui-notification-list-mention { --t: var(--ui-accent); }.ui-notification-list-body { display: grid; gap: 2px; min-width: 0; }.ui-notification-list-name { margin: 0; font-size: 14px; font-weight: 500; overflow-wrap: anywhere; }.ui-notification-list-unread .ui-notification-list-name { font-weight: 650; }.ui-notification-list-name a { color: inherit; text-decoration: none; }.ui-notification-list-name a:hover { color: var(--ui-accent); text-decoration: underline; }.ui-notification-list-name a:focus-visible, .ui-notification-list-links a:focus-visible { outline: none; border-radius: 4px; box-shadow: var(--ui-focus); }.ui-notification-list-text { margin: 0; color: var(--ui-muted); font-size: 13px; }.ui-notification-list-links { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 4px; font-size: 13px; }.ui-notification-list-links a { color: var(--ui-accent); font-weight: 500; text-decoration: none; }.ui-notification-list-links a:hover { text-decoration: underline; }.ui-notification-list-side { display: grid; justify-items: end; align-content: start; gap: 4px; }.ui-notification-list-time { color: var(--ui-muted); font-size: 12px; font-variant-numeric: tabular-nums; }.ui-notification-list-btns { display: flex; gap: 2px; }.ui-notification-list-btn { display: inline-grid; place-items: center; width: 26px; height: 26px; padding: 0; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-muted); cursor: pointer; }.ui-notification-list-btn:hover { background: var(--ui-subtle); color: var(--ui-fg); }.ui-notification-list-btn:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-notification-list-btn svg { width: 14px; height: 14px; }.ui-notification-list-empty { padding: 28px 16px; border: 1px dashed var(--ui-border); border-radius: var(--ui-radius); color: var(--ui-muted); text-align: center; }`let notification_list_days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]let notification_list_months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]// A day's heading: Today, Yesterday, else "Monday, Oct 26" (and the year when it is not today's).fn notification_list_day(d: str, today) -> str { if !date.valid(d) { return d } if today != null && date.valid(str(today)) { let n = date.days_between(d, str(today)) if n == 0 { return "Today" } if n == 1 { return "Yesterday" } } let p = date.parts(d) let year = today != null && slice(str(today), 0, 4) == str(p.year) ? "" : `, ${p.year}` `${notification_list_days[p.weekday - 1]}, ${notification_list_months[p.month - 1]} ${p.day}${year}`}fn notification_list_icon(kind: str) = <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> {match kind { "success" => <path d="M5 12.5l4.5 4.5L19 7.5"/> "warning" => <path d="M12 3 2 20h20L12 3Zm0 6v5m0 3v.5"/> "danger" => [<circle cx="12" cy="12" r="9"/>, <path d="M12 7v6m0 3v.5"/>] "mention" => [<circle cx="12" cy="12" r="4"/>, <path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8"/>] _ => <path d="M6 16V11a6 6 0 1 1 12 0v5l2 2H4l2-2Zm4 4a2 2 0 0 0 4 0"/> }} </svg>view NotificationList(items: list = [], today = null, title: str = "Notifications", empty: str = "You're all caught up.", on_read = null, on_dismiss = null, on_read_all = null, id: str? = null, class: str? = null) { style global notification_list_css let unread = count(items, (n) => n.read != true) let day = (n) => slice(str(n.time ?? ""), 0, 10) let days = unique(items | map((n) => day(n))) <section class={ui_cls(["ui-notification-list", class])} id={id} aria-label={title}> <div.ui-notification-list-head> <h3.ui-notification-list-title>{title} {unread > 0 && <span.ui-notification-list-count aria-label={`${unread} unread`}>{unread}</span>} </h3> {unread > 0 && (on_read_all != null ? <button.ui-notification-list-all type="button" @click={() => { on_read_all() }}>Mark all as read</button> : <button.ui-notification-list-all type="submit" name="read_all" value="1">Mark all as read</button>)} </div> {len(items) == 0 && <div.ui-notification-list-empty>{empty}</div>} {for d in days => <div.ui-notification-list-group key={d}> <h4.ui-notification-list-day>{notification_list_day(d, today)}</h4> <ul.ui-notification-list-items aria-label={notification_list_day(d, today)}> {for n in items | filter((x) => day(x) == d) => { let is_new = n.read != true let t = str(n.time ?? "") <li key={str(n.id)} class={ui_cls(["ui-notification-list-item", is_new && "ui-notification-list-unread"])}> {n.actor != null ? <Avatar name={n.actor.name} src={n.actor.src} size="sm"/> : <span class={`ui-notification-list-icon ui-notification-list-${n.kind ?? "info"}`}>{notification_list_icon(n.kind ?? "info")}</span>} <div.ui-notification-list-body> <p.ui-notification-list-name> {is_new && <span.ui-sr>Unread: </span>} {n.href != null ? <a href={n.href}>{n.title}</a> : n.title} </p> {n.text != null && <p.ui-notification-list-text>{n.text}</p>} {len(n.actions ?? []) > 0 && <div.ui-notification-list-links> {for a in n.actions => <a key={a.label} href={a.href}>{a.label}</a>} </div>} </div> <div.ui-notification-list-side> {len(t) >= 16 && <time.ui-notification-list-time datetime={t}>{slice(t, 11, 16)}</time>} <div.ui-notification-list-btns> {is_new && (on_read != null ? <button.ui-notification-list-btn type="button" aria-label={`Mark read: ${n.title}`} title="Mark read" @click={() => { on_read(n.id) }}>{notification_list_icon("success")}</button> : <button.ui-notification-list-btn type="submit" name="read" value={str(n.id)} aria-label={`Mark read: ${n.title}`} title="Mark read">{notification_list_icon("success")}</button>)} {on_dismiss != null ? <button.ui-notification-list-btn type="button" aria-label={`Dismiss: ${n.title}`} title="Dismiss" @click={() => { on_dismiss(n.id) }}> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg> </button> : <button.ui-notification-list-btn type="submit" name="dismiss" value={str(n.id)} aria-label={`Dismiss: ${n.title}`} title="Dismiss"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg> </button>} </div> </div> </li> }} </ul> </div>} </section>}Related
CheckListItems shown done or not with ticks and crosses (or empty circles), read-only, with an optional count.5 views · 8 downloadsConnectionStatusSays when the browser goes offline, as a bar across the top or a pill, and that it is back online for a moment.6 views · 7 downloadsNotificationDotA small dot or a count on the corner of anything, in tones and four positions, with a label for screen readers.5 views · 7 downloadsSnackbarA short message along the bottom of the window with one action, shown from anywhere by calling snackbar_show().6 views · 6 downloadsActivityFeedA stream of what people did, with their pictures, an icon for each kind of action, when, quotes and day headings.5 views · 7 downloadsAvatarStackOverlapping avatars with names in tooltips, links to each person, and a +N that names the rest.5 views · 9 downloads
Thread
Thread 0 posts
On NotificationList: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.