AI
Chat Day Divider
A muted separator for chat transcripts — day chip, unread watermark and context event — that derives Today/Yesterday from calendar days, relabels itself at local midnight, and can pin to the top while its day scrolls.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chat-day-divider.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ChatDayDivider" component: the muted
seam a chat transcript puts between messages. No date library — Intl does all
the wording.
Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>
minus children; remaining props spread onto the root so a caller can override
role, aria-label, data-*.
- variant?: "date" | "unread" | "event" (default "date").
date — a day chip derived from `date` ("Today", "March 3").
unread — the read/unread watermark, accent coloured.
event — a context event between two messages ("Switched to a different
model", "Chat renamed"); the caller supplies `label`.
- date?: string | number | Date — the day this divider opens.
- now?: string | number | Date — reference "now". Omit to follow the visitor's
clock; pass it to pin the label (SSR determinism, tests, time-travel preview).
- label?: React.ReactNode — overrides the derived text.
- count?: number — unread only: "12 new messages" / "1 new message".
- icon?: React.ReactNode, meta?: React.ReactNode — leading glyph, trailing dim
detail (an event time, a message count).
- locale?: string — BCP 47; omit to use the visitor's.
- formatLabel?: (date: Date, ctx: { todayStart: number; locale?: string }) =>
string — replaces the whole derivation.
- sticky?: boolean (default false), stickyOffset?: number (default 0),
scrollRoot?: RefObject<HTMLElement | null>.
- line?: boolean (default true) — hairline on both sides of the chip.
- reveal?: boolean (default false) — one-shot fade-in on insertion.
- scrollIntoViewOnMount?: boolean (default false).
Behavior — the label is calendar arithmetic, never elapsed time
- A message sent at 23:50 is twenty minutes old and still belongs to
*yesterday* once the clock passes midnight. So compare START-OF-LOCAL-DAY
values, not timestamps: diff = round((startOfDay(date) - todayStart) / 24h).
Round, because a DST day is 23 or 25 hours long.
- Coarsest fit: |diff| <= 1 → Intl.RelativeTimeFormat(locale, {numeric:"auto"})
.format(diff,"day") → "today" / "yesterday" / "tomorrow"; -2..-6 → weekday
name (that is how people refer to the last week); same year → "March 3";
otherwise "March 3, 2025". Upper-case the first grapheme with
toLocaleUpperCase(locale) — Intl returns "today", a chip wants "Today", and
a Chinese reader gets 今天 / 星期一 untouched.
- A future day is legal (scheduled sends): "Tomorrow", then plain dates.
- An unparseable / NaN date renders as a bare hairline, never "Invalid Date".
Behavior — one clock for the whole page
- Keep the start of the reader's local day in ONE module-level external store
read through useSyncExternalStore, not a timer per divider.
- Arm a single setTimeout at the next local midnight, computed by bumping the
DATE field (never by adding 24h: DST, month and year rollover). On fire,
re-derive from the wall clock instead of stepping one day — a suspended
laptop can wake up three days later — notify, then re-arm.
- Also re-sync on document visibilitychange: background tabs get their timers
throttled or frozen, and a tab restored after midnight must not keep
insisting it is "Today".
- The subscription is reference counted: first subscriber arms the timer and
the listener, last unsubscriber clears both. A divider given `now` never
subscribes at all.
- getServerSnapshot returns 0 = "no clock yet". While it is 0 (server render +
the hydrating frame) print date.toISOString().slice(0,10) — a locale- and
time-zone-free string both sides agree on byte for byte — and let the real
label land one commit later. Rendering "Today" on the server is a guaranteed
hydration mismatch: the server has neither the visitor's time zone nor,
across midnight, their date.
Behavior — sticky pinning
- sticky adds `sticky z-10` with top = stickyOffset. Detect the pinned state
without a sentinel element: observe the divider with IntersectionObserver,
root = scrollRoot ?? nearest ancestor whose computed overflow-y is
auto/scroll/overlay (null = viewport), rootMargin
`-${stickyOffset + 1}px 0px 0px 0px`, threshold [1]. Pinned when
intersectionRatio < 1 AND boundingClientRect.top <= rootBounds.top — the
ratio also drops when the divider leaves through the BOTTOM, and the
top-edge test is what tells the two apart.
- While pinned the hairlines fade to 0 (a rule running across the messages
underneath reads as a strikethrough) and the chip takes a border + shadow so
it floats. Drive both from a data-stuck attribute on the root through a named
Tailwind group, so consumers can restyle the pinned state from CSS alone.
- Never call setState synchronously in the effect body when sticky flips off —
keep the last observation and read it as `sticky && stuck`.
- Disconnect the observer on unmount and whenever sticky/offset/root change.
- Document the consumer's half of the deal: each day's messages must live in
their own element with the divider as its first child, so the chip is pushed
out by the next day instead of every chip stacking at top: 0 — and no
ancestor between the divider and the scroll box may set overflow, which kills
position: sticky silently.
Behavior — unread watermark and the one-shot landing
- scrollIntoViewOnMount places the viewport on the watermark the way a reader
expects when they come back to a thread. Set the "already landed" ref BEFORE
scrolling, so a re-render (a message arriving, a prop flip, a Strict Mode
double invoke) can never yank the reader back a second time.
- Scroll the TRANSCRIPT's own scrollbar — measure with getBoundingClientRect
against the scroll root and call root.scrollTo — instead of
el.scrollIntoView, which walks the whole ancestor chain and drags the page to
the panel too. Fall back to scrollIntoView only when there is no scroll root.
- behavior: "smooth", or "auto" under prefers-reduced-motion (read through
matchMedia + useSyncExternalStore, listener removed on unmount).
Rendering & styling
- Semantic tokens only: bg-muted / text-muted-foreground for the day chip,
bg-primary/10 + text-primary and bg-primary/40 hairlines for unread,
bg-muted/50 for events, bg-border for the hairline, border-border + shadow-sm
while pinned. No hex, no rgb(), no oklch().
- Layout: flex row, `h-px min-w-4 flex-1` hairline · rounded-full chip ·
hairline. The chip keeps a transparent border at rest so picking one up while
pinned cannot shift the layout by a pixel. Long event labels wrap and stay
centred; nothing is truncated.
- role="separator" on the root. A non-focusable separator has presentational
children, so the visible text is NOT announced — carry the accessible name in
aria-label, derived automatically when the label is a plain string. Document
that a ReactNode label needs an explicit aria-label. Hairlines are
aria-hidden. The date variant wraps its text in <time dateTime={iso}> so the
machine-readable instant survives the human wording.
- reveal ships its keyframes in a React 19 hoisted <style href precedence> and
is a natural one-shot: a CSS animation plays on INSERTION, so a re-render
cannot replay it. Kill it with motion-reduce:[animation:none], and give the
opacity/shadow transitions motion-reduce:transition-none.
- Merge className through cn() and merge the caller's `style` AFTER the
computed top / scroll-margin so an override is still possible.
Customization levers
- Label rules: the weekday window (-6), the "same year drops the year" rule and
the Today/Yesterday idioms are four lines in one function — or replace the
whole thing with formatLabel and keep the store, the midnight relabel and the
SSR gate for free.
- Density: py-3 on the root and px-2.5 py-0.5 text-xs on the chip are the only
spacing knobs; a dense thread usually wants line={false} (chip only) and py-2.
- Weight: variants differ by background and font-weight, not by size — keep the
chip at text-xs so it never competes with the messages around it.
- Pinned look: swap border+shadow for bg-background/85 + backdrop-blur-sm if
your bubbles are dense enough to read through, or keep the hairlines visible
by dropping the group-data-[stuck] opacity rule.
- Unread wording: `count` produces English plurals — pass `label` (or wire
Intl.PluralRules) for other languages, and keep the accent tokens so the
watermark stays the one line in the transcript that is allowed to be loud.
- Events: `icon` + `meta` are free-form nodes; render a model glyph, a time, a
"6 messages" count, or nothing at all.Concepts
- Calendar day, not elapsed time — the label compares start-of-day values, so a message from 23:50 flips to "Yesterday" the moment the clock passes midnight instead of clinging to "Today" for another 23 hours; rounding the division absorbs the 23- and 25-hour days a DST switch produces.
- Midnight relabel — one shared store holds the reader's current day and one timer is armed at the next local midnight (re-derived from the wall clock on every fire, and re-synced on
visibilitychangebecause a background tab's timers are throttled or frozen), so a thread left open overnight rewrites its own dividers. - Hydration-safe placeholder — "Today" depends on a time zone the server does not have, so the server render and the hydrating frame print a locale-free ISO day and the real wording lands one commit later; the
nowprop opts out of the whole dance when the reference day is known up front. - Pin detection without a sentinel — an IntersectionObserver whose root edge is pulled down past the sticky offset reports ratio < 1 exactly when the chip starts to stick; the extra top-edge test is what separates "pinned at the top" from "leaving through the bottom", and the pinned state is published as a data attribute so the hairlines can fade and the chip can float.
- Unread watermark — the one line in a transcript allowed to use the accent colour, because it answers "where did I stop reading"; it survives the messages being re-read, since only the caller knows when the watermark has been earned.
- One-shot landing — placing the viewport on that watermark is a single privileged move: the lock flips before the scroll and never reopens, so every later render leaves the reader's scroll position exactly where they put it.
Message Branch
A compact pager for the sibling variants of one reply — an id-stable selection that survives late arrivals, follow-the-tip on regenerate, ends that never steal focus, and one announcement per move.
Message Skeleton
A chat-shaped loading placeholder — seeded conversation rhythm, alternating bubbles or document rows, a sweep phase-shifted through the thread, and one late screen-reader announcement.