ADR-004: 420px Scroll Anchor Offset
Status: Accepted Date: 2024 (during UI refactoring) Deciders: BrainDrive Team Tags: ux, ui, chat-interface, auto-scroll
Context
Chat interface needs auto-scroll behavior:
- New messages appear → scroll to show them
- Streaming responses → scroll as content grows
- User manually scrolls up (reading history) → don't interrupt
- User at bottom → keep at bottom as new content arrives
Problem discovered:
- Standard
scrollToBottom()hides last message below fold - User sees bottom of viewport, not content
- Poor UX: last message cut off, requires manual scroll to read
Requirements:
- Keep last message fully visible
- Don't scroll if user reading history
- Smooth auto-scroll during streaming
- Prevent scroll fighting (user vs programmatic)
Problem Statement
How much offset from bottom should auto-scroll target?
Specific issues:
scrollToBottom()→ content hidden below fold- Too small offset → still cuts off content
- Too large offset → doesn't feel "at bottom"
- Variable message heights (1 line vs long code blocks)
Goal: Find sweet spot where:
- Last message fully visible
- Feels natural (user perceives "at bottom")
- Works for various message sizes
Decision
Chosen approach: 420px offset from bottom
Implementation (ChatScrollManager.ts):
const SCROLL_ANCHOR_OFFSET = 420; // px from bottom
scrollToAnchor(behavior: ScrollBehavior = 'smooth'): void {
const scrollHeight = this.container.scrollHeight;
const targetScroll = scrollHeight - this.container.clientHeight - SCROLL_ANCHOR_OFFSET;
this.isProgrammaticScroll = true;
this.container.scrollTo({
top: Math.max(0, targetScroll),
behavior
});
setTimeout(() => this.isProgrammaticScroll = false, 100);
}
Rationale:
Why 420px specifically:
- Min visible last message: 64px (one-line message)
- Comfortable visible content: ~350px (2-3 messages or code block)
- Total: 64 + 350 ≈ 420px offset
Visual breakdown:
┌─────────────────────────┐
│ Scrollable content │
│ │
│ Message N-2 │
│ Message N-1 │ ← ~350px visible
│ Message N (latest) │ ← ~64px minimum
├─────────────────────────┤ ← Scroll anchor (420px from bottom)
│ │
│ (420px buffer) │
│ │
└─────────────────────────┘ ← Actual bottom
Consequences:
- Last message always fully visible
- User sees context (previous messages above)
- Feels "at bottom" without being at absolute bottom
- Works for both short and long messages
Consequences
Positive
- ✅ Last message never cut off
- ✅ Context visible (previous messages)
- ✅ Natural feel (doesn't look broken)
- ✅ Works with streaming (content grows, stays visible)
- ✅ Handles variable message heights
Negative
- ❌ Not at absolute bottom (small whitespace below)
- ❌ Magic number (not dynamically calculated)
- ❌ May need adjustment if UI styling changes
- ❌ Doesn't adapt to screen size (420px fixed)
Risks
- Message taller than 420px: Still partially cut off
- Mitigation: Rare case (would need 400+px message), acceptable
- Small screens: 420px is large portion of viewport
- Mitigation: Min height on chat container, responsive design
- Styling changes: Different message padding/margins
- Mitigation: Document as tunable constant, revisit if UI changes
Neutral
- Trade-off: Visible content vs feeling "at bottom"
- Could be made responsive (future enhancement)
Alternatives Considered
Alternative 1: Absolute scrollToBottom (0px offset)
Description: Scroll to absolute bottom (scrollTop = scrollHeight - clientHeight)
Pros:
- Simple implementation
- Truly "at bottom"
- No magic numbers
Cons:
- Last message cut off below fold (CRITICAL UX ISSUE)
- User can't see what was just sent
- Requires manual scroll to read response
Why rejected: Poor UX, defeats purpose of auto-scroll
Alternative 2: Dynamic offset based on last message height
Description: Calculate offset from last message's actual height
Pros:
- Adapts to content
- Always shows exactly last message
- No magic number
Cons:
- More complex (DOM measurement)
- Performance cost (layout thrashing)
- Race condition during streaming (height changes)
- Doesn't show context (previous messages)
Why rejected: Over-engineered, doesn't show context
Alternative 3: Scroll last message into view
Description: Use element.scrollIntoView({ block: 'end' })
Pros:
- Browser-native behavior
- Automatically handles variable heights
- No offset calculation needed
Cons:
- Less control over exact position
- Can jump awkwardly during streaming
- Doesn't guarantee context visibility
Why rejected: Less predictable, jumpy during streaming
Alternative 4: Responsive offset (based on viewport height)
Description: Offset = 30% of viewport height (dynamic)
Pros:
- Adapts to screen size
- Proportional feel
- Works on mobile and desktop
Cons:
- More complex calculation
- May be too large on large screens
- May be too small on small screens
- Harder to reason about
Why rejected: Added complexity, 420px works well enough
References
- src/domain/ui/ChatScrollManager.ts (implementation)
- src/collection-chat-view/CollectionChatViewShell.tsx (usage)
- Related: Data Quirk - Scroll state machine (isProgrammaticScroll)
- Related: UI_CONFIG.SCROLL_DEBOUNCE_DELAY (100ms)
Implementation Notes
File paths affected:
src/domain/ui/ChatScrollManager.ts- Main implementationsrc/constants.ts- Could extract SCROLL_ANCHOR_OFFSET here
Configuration:
// ChatScrollManager.ts
const SCROLL_ANCHOR_OFFSET = 420; // px from bottom
const NEAR_BOTTOM_THRESHOLD = 100; // px threshold for "near bottom" detection
State machine integration:
// Prevent infinite scroll loop
this.isProgrammaticScroll = true;
this.container.scrollTo({ top: targetScroll, behavior });
setTimeout(() => this.isProgrammaticScroll = false, 100);
When to scroll to anchor:
- New message received (user or AI)
- Streaming response chunk received
- User sends new message
- Conversation loaded (initial scroll)
When NOT to scroll:
- User manually scrolling (within grace period)
- User scrolled up to read history
- isNearBottom = false (user intentionally up)
Critical gotchas:
- Must clear
isProgrammaticScrollflag (100ms timeout) - Check
isNearBottombefore auto-scrolling - Grace period (300ms) prevents immediate re-scroll after user scroll
- Debounce scroll events (100ms) to reduce handler calls
Tuning guide: If last messages cut off:
- Increase SCROLL_ANCHOR_OFFSET (try 500px)
If too much whitespace below:
- Decrease SCROLL_ANCHOR_OFFSET (try 350px)
If scroll feels jumpy:
- Adjust SCROLL_DEBOUNCE_DELAY (try 150ms)
If scroll fights user:
- Increase grace period (try 500ms)
Migration path: None - this was initial decision during UI extraction
Rollback plan: Change to Alternative 3 (scrollIntoView) if offset proves problematic:
lastMessageElement.scrollIntoView({
behavior: 'smooth',
block: 'end'
});