Build a date-grouped feed — Today, Yesterday, Earlier this week
By the end of this recipe you'll have an activity feed grouped into human time buckets, with sticky headers that pin to the top of the list as you scroll past each section.
Overview
Take a flat array of timestamped events and slot each one into a bucket: Today, Yesterday, Earlier this week, Last week, Earlier. Render each bucket as a sticky-headed section.
People don't read a column of timestamps — they read the gap between them. "9:42, 9:38, 9:21, yesterday 18:04" is four entries; "Today (3) / Yesterday (1)" is the same data, in a shape brains can scan. Grouping is what turns an event log into a feed.
Never show a flat list of timestamps. If you have more than five entries and they span more than a day, group them. Always. The bucket header is doing the work the timestamp can't.
Skip this recipe if every event is from the same day — then a flat list with HH:MM is fine. Also skip if events are conceptually unordered (a settings list isn't a feed).
What you'll build
Default feed, sticky header pinned mid-scroll, and the empty bucket state.
Activity
Activity
Activity
Required pieces
Two primitives, one block, and a 30-line bucketing function.
@corelithzw/react exports used here: Stack, RowCard, EmptyState. Sticky headers are CSS — position: sticky; top: 0 on the header inside a scrolling list area.
Step-by-step build
Define the buckets — five, in chronological order
Five is the right number. Fewer ("Today / Earlier") loses signal; more ("This morning / Lunchtime / This afternoon") nobody asked for. The buckets are an ordered enum, not a set — a flat array means rendering is just buckets.map().
Five buckets, ordered. The order is part of the API — never sort by something else at render time.
Bucket the array — one pass, no Intl yet
Compute day boundaries once at the top, then a single pass over the events. Don't use a relative-time library here — calendar comparisons on milliseconds are cheaper and more correct (a "yesterday" event from 23:59 should still be in Yesterday, not Today).
Pure function. Pass now as a parameter so tests can pin the clock — the moment you call new Date() inside, the function is untestable.
Sticky headers — CSS, not JS
position: sticky on the header pins it to the top of the scrolling ancestor as you scroll past. No IntersectionObserver, no scroll listener. The sticky ancestor is the list container; the header sticks inside the list, not to the page top — which keeps the page header visible.
Otherwise the header overlaps your page header.
Render buckets in order, skip silently — except today/yesterday
Walk the ordered bucket array. If a bucket is empty, drop it — except Today and Yesterday, which should always render even if empty. Their absence is meaningful information ("nothing happened today").
Final composition
Bucketing function + render + sticky CSS. About 80 lines. Plug in your event source and go.
Activity
Variations
Three forks where the bucket idea still holds.
By calendar day, not relative
For historical browsing (audit logs), absolute dates beat relative — "Mon 12 May" never lies. Format with Intl.DateTimeFormat, keep the bucket-per-day shape.
// drop the 5-bucket enum: const key = d.toISOString().slice(0,10); buckets[key] ??= []; buckets[key].push(e); // keys sort lexicographically, so iterate // reverse for newest-first.
Infinite scroll into the past
"Earlier" becomes a fetch trigger — when the user scrolls into it, load the next page. The bucket header acts as the "loading older" sentinel.
<Sentinel onVisible={loadOlder}>
<h3 className="feed-section-header">
{loading ? 'Loading…' : 'Earlier'}
</h3>
</Sentinel>
Collapsible buckets
Wrap each section in <details> so old buckets default closed. Today and Yesterday stay open by default.
<details open={ALWAYS_SHOW.includes(key)}>
<summary className="feed-section-header">
{BUCKET_LABEL[key]}
<span>({items.length})</span>
</summary>
{/* rows */}
</details>
Accessibility
A grouped feed has more structure than a flat list — exposing it matters.
- Each bucket is a
<section>witharia-label. Screen readers announce "Today, region" as you enter — the relative label is the section's identity. - Timestamps use
<time dateTime>. The visible string is human ("9:42"), the machine-readable attribute is ISO. Screen readers read the machine format clearly. - Sticky header is a real heading.
<h3>on the bucket label so heading-jump (H key) lands on each bucket in turn. - Empty buckets say so. Don't render a blank section — show "Nothing happened." so users with no vision aren't left wondering if it failed to load.
- Live region for new entries. If events arrive in real time, wrap "Today" with
aria-live="polite"so new rows are announced without yanking focus.