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.
<!-- 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>The three pieces
Section titled “The three pieces”| Piece | What it is | Why |
|---|---|---|
.aparte-app-shell | a recipe: a two-column, two-row grid — sidebar beside, header above, __main in the rest | a grid has no behaviour |
.aparte-app-header | a recipe: a toggle (shown under 48rem), a title, an __actions zone pushed to the end | a header has no behaviour either |
<aparte-sidebar> | an element wearing the .aparte-sidebar recipe | it 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.
What the sidebar does
Section titled “What the sidebar does”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 pane beside the chat
Section titled “A pane beside the chat”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.
Settings, in a dialog
Section titled “Settings, in a dialog”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.
<!-- 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.
Wiring the data
Section titled “Wiring the data”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.
The knobs
Section titled “The knobs”| Token | Default | Moves |
|---|---|---|
--aparte-sidebar-width | 260px | the column, and the drawer |
--aparte-sidebar-bg | --aparte-surface-2 | the column’s ground |
--aparte-app-header-height | 48px | the header row’s minimum height |
--aparte-scrim | rgba(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.