Home/ Cookbook/ Lists/ Grouped by date

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.

Lists 3 primitives · 1 block · 0 patterns ~15 min React · @corelithzw/react

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.

Huchu

Activity

Today
Pour logged9:42
Cash-up signed9:14
Yesterday
Day closed18:04
Refund approved15:21
Earlier this week
Shift editTue
01 Default — buckets stacked top down
Huchu

Activity

Yesterday
Cash-up signed18:04
Refund approved15:21
Day opened07:55
POS sync07:50
Earlier this week
Shift editTue
02 Sticky — "Yesterday" pinned at the top
Huchu

Activity

Today
Pour logged9:42
Yesterday
Nothing happened.
Earlier this week
Shift editTue
03 Empty bucket — show, don't hide

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

01

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().

Code-only step

Five buckets, ordered. The order is part of the API — never sort by something else at render time.

Step 1 · Buckets@corelithzw/react
02

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

Code-only step

Pure function. Pass now as a parameter so tests can pin the clock — the moment you call new Date() inside, the function is untestable.

Step 2 · bucketByDate()
03

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.

Sticky to the LIST container, not the viewport.
Otherwise the header overlaps your page header.
Step 3 · Sticky CSS
04

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").

Today
Pour logged9:42
Yesterday
Nothing happened.
Step 4 · Render

Final composition

Bucketing function + render + sticky CSS. About 80 lines. Plug in your event source and go.

Activity

Today
Pour logged9:42
Cash-up signed9:14
Yesterday
Day closed18:04
ActivityFeed.tsx@corelithzw/react

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> with aria-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.