Anatomy of the Notification Inbox
How a Linear-style inbox groups notifications into sticky buckets, archives a row with an elastic drag, and clears its unread badge in one motion.
↓Scroll to step through it
Header, sticky groups, rows
A notification inbox is judged on triage speed: can you tell what's new, clear it, and dismiss what you don't need without opening a menu? Notification Inbox uses three mechanisms: sticky grouping, a draggable archive, and a badge that clears in lockstep with the rows it represents.
The whole thing is one card: a header with the title and an unread count,
then notifications bucketed by group (defaulting to "Earlier" when a
notification doesn't specify one) under a label that sticks to the top of
the scroll container while its bucket is in view:
function groupNotifications(items: Notification[]) {
const order: string[] = [];
const map = new Map<string, Notification[]>();
for (const item of items) {
const key = item.group ?? "Earlier";
if (!map.has(key)) {
map.set(key, []);
order.push(key);
}
map.get(key)?.push(item);
}
return order.map((key) => [key, map.get(key)!] as const);
}sticky top-0 inside the scroll container lets "Today" pin to the top
while its rows scroll underneath it, with no scroll listener or manual
offset math. z-base keeps it above the rows without an arbitrary z-index,
and bg-card/90 with a blur keeps the pinned label from showing a hard edge
over the content.
Each row's <motion.li layout> makes the group respond to changes: read a notification, remove one, and every remaining row
re-measures and springs to its new position instead of snapping.
Swipe to archive, with a real threshold
Archiving has no button: it's a drag. Each row is drag="x", constrained
to move left only, with elastic resistance so a small nudge feels soft.
dragConstraints: { left: 0, right: 0 } sounds like it should lock the row
in place, and it would without dragElastic. Elastic drag lets the row
travel past its constraints with resistance proportional to the 0.7
factor; right: 0 gives zero travel in that direction, so the row only
pulls left. The archive strip is a solid bg-destructive panel sitting
absolutely behind the row the entire time. Dragging doesn't toggle it into
view; the row slides off it. The strip is solid rather than a
fade-to-transparent, so the action is readable as soon as a sliver appears.
onDragEnd is the only place the threshold matters: cross -80px of
offset and the row archives. Release anywhere short of it and Framer
springs the row back to zero. The snap-back needs no threshold check; it's
the default when the drag has no target.
On archive, the row's exit animation is { opacity: 0, height: 0, x: -80 }.
The row keeps travelling in the direction it was being dragged instead of
snapping back to center first, so the dismissal reads as a continuation of
the gesture.
The badge and the dots clear together
The badge is conditionally rendered inside AnimatePresence rather than
hidden with display: none, so when the count hits zero, removing it
triggers the same exit animation as any other AnimatePresence child.
Because onMarkAllRead sets every notification's read flag in the same
state update, unread drops to 0 in one render: the badge's exit and
every row's unread-dot disappearance happen on the same tick instead of one
notification at a time. The count that drives the badge and the flags that
drive the dots share one source of truth, updated once.
In an empty inbox, the bell icon bobs on a slow, infinite y: [0, -4, 0]
loop:
<motion.div
animate={{ y: [0, -4, 0] }}
transition={{ duration: 2.4, repeat: Infinity }}
>
<BellIcon />
</motion.div>It's a single transform-only keyframe on one small icon, so its
per-frame cost isn't worth pausing for. Unlike a full row list, an empty
state has nothing else on screen competing for main-thread time.
The result
One card, one grouping function, one drag threshold, and a badge that's
another AnimatePresence child reacting to the same state as the rows
underneath it.
- Header
- title + unread badge + mark-all-read
- Group label
- sticky top-0, one per group() bucket
- Row
- avatar + two text lines + time
- Unread dot
- primary, only while notification.read is falsy
<div className="sticky top-0 z-base bg-card/90 px-4 py-1.5 backdrop-blur-sm">{label}</div>Anatomy of the Notification Inbox
How a Linear-style inbox groups notifications into sticky buckets, archives a row with an elastic drag, and clears its unread badge in one motion.
- Header
- title + unread badge + mark-all-read
- Group label
- sticky top-0, one per group() bucket
- Row
- avatar + two text lines + time
- Unread dot
- primary, only while notification.read is falsy
<div className="sticky top-0 z-base bg-card/90 px-4 py-1.5 backdrop-blur-sm">{label}</div>Header, sticky groups, rows
A notification inbox is judged on triage speed: can you tell what's new, clear it, and dismiss what you don't need without opening a menu? Notification Inbox uses three mechanisms: sticky grouping, a draggable archive, and a badge that clears in lockstep with the rows it represents.
The whole thing is one card: a header with the title and an unread count,
then notifications bucketed by group (defaulting to "Earlier" when a
notification doesn't specify one) under a label that sticks to the top of
the scroll container while its bucket is in view:
function groupNotifications(items: Notification[]) {
const order: string[] = [];
const map = new Map<string, Notification[]>();
for (const item of items) {
const key = item.group ?? "Earlier";
if (!map.has(key)) {
map.set(key, []);
order.push(key);
}
map.get(key)?.push(item);
}
return order.map((key) => [key, map.get(key)!] as const);
}sticky top-0 inside the scroll container lets "Today" pin to the top
while its rows scroll underneath it, with no scroll listener or manual
offset math. z-base keeps it above the rows without an arbitrary z-index,
and bg-card/90 with a blur keeps the pinned label from showing a hard edge
over the content.
Each row's <motion.li layout> makes the group respond to changes: read a notification, remove one, and every remaining row
re-measures and springs to its new position instead of snapping.
- Row
- drag="x" · dragElastic left 0.7, right 0
- Threshold
- offset.x < -80
- Archive strip
- sits behind the row, revealed while dragging
<motion.divdrag="x"dragConstraints={{ left: 0, right: 0 }}dragElastic={{ left: 0.7, right: 0 }}onDragEnd={(_, info) => { if (info.offset.x < -80) onArchive?.(notification.id);}}/>Swipe to archive, with a real threshold
Archiving has no button: it's a drag. Each row is drag="x", constrained
to move left only, with elastic resistance so a small nudge feels soft.
dragConstraints: { left: 0, right: 0 } sounds like it should lock the row
in place, and it would without dragElastic. Elastic drag lets the row
travel past its constraints with resistance proportional to the 0.7
factor; right: 0 gives zero travel in that direction, so the row only
pulls left. The archive strip is a solid bg-destructive panel sitting
absolutely behind the row the entire time. Dragging doesn't toggle it into
view; the row slides off it. The strip is solid rather than a
fade-to-transparent, so the action is readable as soon as a sliver appears.
onDragEnd is the only place the threshold matters: cross -80px of
offset and the row archives. Release anywhere short of it and Framer
springs the row back to zero. The snap-back needs no threshold check; it's
the default when the drag has no target.
On archive, the row's exit animation is { opacity: 0, height: 0, x: -80 }.
The row keeps travelling in the direction it was being dragged instead of
snapping back to center first, so the dismissal reads as a continuation of
the gesture.
- Badge
- AnimatePresence, scale 0.6 → 1 spring
- Mark all read
- flips every notification.read at once
- Unread dot
- clears in the same frame as the badge
<AnimatePresence>{unread > 0 ? ( <motion.span initial={{ opacity: 0, scale: 0.6 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.6 }} > {unread} </motion.span>) : null}</AnimatePresence>The badge and the dots clear together
The badge is conditionally rendered inside AnimatePresence rather than
hidden with display: none, so when the count hits zero, removing it
triggers the same exit animation as any other AnimatePresence child.
Because onMarkAllRead sets every notification's read flag in the same
state update, unread drops to 0 in one render: the badge's exit and
every row's unread-dot disappearance happen on the same tick instead of one
notification at a time. The count that drives the badge and the flags that
drive the dots share one source of truth, updated once.
In an empty inbox, the bell icon bobs on a slow, infinite y: [0, -4, 0]
loop:
<motion.div
animate={{ y: [0, -4, 0] }}
transition={{ duration: 2.4, repeat: Infinity }}
>
<BellIcon />
</motion.div>It's a single transform-only keyframe on one small icon, so its
per-frame cost isn't worth pausing for. Unlike a full row list, an empty
state has nothing else on screen competing for main-thread time.
Inbox
2- ArchiveUnread
- ArchiveUnread
- Archive
- Archive
The result
One card, one grouping function, one drag threshold, and a badge that's
another AnimatePresence child reacting to the same state as the rows
underneath it.
Accessibility
The unread dot carries a visually-hidden <span className="sr-only">Unread </span> so screen readers get the state without relying on a bare color
dot. Marking a row read is a native <button> wrapping the row's content,
so it's reachable and activatable from the keyboard independent of the drag
gesture layered on top of it.
Motion Score
heightstack item collapse to height:autoopacityFade / cross-fade