Data

NotificationList

Notifications grouped by day (Today, Yesterday, then dates), read or unread, with links, mark-read and dismiss buttons and Mark all as read.

#notification#list#status#no-js

Demo

Preview

Notifications3

Today

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.

Alerts2

Wednesday, Oct 28, 2026

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.qwe
main.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>}
Thread
76Useful? Like it, or add to its thread below.

Thread 0 posts

On NotificationList: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.