Frontend Split Panel Pattern
Read rules/frontend.md first for core architecture, routing, and project conventions.
The src/features/applications/detail/documents/view2.tsx is the primary reference implementation for this pattern.
When to Use This Pattern
Use when:
- A list and its detail view must be visible simultaneously (master-detail)
- Switching items in the list should update the detail pane without page scroll
- A comment/chat section needs a pinned editor at the bottom with a scrollable message list above it (Slack/GitHub style)
- The layout must fill the viewport height without causing page scroll
Do NOT use when:
- Content should flow naturally (use a normal page layout instead)
- The detail is rarely needed alongside the list
- Mobile is the primary target (this pattern stacks vertically on mobile)
Visual Structure
┌─────────────────────────────────────────────────────┐
│ Top bar (status, toolbar) [flex-shrink-0] │
├──────────────────────┬──────────────────────────────┤
│ Left panel (55%) │ Right panel (45%) │
│ ┌────────────────┐ │ ┌──────────────────────────┐│
│ │ Filter bar │ │ │ Tab bar ││
│ │ [flex-shrink-0]│ │ │ [flex-shrink-0] ││
│ ├────────────────┤ │ ├──────────────────────────┤│
│ │ List │ │ │ Details tab ──scrolls── ││
│ │ ──scrolls── │ │ │ OR ││
│ │ │ │ │ Comments tab ││
│ │ │ │ │ Comment list ─scrolls─ ││
│ │ │ │ │ ────────────────────────││
│ │ │ │ │ Editor [pinned] ││
│ └────────────────┘ │ └──────────────────────────┘│
└──────────────────────┴──────────────────────────────┘
Critical CSS Rules
The split panel relies on a precise flex chain. Every level must be correct — a missing property at any level breaks containment and causes the page to grow infinitely.
Rule 1 — Outer container must have a fixed height
<div className="flex flex-col overflow-hidden rounded-xl border lg:h-[calc(100svh-230px)]">
lg:h-[...]— fixes the total height on desktop. Adjust the offset (230px) to account for the page chrome above this component (header, breadcrumb, page title, tabs, etc.).overflow-hidden— clips anything that escapes.- On mobile there is no fixed height; the layout stacks vertically.
Rule 2 — Split panels container fills remaining height
<div className="flex flex-col overflow-hidden min-h-0 lg:flex-1 lg:flex-row">
lg:flex-1— fills height after the top bar.overflow-hidden— required.min-h-0— allows flex children to shrink below their content size (critical for flex-col chains).
Rule 3 — Each panel MUST have overflow-hidden
{/* Left panel */}
<div className="flex w-full flex-col border-b lg:w-[55%] lg:flex-shrink-0 lg:overflow-hidden lg:border-b-0 lg:border-r">
{/* Right panel */}
<div className="flex flex-col overflow-hidden lg:flex-1 lg:min-h-0">
This is the most commonly missed rule. Without overflow-hidden on each panel, the panel's content escapes the flex row's height and causes the page to grow infinitely. The panel's content height must be clipped so that inner flex-1 + overflow-y-auto children can scroll correctly.
Rule 4 — Scrollable areas use flex-1 + overflow-y-auto
{/* Left panel scrollable list */}
<div className="max-h-[350px] overflow-y-auto lg:max-h-none lg:flex-1">
<ListComponent />
</div>
- Mobile:
max-h-[350px]caps the height so the right panel is reachable. - Desktop:
lg:max-h-none lg:flex-1fills remaining panel height and scrolls.
Rule 5 — Do NOT use no-scrollbar
The global CSS (src/styles/index.css) already applies scrollbar-width: thin and scrollbar-color: var(--border) transparent to every element. This gives the thin, muted scrollbar used by Slack and GitHub.
Using no-scrollbar hides this and makes scroll state invisible to the user. Only use no-scrollbar for decorative or non-primary scroll containers.
Slack/GitHub Comment Panel Pattern
The right panel commonly hosts a tabbed view with a Details tab and a Comments tab. The comments tab uses the pinned-editor pattern.
SlotDetailPanel structure
<Tabs className="flex flex-1 flex-col overflow-hidden min-h-0">
{/* Tab bar — always visible */}
<div className="flex-shrink-0 border-b px-3 pt-2">
<TabsList>...</TabsList>
</div>
{/* Details tab — scrolls freely */}
<TabsContent value="details" className="flex-1 overflow-y-auto mt-0 p-0">
<DetailContent />
</TabsContent>
{/* Comments tab — Slack/GitHub style */}
<TabsContent value="comments" className="flex flex-1 flex-col overflow-hidden min-h-0 mt-0">
<CommentPanel />
</TabsContent>
</Tabs>
Comment panel (Slack/GitHub style)
{/* Outer */}
<div className="flex flex-1 flex-col overflow-hidden min-h-0">
{/* Filter bar + sort toggle + view-all link — pinned at top */}
<div className="flex flex-shrink-0 items-center justify-between gap-2 border-b px-3 py-1.5">
<div className="flex items-center gap-1.5">
<TypeFilterPills /> {/* All / External / Internal */}
<SortToggle /> {/* ↓ newest-first | ↑ oldest-first (newest-last) */}
</div>
<ViewAllLink /> {/* always visible */}
</div>
{/* Comment list — scrollable */}
<div ref={scrollRef} className="flex flex-1 flex-col overflow-y-auto px-3 pt-3">
{/* Load older — top in newest-first mode */}
{!sortAsc && pageData?.hasNext && <LoadOlderButton icon={ChevronUp} />}
<CommentList />
</div>
{/* Load older — bottom in newest-last mode */}
{sortAsc && pageData?.hasNext && <LoadOlderButton icon={ChevronDown} />}
{/* Editor — pinned at bottom */}
{canWrite && (
<div className="flex-shrink-0 border-t p-3">
<CommentEditor />
</div>
)}
</div>
Key points:
- Filter bar and editor are both
flex-shrink-0so they never scroll away - Comment list is
flex-1 overflow-y-auto— fills remaining height and scrolls - The editor is always reachable without scrolling, exactly like Slack
viewAllLinkis always visible in the filter bar (not hidden behindcanWrite)
Sort order & auto-scroll
Default: newest-last (sortAsc = true) — oldest at top, newest at bottom, auto-scrolls to bottom. This is the natural chat direction (Slack, iMessage).
Newest-first (sortAsc = false) — newest at top, auto-scrolls to top.
// Default — newest-last unless user previously chose desc
const [sortAsc, setSortAsc] = useState<boolean>(() => {
try { return localStorage.getItem('ams.docs.comments.sort') !== 'desc' } catch { return true }
})
const scrollRef = useRef<HTMLDivElement>(null)
// Sort display: server always returns newest-first; reverse for newest-last
const displayedComments = sortAsc ? [...visibleComments].reverse() : visibleComments
// Auto-scroll to newest on: initial load, new comment submitted, slot change, sort toggle.
// Does NOT scroll when loading older pages (page > 0) — user is browsing history.
useEffect(() => {
if (page > 0) return
const el = scrollRef.current
if (!el) return
requestAnimationFrame(() => {
el.scrollTop = sortAsc ? el.scrollHeight : 0
})
}, [accumulated, sortAsc]) // eslint-disable-line react-hooks/exhaustive-deps
Sort toggle UI — two arrow buttons in a bordered pill, active arrow is text-foreground, inactive is text-muted-foreground/40:
<div className="flex items-center rounded-md border p-0.5">
{/* ↓ newest-first (desc) */}
<button onClick={() => toggleSort(false)} title="Newest first"
className={cn('rounded p-0.5 transition-colors',
!sortAsc ? 'text-foreground' : 'text-muted-foreground/40 hover:text-muted-foreground')}>
<ArrowDown className="h-3 w-3" />
</button>
{/* ↑ newest-last (asc, default) */}
<button onClick={() => toggleSort(true)} title="Oldest first"
className={cn('rounded p-0.5 transition-colors',
sortAsc ? 'text-foreground' : 'text-muted-foreground/40 hover:text-muted-foreground')}>
<ArrowUp className="h-3 w-3" />
</button>
</div>
"Load older" button placement depends on sort mode — it always appears at the end where older content would load:
- Newest-first (
↓): older content loads below → button at top of list (ChevronUpicon) - Newest-last (
↑): older content loads above (after reverse) → button at bottom of list, outside the scrollable div, above the editor (ChevronDownicon)
Persist preference:
const toggleSort = (asc: boolean) => {
setSortAsc(asc)
try { localStorage.setItem('ams.docs.comments.sort', asc ? 'asc' : 'desc') } catch {}
}
Complete Flex Chain Reference
Outer container flex-col overflow-hidden lg:h-[calc(100svh-Npx)]
Top bar flex-shrink-0
Split panels flex-col overflow-hidden min-h-0 lg:flex-1 lg:flex-row
Left panel flex-col lg:overflow-hidden lg:w-[55%] lg:flex-shrink-0
Filter bar flex-shrink-0
List area overflow-y-auto lg:flex-1
Right panel flex-col overflow-hidden lg:flex-1 lg:min-h-0
Tabs flex-col overflow-hidden min-h-0 flex-1
Tab bar flex-shrink-0
Details tab overflow-y-auto flex-1
Comments tab flex-col overflow-hidden min-h-0 flex-1
Filter bar flex-shrink-0
Comment list overflow-y-auto flex-1
Editor flex-shrink-0
Mobile Behaviour
On mobile (< lg), the split panel stacks vertically:
- Left panel takes full width, capped at
max-h-[350px]with scroll - Right panel renders below with
min-h-[400px] - No fixed viewport height — the page scrolls naturally
This requires no extra work as long as the lg: prefix is used on all desktop-specific classes.
Offset Calculation for lg:h-[calc(100svh-Npx)]
The N value must account for all chrome above the component:
| Element | Typical height |
|---|---|
| App header | ~56px |
| Page breadcrumb | ~36px |
| Page title + description | ~60px |
| Section tabs (if any) | ~44px |
| Padding/gaps | ~34px |
| Total (example) | ~230px |
Measure the actual offset in DevTools if the panel appears cut off or has excess whitespace.
Reference Implementation
- view2.tsx —
src/features/applications/detail/documents/view2.tsx - SlotDetailPanel —
src/features/applications/detail/documents/slot-detail-panel.tsx - CommentTab (panel layout) —
src/features/applications/detail/documents/components/comment-tab.tsx