Skip to content

ChatGPT-style app shell: sidebar, header and chat

A chat product is the transcript plus the chrome around it: a column of conversations with a new-chat button and a search field, a header with the title and a model picker, and the chat in the rest of the window. aparté ships that chrome as three recipes and one element — enough to build the whole page on the library alone, and nothing that owns your data.

Width Open in a tab
The shell: a sidebar, a header, the chat. Narrow the frame to 375 and the sidebar becomes a drawer behind the header's toggle (the demo sets the breakpoint to 30rem; the shell's own default is 48rem, wider than this frame).
<!-- Three pieces: the grid and the header are recipes, the sidebar is an element
because it has behaviour. The toggle in the header needs no script — the sidebar
listens for `data-aparte-sidebar-toggle` itself, and shows under 48rem.
breakpoint="30rem" only because this frame is narrower than the shell's own 48rem:
the column shows here, and at a phone's width it is a drawer behind the toggle. -->
<div class="aparte-app-shell" style="height: 24rem">
<aparte-sidebar breakpoint="30rem">
<div class="aparte-sidebar__header">
<span class="aparte-sidebar__brand">aparté</span>
<button class="aparte-btn aparte-btn--icon aparte-btn--sm" type="button" aria-label="New chat">
<aparte-icon name="edit"></aparte-icon>
</button>
</div>
<div class="aparte-sidebar__search aparte-field-group">
<input class="aparte-field aparte-field--sm" type="search" placeholder="Search conversations" data-aparte-sidebar-search>
</div>
<div class="aparte-sidebar__body">
<aparte-conversation-list active-id="c1"></aparte-conversation-list>
</div>
<div class="aparte-sidebar__footer">
<span class="aparte-avatar aparte-avatar--sm">P</span> Paul
</div>
</aparte-sidebar>
<header class="aparte-app-header">
<button class="aparte-btn aparte-btn--icon aparte-app-header__toggle" type="button" aria-label="Toggle the sidebar" data-aparte-sidebar-toggle><aparte-icon name="menu"></aparte-icon></button>
<span class="aparte-app-header__title">Deploy checklist</span>
<div class="aparte-app-header__actions"><span class="aparte-tag">gpt-4.1</span></div>
</header>
<main class="aparte-app-shell__main">
<aparte-chat>
<aparte-chat-viewport>
<aparte-chat-bubble message-id="u1" data-role="user" content="Where does the sidebar's state live?"></aparte-chat-bubble>
<aparte-chat-bubble message-id="a1" data-role="assistant" name="Assistant" content="On the element: collapsed is an attribute you can set, read and persist."></aparte-chat-bubble>
</aparte-chat-viewport>
<aparte-composer>
<div class="aparte-composer-shell">
<div class="aparte-composer-row">
<aparte-composer-input></aparte-composer-input>
<aparte-composer-send></aparte-composer-send>
</div>
</div>
</aparte-composer>
</aparte-chat>
</main>
</div>
<script>
const day = 864e5;
document.querySelector('aparte-conversation-list').conversations = [
{ id: 'c1', title: 'Deploy checklist', updatedAt: Date.now() },
{ id: 'c2', title: 'Rename the segment types', updatedAt: Date.now() - day },
{ id: 'c3', title: 'Tokens, not selectors', updatedAt: Date.now() - 4 * day },
{ id: 'c4', title: 'The first release', updatedAt: Date.now() - 60 * day },
];
</script>
PieceWhat it isWhy
.aparte-app-shella recipe: a two-column, two-row grid — sidebar beside, header above, __main in the resta grid has no behaviour
.aparte-app-headera recipe: a toggle (shown under 48rem), a title, an __actions zone pushed to the enda header has no behaviour either
<aparte-sidebar>an element wearing the .aparte-sidebar recipeit collapses, it becomes a drawer, and its search filters the list — three behaviours

That split is the rule the whole kit follows: a recipe draws, an element exists only where there is something to do. The UI kit has every recipe with its markup, one family per page; the element page has the sidebar’s attributes and events.

Collapse. collapsed is an attribute — set it, read it, persist it. Any element carrying data-aparte-sidebar-toggle toggles the nearest sidebar (or the one whose id the attribute names), so the header’s hamburger needs no script, and the sidebar keeps aria-expanded and aria-controls on it in step. aparte-sidebar-toggle fires on every change, whoever caused it (AparteSidebarToggleDetail: { collapsed, drawer }); the AparteSidebar class exposes open(), close(), toggle() and filter(query).

Drawer. Under 48rem of window (breakpoint, a length or none) the sidebar leaves the grid and slides over the page: open, it draws a scrim, closes on Escape or a click outside, and hands the focus back to the control that opened it. It is the same element in the same place, positioned fixed — no portal, which is what a sidebar can do and a dialog cannot.

Search. An input carrying data-aparte-sidebar-search filters the conversation list below it by title as the user types, case- and accent-insensitive; a date group with nothing left hides with its rows. Client-side, on the titles the list already holds — an index over message bodies belongs to your storage adapter.

A builder is this shell with one more piece: the sidebar, then the chat, then a seam, then whatever the chat is producing. <aparte-split> goes in __main, and a pane contains a chat; a chat never contains a split — the shell’s rule is a direct-child selector, so a split nested inside an <aparte-chat> would sit between the transcript and the composer, where nothing sizes it.

<div class="aparte-app-shell">
<aparte-sidebar></aparte-sidebar>
<header class="aparte-app-header">
<button class="aparte-btn aparte-btn--icon aparte-app-header__toggle" type="button"
aria-label="Toggle the sidebar" data-aparte-sidebar-toggle><aparte-icon name="menu"></aparte-icon></button>
<span class="aparte-app-header__title">Landing page</span>
<div class="aparte-app-header__actions">
<!-- Under the split's breakpoint these switch the visible pane; above it they
change nothing on screen. The `pane` attribute still moves, so hide them with
the same media query rather than leaving two controls that look dead. -->
<button class="aparte-btn aparte-btn--sm aparte-btn--surface" type="button" data-aparte-split-pane="start">Chat</button>
<button class="aparte-btn aparte-btn--sm aparte-btn--surface" type="button" data-aparte-split-pane="end">Preview</button>
</div>
</header>
<main class="aparte-app-shell__main">
<aparte-split position="38">
<aparte-chat>
<aparte-chat-viewport></aparte-chat-viewport>
<aparte-composer></aparte-composer>
</aparte-chat>
<section class="aparte-split__pane">your pane</section>
</aparte-split>
</main>
</div>

No height on the split and none on the chat: the shell’s grid gives __main its row, the sheet gives a split or a chat sitting directly in __main the full height of it, and the chat inside a pane fills the pane. The seam, the drag, the arrow keys, the breakpoint and the persistence event are all on the layout guide.

A settings hub is a modal, and the kit’s modal is the browser’s own <dialog> wearing .aparte-dialog: showModal() gives the top layer, the focus trap, Escape and the focus return, and the recipe gives the box — header, a body that scrolls, footer, the backdrop, a full-screen sheet under 30rem. Three attributes wire it without a line of script: a control with data-aparte-dialog-open="id" opens the dialog it names, data-aparte-dialog-close inside one closes it (its value becomes returnValue, read in the native close event), and a click on the backdrop closes it unless the dialog carries data-aparte-dialog-static.

Width Open in a tab
The kit's dialog on a native <dialog>: open it, tab through it, press Escape or click the backdrop.
<!-- The browser's <dialog> wearing the kit's recipe. No script: the button's
`data-aparte-dialog-open` names the dialog, `data-aparte-dialog-close` closes it,
the backdrop click closes it too. -->
<button class="aparte-btn aparte-btn--surface" type="button" data-aparte-dialog-open="settings">Open settings</button>
<p class="aparte-field-hint" id="settings-result">Nothing saved yet.</p>
<dialog class="aparte-dialog" id="settings" aria-labelledby="settings-title">
<div class="aparte-dialog__header">
<h2 class="aparte-dialog__title" id="settings-title">Settings</h2>
<button class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-dialog__close" type="button" aria-label="Close" data-aparte-dialog-close>
<aparte-icon name="close"></aparte-icon>
</button>
</div>
<div class="aparte-dialog__body">
<div class="aparte-tabs aparte-tabs--underline" role="tablist" aria-label="Settings section">
<button class="aparte-tabs__tab" role="tab" type="button" id="tab-model" aria-controls="panel-model" aria-selected="true" tabindex="0">Model</button>
<button class="aparte-tabs__tab" role="tab" type="button" id="tab-appearance" aria-controls="panel-appearance" aria-selected="false" tabindex="-1">Appearance</button>
</div>
<div class="aparte-tabs__panel" role="tabpanel" id="panel-model" aria-labelledby="tab-model">
<label class="aparte-field-label" for="endpoint">Endpoint</label>
<input class="aparte-field" id="endpoint" value="http://localhost:11434/v1">
<p class="aparte-field-hint">Any OpenAI-compatible server.</p>
</div>
<div class="aparte-tabs__panel" role="tabpanel" id="panel-appearance" aria-labelledby="tab-appearance" hidden>
<label class="aparte-field-label" for="accent">Accent</label>
<input class="aparte-field" id="accent" type="color" value="#b8860b">
</div>
</div>
<div class="aparte-dialog__footer">
<button class="aparte-btn aparte-btn--ghost" type="button" data-aparte-dialog-close>Cancel</button>
<button class="aparte-btn aparte-btn--primary aparte-btn--solid" type="button" data-aparte-dialog-close="saved">Save</button>
</div>
</dialog>
<script>
// The native close event carries the value of the control that closed it.
document.getElementById('settings').addEventListener('close', (e) => {
document.getElementById('settings-result').textContent =
e.target.returnValue === 'saved' ? 'Saved.' : 'Closed without saving.';
});
// The tabs. The recipe draws the row; who is selected, which panel shows and where
// the one tab stop sits are the app's - a tablist whose arrows do nothing announces
// more than plain buttons and does less. This is the whole handler.
const tabs = [...document.querySelectorAll('#settings [role="tab"]')];
const showTab = (tab) => {
for (const t of tabs) {
const on = t === tab;
t.setAttribute('aria-selected', String(on));
t.tabIndex = on ? 0 : -1;
document.getElementById(t.getAttribute('aria-controls')).hidden = !on;
}
tab.focus();
};
tabs.forEach((tab, i) => {
tab.addEventListener('click', () => showTab(tab));
tab.addEventListener('keydown', (e) => {
const step = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0;
if (step) { e.preventDefault(); showTab(tabs[(i + step + tabs.length) % tabs.length]); }
});
});
</script>

The tabs of a settings hub are .aparte-tabs; what goes in each panel is yours. The wiring is installed when @aparte/core is imported; a page built before that import calls installDialogTriggersOnce() itself, once.

The sidebar renders what you put in it, and the list is the conversation list you already wire to a manager — see persistence. The header’s title is the active conversation’s, the new-chat button calls manager.createNew(), and the model picker is @aparte/plugin-model-selector in __actions. What stays yours: routing, authentication, the storage adapter, and the contents of a settings panel.

TokenDefaultMoves
--aparte-sidebar-width260pxthe column, and the drawer
--aparte-sidebar-bg--aparte-surface-2the column’s ground
--aparte-app-header-height48pxthe header row’s minimum height
--aparte-scrimrgba(0, 0, 0, 0.35)the shade behind an open drawer

The breakpoint is 48rem, written in the sheets rather than a token: a media query cannot read a custom property.