Skip to main content

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-1 fills 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-0 so 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
  • viewAllLink is always visible in the filter bar (not hidden behind canWrite)

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 (ChevronUp icon)
  • Newest-last (): older content loads above (after reverse) → button at bottom of list, outside the scrollable div, above the editor (ChevronDown icon)

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:

ElementTypical 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.tsxsrc/features/applications/detail/documents/view2.tsx
  • SlotDetailPanelsrc/features/applications/detail/documents/slot-detail-panel.tsx
  • CommentTab (panel layout) — src/features/applications/detail/documents/components/comment-tab.tsx