GodUIGodUI
129Follow on X

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.

01/04Header, sticky groups, rows
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
tsx
<div className="sticky top-0 z-base bg-card/90 px-4 py-1.5 backdrop-blur-sm">{label}</div>
01

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:

tsx
 
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.

02/04Swipe to archive, with a real threshold
Row
drag="x" · dragElastic left 0.7, right 0
Threshold
offset.x < -80
Archive strip
sits behind the row, revealed while dragging
tsx
<motion.divdrag="x"dragConstraints={{ left: 0, right: 0 }}dragElastic={{ left: 0.7, right: 0 }}onDragEnd={(_, info) => {  if (info.offset.x < -80) onArchive?.(notification.id);}}/>
02

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.

03/04The badge and the dots clear together
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
tsx
<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>
03

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:

tsx
 
<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.

04/04Result

Inbox

2
Today
  • Archive
    Unread
  • Archive
    Unread
  • Archive
Earlier
  • Archive
04

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

Notification InboxDD: Layout-triggering
Dheightstack item collapse to height:auto
SopacityFade / cross-fade
Each property is graded by how the browser runs it, from S (composited off the main thread) down to F (layout thrashing); the component takes the worst. MotionScore methodology →