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…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------- *
 * Reveal keyframes
 *
 * Shipped with the component through a React 19 hoisted <style> — no Tailwind
 * config edit, and every instance dedupes on the same href. A CSS animation
 * plays when the element is INSERTED, so it is a natural one-shot: a re-render
 * (a new message landing above the line) can never replay it.
 * -------------------------------------------------------------------------- */
const KEYFRAMES = `@keyframes cdd-reveal{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}`

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chat-day-divider.json

Prompt

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 visibilitychange because 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 now prop 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.

On This Page