A lightweight TypeScript library that adds a floating date indicator to chronological timelines while the user scrolls

Recorded in a real Matrix client to illustrate the intended use case! The conversation itself is a mock.
Long chat or feed timelines often contain hundreds of items. When the user scrolls quickly, it is easy to lose track of which day they are looking at. This library:
- Shows the current day as a floating pill while the user scrolls.
- Hides the pill automatically after a short idle period.
- Tracks the visible date via
IntersectionObserver, no scroll math. - Stays unopinionated: works with any DOM, optional React bindings.
- Floating date pill with fade in/out
- Framework-agnostic core in pure TypeScript
- Optional React hook (
useScrollDateIndicator) and component (ScrollDateIndicator) - Locale-aware relative day formatting (Today, Yesterday, weekday names, full date)
- Custom timestamp extractors and selectors for any DOM layout
- Tiny, tree-shakable, zero runtime dependencies
- Full TypeScript type definitions
npm install scroll-date-indicator
# or
pnpm add scroll-date-indicator
# or
yarn add scroll-date-indicatorReact is an optional peer dependency. If you use the React bindings, install
react and react-dom>=18.
This library is framework-agnostic and works with:
- Vanilla JavaScript/TypeScript (ES2020+)
- React (18+, 19)
- Vue.js, Svelte, Solid, Angular via the vanilla core
- Next.js, Nuxt.js, SvelteKit etc.
- Webpack, Vite, Rollup, esbuild - all bundlers
- Browser environments (requires
IntersectionObserver)
No runtime dependencies - works everywhere modern JavaScript runs.
The library reads timestamps directly from the DOM. Each timeline item must
carry a data-timestamp attribute with a Unix timestamp in milliseconds.
import{createScrollDateIndicator}from"scroll-date-indicator";import{formatRelativeDay}from"scroll-date-indicator/presets";constcontainer=document.querySelector("#timeline")asHTMLElement;constpill=document.querySelector("#pill")asHTMLElement;constctrl=createScrollDateIndicator({
container,onDateChange: (date)=>{pill.textContent=date ? formatRelativeDay(date,{locale: "de-DE"}) : "";},onVisibilityChange: (visible)=>{pill.style.opacity=visible ? "1" : "0";},});// later on cleanupctrl.destroy();React:
import{useRef}from"react";import{ScrollDateIndicator}from"scroll-date-indicator/react";functionChatRoom({ messages }){constscrollRef=useRef<HTMLDivElement|null>(null);return(<divstyle={{position: "relative",height: "100%"}}><divref={scrollRef}style={{height: "100%",overflowY: "auto"}}>{messages.map((m)=>(<divkey={m.id}data-timestamp={m.ts}>{m.body}</div>))}</div><ScrollDateIndicatorcontainerRef={scrollRef}locale="en-US"position="top-center"/></div>);}Matrix-based clients (matrix-js-sdk, matrix-rust-sdk, hydrogen-sdk, ...):
Each event carries an origin_server_ts (ms). Set it as data-timestamp and
the indicator works out of the box.
{events.map((ev)=>(<divkey={ev.event_id}data-timestamp={ev.origin_server_ts}>{ev.content.body}</div>))}Custom timestamp source:
<ScrollDateIndicatorcontainerRef={scrollRef}itemSelector=".message"getTimestamp={(el)=>Number(el.dataset.myTs)}/>Framework-agnostic core. Returns a controller.
| Option | Type | Default | Description |
|---|---|---|---|
container | HTMLElement | required | The scrollable container |
itemSelector | string | "[data-timestamp]" | Selector for timeline items |
getTimestamp | (el: HTMLElement) => number | null | reads data-timestamp | Custom timestamp extractor |
onDateChange | (date: Date | null) => void | Called when the visible date changes | |
onVisibilityChange | (visible: boolean) => void | Called when the indicator should appear or hide | |
idleHideDelay | number | 1200 | Hide delay in ms after the last scroll event |
anchor | "topmost" | "center" | "topmost" | Which visible item to anchor on |
rootMargin | string | "0px" | Forwarded to IntersectionObserver |
ReturnsScrollDateIndicatorController:
refresh(): void- rescans items, useful after prepending contentdestroy(): void- removes listeners and observerscurrentDate: Date | nullisVisible: boolean
React hook. Same options as the core, plus:
containerRef: RefObject<HTMLElement>deps?: ReadonlyArray<unknown>- reattaches the controller when changed
Returns{ date: Date | null; visible: boolean }
Drop-in React component that renders a styled pill. Hook options plus:
| Prop | Type | Description |
|---|---|---|
locale | string | string[] | Locale for the default formatter |
labels | { today?: string; yesterday?: string } | Localized labels for relative-day buckets |
format | (date: Date) => string | Override the default formatter completely |
position | "top-center" | "top-right" | "top-left" | "right-edge" | "left-edge" | Anchor position. Default "top-center" |
className | string | Extra class for the pill |
style | CSSProperties | Inline style for the pill |
children | (state) => ReactNode | Render override; ignores the built-in style |
Locale-aware day formatter. Returns:
Todayfor the same calendar dayYesterdayfor the previous calendar day- weekday name within the last 6 days
24 Aprilwithin the same year24 April 2024for older dates
formatRelativeDay(newDate(),{locale: "de-DE",labels: {today: "Heute",yesterday: "Gestern"},});Convenience preset with chat-friendly defaults (itemSelector,
timestampAttr, idleHideDelay, format).
import{matrixTimelinePreset}from"scroll-date-indicator/presets";constpreset=matrixTimelinePreset({locale: "de-DE"});preset.format(newDate());// "Today"# Install dependencies
pnpm install
# Run tests
pnpm test# Start the interactive playground
pnpm playground
# Build the library
pnpm buildMIT © Kevin Mirzaian