SessionORG
A whole chat session, hosted and scrolled: a transcript (role="log") of messages, bubbles and markers that follows the newest message while you are at the bottom and lets go the moment you scroll up, a button back to the latest, history that loads without moving your place, anchored turns, streaming replies, and files dropped anywhere on it - with the Textarea page's auto-growing composer below. After shadcn's MessageScroller.
On this page (8)
§Session
A whole conversation: the transcript follows the newest message while you are at the bottom and lets go when you scroll up - the arrow brings you back. The composer is the Textarea page's .textarea-group with the same script: it grows, + attaches images, Enter sends. Drop images anywhere on the message window (data-drop) and they land in the composer; send, and Sofia types back.
§Assistant: anchored turns and streaming
Ask something: your question is a data-anchor turn - it settles near the top with a peek at the previous answer, instead of the window jumping to the very end - and the reply streams in word by word under the streaming state (aria-busy on the log, following the text while you are at the bottom).
§Load older messages
History arrives above: df$.shadcn.session.prepend() adds older rows and shifts the scroll by exactly their height, so the message you were reading does not move. The session owns this (overflow-anchor: none) - the browser's own anchoring would correct twice.
§Jump to a turn
data-track reports the current turn (the last anchored question in the upper third) and the visible rows as session-visibility - the outline highlights it as you scroll. Click a question: scrollToMessage() anchors it with the peek. The session opens at the last anchored turn (data-default-position='last-anchor').
§Scroll status
Opened at the start (data-default-position='start'): the up button appears once there is something above, the down button while there is something below - each inert when there is nothing in its direction. The status line reads the state and data-scrollable.
§States
Named states via the shared State API, driven per session through the bound api:
default- following the live edge: new messages scroll into viewdetached- not following: the reader scrolled away or a turn anchored;{ to: 'start' | messageId }scrolls therestreaming- a reply is being written:aria-busyon the log, the transcript follows it at the end
The first example carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/session-{state}.png.
Machine contract - verified against session.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
detached | boolean | true, false | false | Not following the live edge - the reader scrolled away or a turn anchored. |
streaming | boolean | true, false | false | A reply is being written: aria-busy on the log, following at the end. |
§CSS view file
/* -- Session component ------------------------------------------- A chat session: a scrolling transcript (role="log") that follows the live edge, jumps back to it, keeps its place when history loads, anchors new turns, and takes dropped files - plus a footer for the composer. The runtime keeps the data-* state current; CSS draws from it. */@layer components { .session { position: relative; display: flex; flex-direction: column; min-height: 0; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-xl); background: var(--card); color: var(--card-foreground); } /* -- Viewport: the scroller --------------------------------------- */ .session-viewport { position: relative; display: flex; flex-direction: column; flex: 1; min-height: 0; padding: 1rem; overflow-y: auto; overscroll-behavior: contain; /* the runtime keeps the reader's place when history prepends - the browser's own scroll anchoring would correct twice */ overflow-anchor: none; scrollbar-gutter: stable; &:focus-visible { outline: 2px solid var(--ring); outline-offset: -2px; } } /* short threads sit at the bottom, like every chat */ .session-content { display: flex; flex-direction: column; gap: 1rem; margin-block-start: auto; } /* rows off screen skip rendering; their size is remembered. content- visibility implies paint containment, which clips at the row's edge - the clip margin lets bubble tails, reactions and focus rings draw outside it */ .session-item { content-visibility: auto; contain-intrinsic-size: auto 3.5rem; overflow-clip-margin: 1rem; &[data-current] { scroll-margin-block-start: 1rem; } } /* data-animate: new rows rise in */ .session[data-animate] .session-item { transition: opacity 250ms ease, translate 250ms ease; @starting-style { opacity: 0; translate: 0 0.5rem; } } /* -- Scroll buttons: float over the transcript, stuck to its edge -- */ .session-scroll-button { position: sticky; z-index: 1; align-self: center; flex: none; display: grid; place-items: center; width: 2.25rem; height: 2.25rem; margin: 0; padding: 0; border: 1px solid var(--border); border-radius: 999px; background: var(--background); color: var(--foreground); box-shadow: var(--shadow-md); cursor: pointer; opacity: 0; scale: 0.85; visibility: hidden; transition: opacity 150ms, scale 150ms, visibility 150ms allow-discrete, background-color 150ms; /* a chevron, drawn */ &::before { content: ""; width: 0.5rem; height: 0.5rem; border-inline-end: 2px solid currentColor; border-block-end: 2px solid currentColor; rotate: 45deg; translate: 0 -0.125rem; } &:hover { background: var(--accent); color: var(--accent-foreground); } &:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } /* to the end: at the bottom edge, overlaying the last row */ &[data-to="end"] { order: 2; inset-block-end: 0.5rem; margin-block-start: -2.25rem; } /* to the start: at the top edge, pointing up */ &[data-to="start"] { order: -1; inset-block-start: 0.5rem; margin-block-end: -2.25rem; &::before { rotate: -135deg; translate: 0 0.125rem; } } } .session[data-scrollable~="end"] .session-scroll-button[data-to="end"], .session[data-scrollable~="start"] .session-scroll-button[data-to="start"] { opacity: 1; scale: 1; visibility: visible; } /* -- Footer: the composer, a status line ---------------------------- */ .session-footer { flex: none; padding: 0.75rem; border-top: 1px solid var(--border); background: var(--card); } .session-status { padding: 0.375rem 1rem; border-top: 1px solid var(--border); color: var(--muted-foreground); font-size: 0.75rem; } /* -- Drop target: files dragged over the session ----------------------- */ .session[data-drop-active]::after { content: attr(data-drop-label); position: absolute; inset: 0.5rem; z-index: 2; display: grid; place-items: center; border: 2px dashed var(--ring); border-radius: var(--radius-lg); background: color-mix(in oklch, var(--background) 85%, transparent); color: var(--foreground); font-size: 0.875rem; font-weight: 500; pointer-events: none; } /* -- Accessibility -------------------------------------------- */ @media (prefers-reduced-motion: reduce) { .session-scroll-button, .session[data-animate] .session-item { transition: none; } } @media (prefers-contrast: more) { .session, .session-footer, .session-scroll-button { border-color: var(--foreground); } } @media (forced-colors: active) { .session { border: 1px solid CanvasText; } .session-scroll-button { border: 1px solid ButtonText; } .session[data-drop-active]::after { border-color: Highlight; forced-color-adjust: none; } }}§JS view file
// -- Session ----------------------------------------------------// A chat transcript that behaves like one: it follows the live edge while// the reader is there and lets go the moment they scroll away; a button// brings them back; history loaded above keeps their place; a new turn can// anchor near the top with a peek at the one before; files dropped on it// arrive as an event. The scrolling is native (a focusable region, the// keyboard's own keys) - this module only decides where to put it.// Plus the named-state API (AGENTS.md "State API") and df$.shadcn.session.// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.import { defussGlobals } from '../../shared/state-api.js';const df$ = defussGlobals();/** default = following the live edge; detached = the reader scrolled away * (or a turn anchored); streaming = a reply is being written (aria-busy), * the transcript follows it. */const sessionStates = ['default', 'detached', 'streaming'];const num = (el, key, fallback) => { const v = parseFloat(el.dataset[key]); return Number.isFinite(v) ? v : fallback;};const reduced = () => globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches;const resolve = (t) => (typeof t === 'string' ? document.getElementById(t) ?? document.querySelector(t) : t);const parts = (s) => ({ viewport: s.querySelector(':scope > .session-viewport'), content: s.querySelector(':scope > .session-viewport > .session-content'),});/** How far from the end the reader is, in px. */const fromEnd = (v) => v.scrollHeight - v.scrollTop - v.clientHeight;/** Scrolls the viewport without the reader's scroll logic reacting to it. */function scrollViewport(s, top, smooth) { const { viewport } = s._parts; s.setAttribute('data-autoscrolling', ''); viewport.scrollTo({ top, behavior: smooth && !reduced() ? 'smooth' : 'instant' }); clearTimeout(s._settle); // scrollend where the browser has it; a timeout covers the rest s._settle = setTimeout(() => settle(s), smooth ? 700 : 50);}function settle(s) { clearTimeout(s._settle); s.removeAttribute('data-autoscrolling'); measure(s);}/** Recomputes what lies beyond each edge and the follow flag. */function measure(s) { const { viewport } = s._parts; if (!viewport) return; const threshold = num(s, 'threshold', 48); const start = viewport.scrollTop > 1; const end = fromEnd(viewport) > 1; const tokens = [start && 'start', end && 'end'].filter(Boolean).join(' '); if (tokens) s.setAttribute('data-scrollable', tokens); else s.removeAttribute('data-scrollable'); s.querySelectorAll('.session-scroll-button').forEach((b) => { const active = b.dataset.to === 'start' ? start : end; b.dataset.active = String(active); b.inert = !active; }); s._height = viewport.scrollHeight; // only the reader's own scrolling moves the follow flag if (!s.hasAttribute('data-autoscrolling')) { // room reserved under an anchored turn is not a live edge to follow const reserved = parseFloat(s._parts.content.style.paddingBlockEnd) > 0; const stick = fromEnd(viewport) <= threshold && !reserved; setStick(s, stick); } track(s);}function setStick(s, stick) { s.toggleAttribute('data-stick', stick); const name = s.dataset.stateName; if (name === 'streaming') return; // streaming stays the named state const next = stick ? 'default' : 'detached'; if (name !== next) { s.dataset.stateName = next; s._stateConfig = {}; }}/** Keeps a target position while rows settle - see onResize. */function hold(s, target, ms) { s._opening = target; clearTimeout(s._holdTimer); s._holdTimer = setTimeout(() => { s._opening = null; }, ms);}/** * Room under the latest anchored turn: a short reply leaves nothing to * scroll into, so the turn could never reach the top. The content's end * padding is exactly what is missing - it shrinks to 0 as the reply grows * past the window. */function anchorSpace(s) { const a = s._anchor; const { viewport, content } = s._parts; if (!a || !content.contains(a)) { if (content.style.paddingBlockEnd) content.style.paddingBlockEnd = ''; s._anchor = null; return; } const pad = parseFloat(content.style.paddingBlockEnd) || 0; const vpPad = parseFloat(getComputedStyle(viewport).paddingBlockEnd) || 0; // layout offsets, not rects: a row rising in (data-animate) is translated const below = content.offsetTop + content.offsetHeight - pad - a.offsetTop; const need = Math.max(0, Math.round(viewport.clientHeight - vpPad - num(s, 'peek', 48) - below)); if (need !== Math.round(pad)) content.style.paddingBlockEnd = need ? `${need}px` : ''; // measure, don't assume: top up whatever the real scroll range still lacks if (need) { const short = anchorTop(s, a) - (viewport.scrollHeight - viewport.clientHeight); if (short > 0) content.style.paddingBlockEnd = `${need + Math.ceil(short)}px`; }}/** Where an anchored item settles: near the top, a peek of the previous one. */const anchorTop = (s, item) => item.offsetTop - num(s, 'peek', 48);// -- Imperative scrolling ----------------------------------------------------function scrollToEnd(s, { smooth = true } = {}) { s.setAttribute('data-stick', ''); scrollViewport(s, s._parts.viewport.scrollHeight, smooth);}function scrollToStart(s, { smooth = true } = {}) { s.removeAttribute('data-stick'); scrollViewport(s, 0, smooth);}function scrollToMessage(s, id, { smooth = true } = {}) { const item = s._parts.content.querySelector(`.session-item[data-message-id="${CSS.escape(id)}"]`); if (!item) return false; s.removeAttribute('data-stick'); scrollViewport(s, anchorTop(s, item), smooth); // rows it passes may only now render at their real size: keep aiming at // the message for a moment (the reader's own scroll cancels the hold) hold(s, () => anchorTop(s, item), smooth ? 900 : 400); return true;}/** * UI side of setState. 'default' re-engages following and scrolls to the * end (ends streaming); 'detached' lets go - `{ to: 'start' | messageId }` * scrolls there; 'streaming' marks the log busy and follows the growing * reply while the reader is at the end. */function triggerStateChange(s, stateName, config) { const { content } = s._parts; if (stateName === 'streaming') content.setAttribute('aria-busy', 'true'); else content.removeAttribute('aria-busy'); switch (stateName) { case 'default': scrollToEnd(s, { smooth: config.smooth !== false }); break; case 'detached': s.removeAttribute('data-stick'); if (config.to === 'start') scrollToStart(s); else if (typeof config.to === 'string') scrollToMessage(s, config.to); break; case 'streaming': if (s.hasAttribute('data-stick')) scrollToEnd(s, { smooth: false }); break; }}/** Registry-level API; pass the .session element explicitly. Unknown names throw. */export const sessionApi = { setState(s, stateName, config = {}) { if (!sessionStates.includes(stateName)) { throw new Error(`session: unknown state "${stateName}" (supported: ${sessionStates.join(', ')})`); } s.dataset.stateName = stateName; s._stateConfig = config; triggerStateChange(s, stateName, config); }, getState(s) { return { name: s.dataset.stateName || 'default', config: s._stateConfig ?? {} }; },};df$.sessionApi = sessionApi;df$.sessionStates = sessionStates;// -- Reacting to the transcript changing ------------------------------------/** * Items added: a prepend (history) keeps the reader's place; an append * follows the live edge when the reader is there - or anchors the turn * near the top when it carries data-anchor. */function onItems(s, records) { const { viewport } = s._parts; const before = s._height ?? viewport.scrollHeight; let prepended = false; let appended = false; let anchored = null; for (const r of records) { if (!r.addedNodes.length) continue; // nothing after the insertion = an append; nothing before it = a prepend if (r.nextSibling === null) { appended = true; for (const node of r.addedNodes) if (node.nodeType === 1 && node.matches('.session-item[data-anchor]')) anchored = node; } else if (r.previousSibling === null) prepended = true; } if (prepended && !appended) { // history above: shift by exactly what was added, the visible row stays // put. Off-screen rows skip rendering (content-visibility) and count at // an estimate - render the new ones once so the shift is their real // height; 'auto' then remembers it, so nothing moves later. const added = records.flatMap((r) => [...r.addedNodes]).filter((n) => n.nodeType === 1); added.forEach((n) => { n.style.contentVisibility = 'visible'; }); s.setAttribute('data-autoscrolling', ''); viewport.scrollTop += viewport.scrollHeight - before; added.forEach((n) => { n.style.contentVisibility = ''; }); settle(s); return; } if (anchored) { s._anchor = anchored; anchorSpace(s); s.removeAttribute('data-stick'); scrollViewport(s, anchorTop(s, anchored), true); // rows above may still settle to their real height - keep aiming at the turn hold(s, () => anchorTop(s, anchored), 900); if (s.dataset.stateName !== 'streaming') { s.dataset.stateName = 'detached'; s._stateConfig = {}; } return; } if (appended && s.hasAttribute('data-stick')) scrollViewport(s, viewport.scrollHeight, s.dataset.stateName !== 'streaming'); else measure(s);}/** Content grew in place (a streaming reply): stay on the live edge. */function onResize(s) { const { viewport } = s._parts; anchorSpace(s); // opening at the start / an anchor: rows above render at their real size // after the first frames - hold the opening position until the reader moves if (s._opening) { s.setAttribute('data-autoscrolling', ''); viewport.scrollTop = s._opening(); settle(s); return; } if (s.hasAttribute('data-stick') && fromEnd(viewport) > 1) { s.setAttribute('data-autoscrolling', ''); viewport.scrollTop = viewport.scrollHeight; settle(s); } else measure(s);}// -- Visibility tracking (data-track: pay only when asked) -------------------function track(s) { if (!s.hasAttribute('data-track')) return; const { viewport, content } = s._parts; const top = viewport.getBoundingClientRect().top; const bottom = top + viewport.clientHeight; const items = Array.from(content.querySelectorAll(':scope > .session-item')); const visible = items.filter((it) => { const r = it.getBoundingClientRect(); return r.bottom > top && r.top < bottom; }); // the current turn: the last anchor that has reached the upper third const line = top + viewport.clientHeight / 3; let current = null; for (const it of items) { if (!it.hasAttribute('data-anchor')) continue; if (it.getBoundingClientRect().top <= line) current = it; else break; } current ??= items.find((it) => it.hasAttribute('data-anchor')) ?? null; const ids = visible.map((it) => it.dataset.messageId).filter(Boolean); const currentId = current?.dataset.messageId ?? null; if (currentId === s._currentId && ids.join() === s._visibleIds) return; s._currentId = currentId; s._visibleIds = ids.join(); items.forEach((it) => it.toggleAttribute('data-current', it === current)); s.dispatchEvent(new CustomEvent('session-visibility', { bubbles: true, detail: { currentAnchorId: currentId, visibleMessageIds: ids } }));}// -- Drop target (data-drop) ----------------------------------------------------function bindDrop(s) { const hasFiles = (e) => [...(e.dataTransfer?.types ?? [])].includes('Files'); if (!s.dataset.dropLabel) s.dataset.dropLabel = 'Drop files to attach'; const accept = (s.dataset.dropAccept || '').split(',').map((a) => a.trim()).filter(Boolean); const ok = (file) => !accept.length || accept.some((a) => a.endsWith('/*') ? file.type.startsWith(a.slice(0, -1)) : a.startsWith('.') ? file.name.toLowerCase().endsWith(a.toLowerCase()) : file.type === a); s.addEventListener('dragenter', (e) => { if (hasFiles(e)) { e.preventDefault(); s.setAttribute('data-drop-active', ''); } }); s.addEventListener('dragover', (e) => { if (hasFiles(e)) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; } }); s.addEventListener('dragleave', (e) => { if (!s.contains(e.relatedTarget)) s.removeAttribute('data-drop-active'); }); s.addEventListener('drop', (e) => { if (!hasFiles(e)) return; e.preventDefault(); s.removeAttribute('data-drop-active'); const files = [...e.dataTransfer.files].filter(ok); if (files.length) s.dispatchEvent(new CustomEvent('session-drop', { bubbles: true, detail: { files } })); });}/** Back to the live edge: re-engage following (a streaming reply stays streaming). */function follow(s, options = {}) { if (s.dataset.stateName === 'streaming') scrollToEnd(s, options); else sessionApi.setState(s, 'default', options);}// -- init ---------------------------------------------------------------------function init() { document.querySelectorAll('.session:not([data-init])').forEach((s) => { const p = parts(s); if (!p.viewport || !p.content) return; // not a session yet s.dataset.init = ''; s._parts = p; const { viewport, content } = p; // accessible defaults, unless authored if (!viewport.hasAttribute('role')) viewport.setAttribute('role', 'region'); if (!viewport.hasAttribute('aria-label')) viewport.setAttribute('aria-label', 'Messages'); if (!viewport.hasAttribute('tabindex')) viewport.tabIndex = 0; if (!content.hasAttribute('role')) content.setAttribute('role', 'log'); if (!content.hasAttribute('aria-relevant')) content.setAttribute('aria-relevant', 'additions'); viewport.addEventListener('scroll', () => { if (s.hasAttribute('data-autoscrolling')) return track(s); s._opening = null; // the reader moved - the opening position is theirs now measure(s); }, { passive: true }); // any reader input ends the opening hold too ['wheel', 'touchstart', 'keydown', 'pointerdown'].forEach((type) => viewport.addEventListener(type, () => { s._opening = null; }, { passive: true })); viewport.addEventListener('scrollend', () => settle(s)); new MutationObserver((records) => onItems(s, records)).observe(content, { childList: true }); new ResizeObserver(() => onResize(s)).observe(content); new ResizeObserver(() => measure(s)).observe(viewport); s.querySelectorAll('.session-scroll-button').forEach((b) => { b.addEventListener('click', () => (b.dataset.to === 'start' ? scrollToStart(s) : follow(s))); }); if (s.hasAttribute('data-drop')) bindDrop(s); s.api = { setState: (stateName, config) => sessionApi.setState(s, stateName, config), getState: () => sessionApi.getState(s), }; // the opening position - applied once, before anyone reads the thread s.setAttribute('data-pending-scroll', ''); s.dataset.stateName = 'default'; const where = s.dataset.defaultPosition || 'end'; const last = [...content.querySelectorAll(':scope > .session-item[data-anchor]')].pop(); if (where === 'start') s._opening = () => 0; else if (where === 'last-anchor' && last) s._opening = () => anchorTop(s, last); if (s._opening) { scrollViewport(s, s._opening(), false); // rows settle within the first frames; after that the position is free hold(s, s._opening, 1000); } else { s.setAttribute('data-stick', ''); scrollViewport(s, viewport.scrollHeight, false); } s.removeAttribute('data-pending-scroll'); });}// -- df$.shadcn.session: the imperative surface ---------------------------------/** Wraps content in a .session-item (unless it is one) with an id / anchor. */function toItem(content, { id, anchor } = {}) { let node = content; if (typeof content === 'string') node = document.createRange().createContextualFragment(content); const single = node instanceof Element && node.classList.contains('session-item'); const item = single ? node : document.createElement('div'); if (!single) { item.className = 'session-item'; item.append(node); } if (id) item.dataset.messageId = id; if (anchor) item.setAttribute('data-anchor', ''); return item;}df$.session = { /** Adds a message at the end; follows (or anchors) as the session decides. */ append(t, content, options) { const s = resolve(t); const item = toItem(content, options); s?._parts?.content.append(item); return item; }, /** Adds older messages at the start; the reader's place is kept. */ prepend(t, content, options) { const s = resolve(t); const items = (Array.isArray(content) ? content : [content]).map((c) => toItem(c, options)); s?._parts?.content.prepend(...items); return items; }, scrollToEnd: (t, o) => { const s = resolve(t); if (s) follow(s, o); }, scrollToStart: (t, o) => { const s = resolve(t); if (s) scrollToStart(s, o); }, scrollToMessage: (t, id, o) => { const s = resolve(t); return s ? scrollToMessage(s, id, o) : false; }, isAtEnd: (t) => { const s = resolve(t); return !!s && fromEnd(s._parts.viewport) <= num(s, 'threshold', 48); },};init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub