Skip to content

Chat layouts: resizable split, full-width feed, side panel

An <aparte-chat> is a flex column — the transcript above, the composer below — and the transcript is the element that scrolls. That one fact decides every layout on this page: the chat needs a height to scroll inside, and the scrollbar sits wherever the chat’s right edge is.

<aparte-chat> is height: 100% by default, so it fills whatever you size. With nothing sized above it, it grows with its content and never scrolls — the most common first-minute question. Three ways to size it:

<!-- A box: the getting-started default. -->
<aparte-chat style="height: 600px"></aparte-chat>
/* Inside a flex column with a header — the shape every example app uses. */
.app { display: flex; flex-direction: column; height: 100dvh; }
.app aparte-chat {
flex: 1;
min-height: 0; /* lets the flex item shrink below its content, so it scrolls */
height: auto; /* the default 100% ignores the header and overflows the page */
}
/* The whole page, no header. */
html, body { height: 100%; margin: 0; }
aparte-chat { height: 100dvh; }

100dvh rather than 100vh: on a phone the dynamic unit follows the browser chrome as it hides and shows, so the composer stays on screen.

Fill the page — the scrollbar at the window’s edge

Section titled “Fill the page — the scrollbar at the window’s edge”

ChatGPT and Claude look like the page scrolls: the scrollbar runs down the window’s right edge and the messages sit in a centred column. They are not scrolling the page — their transcript is a full-width element, and the column is a max-width on the content inside it. aparté already does the second half: bubbles and the composer share one column — centred at --aparte-message-max-width (800px) on a container wider than that, and inset by the same --aparte-viewport-padding gutter on one narrower. So the recipe is only the first half — do not box the chat:

/* Not this — the chat is a 820px box, so its scrollbar sits 200px in from the edge
of a wide screen. */
.app { max-width: 820px; margin: 0 auto; }
/* This — the chat spans the page, the content centres itself, the scrollbar lands on
the window's edge. */
.app { display: flex; flex-direction: column; height: 100dvh; }
:root { --aparte-message-max-width: 48rem; } /* the column, if 800px is not yours */

Measured on the vanilla example at 1200px wide: the scroll surface’s right edge moved from 998px to 1200px and the bubbles stayed at 200–1000px. Nothing about scrolling changed — the transcript still owns it, so following a streaming reply, the scroll-to- bottom button and the “you scrolled up, we stop following” rule all keep working.

The scroll surface reserves its scrollbar gutter on both edges (scrollbar-gutter: stable both-edges), so the column does not shift by half a scrollbar the moment the first reply overflows.

Width Open in a tab
The column is a token on the content, not a box around the chat — so the transcript still spans the frame and the scrollbar stays on its edge.
<!-- The ChatGPT shape. Do NOT box the chat — let it span whatever sizes it — and let
`--aparte-message-max-width` draw the column: bubbles and the composer centre
themselves inside the full-width transcript, so the scrollbar lands on the outer
edge while the text stays readable. Boxing the chat instead moves the scrollbar in
with it. -->
<aparte-chat style="height: 26rem; --aparte-message-max-width: 32rem">
<aparte-chat-viewport>
<aparte-chat-bubble message-id="u1" data-role="user" content="My chat runs the full width of the page. How do I get the centred column?"></aparte-chat-bubble>
<aparte-chat-bubble message-id="a1" data-role="assistant" name="Assistant" content="You already have it: the column is a max-width on the content, not on the chat. Set --aparte-message-max-width and the bubbles centre themselves inside the full-width transcript."></aparte-chat-bubble>
<aparte-chat-bubble message-id="u2" data-role="user" content="And the scrollbar?"></aparte-chat-bubble>
<aparte-chat-bubble message-id="a2" data-role="assistant" name="Assistant" content="It stays on the transcript's own edge, which is the window's edge as long as nothing boxes the chat. Size the host; leave the chat alone."></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>

The composer over the transcript — the scrollbar runs the full height

Section titled “The composer over the transcript — the scrollbar runs the full height”

The recipe above puts the scrollbar on the window’s edge; its height still stops at the composer’s top, because the composer is a row below the transcript. ChatGPT and Claude do the other half too: their scroll surface owns the whole column and the composer floats over it, so the bar runs edge to edge vertically. That half is one attribute:

<aparte-chat overlay-composer></aparte-chat>
// React — the same switch on every wrapper (Vue, Svelte, Angular alike).
<AparteChat overlayComposer />

The viewport leaves the flow and spans the shell; the composer — and whatever else shares the bottom, an elicitation panel, an above-composer row — keeps flowing, bottom-anchored, painted over it. The viewport measures that stack and publishes --aparte-bottom-inset, and three readers keep everything clear of it: the transcript’s own bottom clearance, the spacer that tops your last message, and the scroll-to-bottom button, which floats just above the composer instead of behind it. When the draft grows a few lines under a reader pinned at the bottom, the inset is re-measured and the reader re-anchored in the same pass — the view never jumps.

Width Open in a tab
One attribute: the scrollbar runs past the composer to the chat's bottom edge, and the content clears the floating stack.
<!-- One attribute. The transcript's scroll surface takes the WHOLE column - watch the
scrollbar run past the composer to the bottom edge - and the composer floats over
it. The viewport measures the floating stack and publishes `--aparte-bottom-inset`,
so the last message, the spacer and the scroll button all clear it, and a composer
that grows re-anchors a reader pinned at the bottom in the same pass. -->
<aparte-chat overlay-composer style="height: 20rem">
<aparte-chat-viewport>
<aparte-chat-bubble message-id="u1" data-role="user" content="Where does the scrollbar end now?"></aparte-chat-bubble>
<aparte-chat-bubble message-id="a1" data-role="assistant" name="Assistant" content="At the very bottom of the chat - it runs behind the composer, because the scroll surface owns the whole column and the composer floats over it."></aparte-chat-bubble>
<aparte-chat-bubble message-id="u2" data-role="user" content="And nothing hides under the composer?"></aparte-chat-bubble>
<aparte-chat-bubble message-id="a2" data-role="assistant" name="Assistant" content="The viewport measures the floating stack and pads the transcript by exactly that much - grow the draft a few lines and watch this bubble stay clear of it."></aparte-chat-bubble>
<aparte-chat-bubble message-id="u3" data-role="user" content="Scroll up a little, too."></aparte-chat-bubble>
<aparte-chat-bubble message-id="a3" data-role="assistant" name="Assistant" content="The scroll-to-bottom button floats just above the composer instead of behind it - its offset rides the same measured inset."></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>

Deliberately opt-in, never the default: in an embedded 320px box a composer laid over the transcript eats reading space; overlay earns its keep on a chat that owns its column. Set it in the initial markup — it is read when the viewport mounts.

Two edges of the same recipe, for hosts that go further. A header laid over the top works with one rule of your own, since nothing scrolls under a padding-top:

[overlay-composer] .aparte-viewport-container { padding-top: 3.5rem; }

And a composer of your own, floated without the attribute, can write the variable by hand — the readers are unconditional (0px unset):

aparte-chat-viewport { --aparte-bottom-inset: 96px; }

Every chat product on the market is one of five shapes, and each is a way of sizing the host around the same <aparte-chat> — no attribute, no second component. The column width is the one token that changes between them.

LayoutWhoThe recipe
Reading columnChatGPT, Claude, Gemini, Perplexitythe section above: an unboxed chat, --aparte-message-max-width for the column
Full-width feedSlack, Discord, a “wide mode” toggle--aparte-message-max-width: none
Builder splitLovable, bolt, v0, Canvas, Artifacts<aparte-split>: the chat in one pane, yours in the other, a seam you can drag between them
Side panelCursor, Copilot Chat, a browser sidebarthe host gives the width; --aparte-message-max-width: none, height: 100%
Floating widgetIntercom, Crispa launcher and a fixed card — the one shape that needs an element of its own, and it is not this one: a launcher, a fixed card, a full-screen overlay under a breakpoint and a focus trap. On the roadmap; ask for it and it moves up

Lift the column and bubbles run edge to edge, with only the transcript’s own padding on each side. Set the token on the chat rather than on :root to keep it to one instance.

Width Open in a tab
No reading column: `--aparte-message-max-width: none` on this one chat.
<!-- `--aparte-message-max-width: none` lifts the centred column: bubbles and the
composer run edge to edge with the transcript's own padding, the way a team-chat
feed does. The same token, set on the chat rather than on :root, keeps it to this
one instance. -->
<aparte-chat style="height: 22rem; --aparte-message-max-width: none">
<aparte-chat-viewport>
<aparte-chat-bubble message-id="u1" data-role="user" content="Is this the whole width?"></aparte-chat-bubble>
<aparte-chat-bubble message-id="a1" data-role="assistant" name="Assistant" content="Yes — no column here, just the transcript's padding on each side."></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>

The chat in one pane, what it is building in the other, and a seam the reader can move. <aparte-split> is that seam. Two panes, one handle, and a pane contains a chat — a chat never contains a split.

Width Open in a tab
Drag the seam, or tab to it and use the arrows. The frame itself resizes too: its handle is at the bottom right and Full widens it to the page.
<!-- Press the seam and move it, or tab to it and use the arrow keys — Shift for ten
percent at a time, Home and End for the extremes, Enter to collapse, double-click to
reset. `--aparte-split-min` is a CSS length, so the chat cannot be dragged narrower
than 16rem whatever the percentage says: the browser clamps, nothing in JS parses a
unit.
The split stores nothing. `position` goes in, one `aparte-split-resize` comes out
on release — persist from there.
Under its breakpoint (the 48rem default, restated here so the frame and the buttons
agree) the split shows ONE pane: set the width to 375 above and the two buttons switch
it through `data-aparte-split-pane` and no script at all.
The buttons are hidden above that same width, which is the rule rather than the
detail: above the breakpoint there is only one layout, so a control that switches
panes has nothing to switch and would read as broken. Hide it with the media query
the split already answers to. -->
<style>
#split-panes { display: none }
@media (max-width: 48rem) {
#split-panes { display: flex; gap: var(--aparte-space-2); margin-block-end: var(--aparte-space-3) }
}
</style>
<div id="split-panes">
<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>
<aparte-split position="38" breakpoint="48rem" style="height: 22rem; --aparte-split-min: 16rem; --aparte-split-max: 65%">
<aparte-chat>
<aparte-chat-viewport>
<aparte-chat-bubble message-id="u1" data-role="user" content="Make the hero headline bigger and pin the header."></aparte-chat-bubble>
<aparte-chat-bubble message-id="a1" data-role="assistant" name="Assistant" content="Done — the headline is 20px now and the header stays at the top while the page scrolls."></aparte-chat-bubble>
<aparte-chat-bubble message-id="u2" data-role="user" content="Give me more room for the preview."></aparte-chat-bubble>
<aparte-chat-bubble message-id="a2" data-role="assistant" name="Assistant" content="That one is yours: drag the seam. I cannot reach past 65% either — the max is a CSS bound on the pane, not a rule I follow."></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>
<section class="aparte-split__pane">
<iframe title="Preview" style="inline-size: 100%; block-size: 100%; border: 0"
srcdoc="&lt;body style='margin:0; padding:28px; font:15px/1.6 system-ui, sans-serif; color:#1b1b1f; background:#fbf9f5'&gt;&lt;h1 style='margin:0 0 10px; font-size:20px'&gt;Your pane&lt;/h1&gt;&lt;p style='margin:0; color:#6b6b76'&gt;A preview frame, an editor, a canvas — whatever the chat is building. It is a real iframe, and the drag keeps tracking across it.&lt;/p&gt;&lt;/body&gt;"></iframe>
</section>
</aparte-split>

The grid is the whole mechanism. --aparte-split-position is the primary pane’s size and --aparte-split-min / --aparte-split-max are CSS clamp() bounds, so the browser does the clamping and nothing in JavaScript parses a unit — px, %, rem and ch all work. The element adds only what CSS cannot: the drag, the arrow keys, the ARIA of an APG window splitter, and one pane at a time under a breakpoint. Used without the element, .aparte-split is still a split; it just does not move.

position sizes the primary pane, primary="end" makes that the second one instead (the shape you want when the chat is the narrow strip on the right), orientation="vertical" stacks the panes and puts the seam between them, collapsed folds the primary pane to its minimum, and disabled keeps the seam drawn but inert — no drag, no keys, no tab stop. The AparteSplit class exposes position, orientation, primary, collapsed, pane, disabled and a read-only stacked, plus collapse(), expand(), toggleCollapse(), showPane('start' | 'end') and reset(). Keys on the seam: the arrows step 1%, Shift steps 10%, Home and End reach the extremes, Enter collapses and a second Enter restores the size it had, and a double-click resets it to the position it was connected with.

It stores nothing. position goes in; one aparte-split-resize comes out when the position settles — a release, a key up, a double-click, a property set — and never during a drag, so a framework’s reconciler is not in the drag loop. Persistence is four lines and it is yours:

<aparte-split id="workspace" position="38" style="height: 100dvh">
<aparte-chat></aparte-chat>
<section class="aparte-split__pane">your pane</section>
</aparte-split>
<script type="module">
import '@aparte/core';
const split = document.getElementById('workspace');
const saved = localStorage.getItem('split-position');
if (saved) split.setAttribute('position', saved);
// `detail` is an AparteSplitResizeDetail: the ACHIEVED position after the CSS clamp,
// plus `collapsed`, `stacked`, `pane`, `orientation` and `source` ('pointer' |
// 'keyboard' | 'api'). One event per settle, so this writes once per gesture.
split.addEventListener('aparte-split-resize', (event) => {
localStorage.setItem('split-position', String(event.detail.position));
});
</script>

On a narrow screen, one pane. Below breakpoint — a length, 48rem by default, or none to never stack — the split shows a single pane and hides the seam along with its tab stop. Any control on the page carrying data-aparte-split-pane switches which one, with no script. The attribute picks the split first and the pane second: the value start or end shows that pane of the split the control sits inside — or, for a control outside every split, of the first <aparte-split> on the page; an empty value toggles that same split; and any other value names a split’s id and toggles that one. So to reach a specific pane of a specific split, put the control inside it.

Above the breakpoint the buttons change nothing on screen. The attribute still moves, so hide them with the same media query rather than leaving two controls that look dead:

<div class="aparte-app-header">
<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>
<style>
.aparte-app-header .aparte-btn[data-aparte-split-pane] { display: none }
@media (max-width: 60rem) {
.aparte-app-header .aparte-btn[data-aparte-split-pane] { display: inline-flex }
}
</style>
<!-- 60rem rather than the default 48rem: a split whose pane needs room stacks earlier. -->
<aparte-split breakpoint="60rem" style="height: 100dvh"></aparte-split>

If your host already owns its breakpoints, set breakpoint="none" and put the state on the element yourself — or set single (with pane="start" or pane="end") when the element should show one pane whatever the width, a preview with nothing to preview yet, say: <aparte-split single pane="start">. The CSS route is the same state for a host that owns its breakpoints: .aparte-split--only-start and .aparte-split--only-end carry rules byte-identical to the ones data-stacked selects, and the element reads them the same way — the seam loses its tab stop, and nothing measures the single track it is now showing. The same two classes are what stack a split that has no element at all:

<!-- Your breakpoint, not the element's: toggle the class where you toggle the rest. -->
<aparte-split breakpoint="none" class="aparte-split--only-start" style="height: 100dvh">
<aparte-chat></aparte-chat>
<section class="aparte-split__pane">your pane</section>
</aparte-split>
<!-- Or no element at all: a static split, stacked by the same class. -->
<div class="aparte-split aparte-split--only-start" style="height: 100dvh">
<div>the chat</div>
<div class="aparte-split__handle"></div>
<div class="aparte-split__pane">your pane</div>
</div>

Right to left. The seam follows the writing direction on its own: a grid container in rtl already reverses its columns, every rule is written in logical properties, and the element reads the computed direction — so ArrowLeft and ArrowRight swap and the pointer delta is negated, while ArrowUp and ArrowDown never do. Nothing to configure, and a subtree flipped with dir="rtl" is enough; document.dir is never consulted.

Below 520px of chat width the transcript switches to its narrow spacing on its own — a container query on the chat, not a media query on the page — so the narrow pane of a split and the phone share the same CSS. Every attribute, event and knob is on the element page; the recipe on its own, with its markup, is in the UI kit.

The split’s reason to exist: the model returns a document, and the reader wants it beside the conversation rather than scrolled away inside it. @aparte/plugin-artifacts renders the document as a Code / Preview card with Copy and Download, and the pane is where your app mounts the result.

Width Open in a tab
One setupArtifacts() call, and the card is the reply. The pane beside it is the same document, mounted — that part is your app's, not the library's.
<!-- The split earning its keep. The model returns a document, @aparte/plugin-artifacts
renders it as the Code / Preview card with Copy and Download, and the wide pane is
where your app mounts the result.
`setupArtifacts()` is the whole wiring — the tool, the card on its result, the
`<artifact>` grammar and the segment renderer — and it is also what puts the card's
stylesheet on the page. Building the card by hand instead gets you an unstyled one.
`--aparte-artifact-body-max` is the card's own knob (600px by default), turned down
here so the whole card fits a documentation frame.
In your app the first line is `import { setupArtifacts } from '@aparte/plugin-artifacts'`.
This frame reads it off `window.aparteArtifacts` only because a classic script
cannot import. -->
<aparte-split position="55" breakpoint="48rem" style="height: 30rem; --aparte-split-min: 18rem; --aparte-split-max: 75%">
<aparte-chat style="--aparte-artifact-body-max: 11rem">
<aparte-chat-viewport></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>
<section class="aparte-split__pane">
<iframe id="mounted" title="The artifact, mounted" style="inline-size: 100%; block-size: 100%; border: 0"></iframe>
</section>
</aparte-split>
<script>
const { setupArtifacts, artifactSegment } = window.aparteArtifacts;
setupArtifacts();
const DOC = `<!doctype html>
<meta charset="utf-8">
<style>
body { margin: 0; font: 16px/1.6 system-ui, sans-serif; color: #1b1b1f; background: #fbf9f5 }
main { max-width: 30rem; margin: 0 auto; padding: 3rem 1.5rem }
h1 { font-size: 1.8rem; margin: 0 0 .6rem }
p { color: #6b6b76; margin: 0 0 1.6rem }
a { display: inline-block; padding: .6rem 1.1rem; border-radius: .5rem; background: #1b1b1f; color: #fff; text-decoration: none }
</style>
<main>
<h1>A chat you can put anywhere</h1>
<p>Web components, no framework, and the agent loop inside.</p>
<a href="#">Read the guide</a>
</main>`;
// The pane is the app's, not the library's: mounting the document is one assignment.
document.getElementById('mounted').srcdoc = DOC;
const viewport = document.querySelector('aparte-chat-viewport');
viewport.appendMessage({ id: 'u1', role: 'user', content: 'Draft the landing page for the beta.', timestamp: Date.now() });
viewport.appendMessage({
id: 'a1',
role: 'assistant',
timestamp: Date.now(),
segments: [
{ id: 's1', type: 'text', content: 'Here is a first draft — the pane beside this is the same document, mounted.' },
artifactSegment('s2', { mimeType: 'text/html', title: 'Landing page', content: DOC }),
],
});
</script>

A docked strip in a host window — an editor, a browser sidebar — gives the chat its width; there is no room for a column inside it, so it is the feed recipe in a band:

.panel aparte-chat { height: 100%; --aparte-message-max-width: none; }
Width Open in a tab
A 20rem strip beside the file. Nothing sets the narrow spacing: below 520px of chat width a container query on the chat does it.
<!-- A docked strip in a host window — an editor, a browser sidebar, a review panel.
The host gives the width, so there is no room for a reading column inside it: lift
the token and let the chat fill the band, with one border for the seam.
Nothing else is needed. Below 520px of CHAT width the transcript switches to its
narrow spacing on its own — a container query on the chat, not a media query on the
page — so a 20rem strip and a phone share the same CSS. -->
<style>
#side-panel-demo { display: grid; grid-template-columns: minmax(0, 1fr) 20rem; height: 20rem; background: var(--aparte-surface-2) }
/* Under 40rem the strip stacks: a fixed 20rem column beside a 1fr one squeezed the code
to a sliver on a phone and let the chat overflow its host. */
@media (max-width: 40rem) {
#side-panel-demo { grid-template-columns: minmax(0, 1fr); grid-template-rows: 9rem minmax(0, 1fr); height: 30rem }
#side-panel-demo > aparte-chat { border-inline-start: 0; border-block-start: var(--aparte-border-width) solid var(--aparte-border) }
}
</style>
<div id="side-panel-demo">
<pre style="margin: 0; overflow: auto; padding: var(--aparte-space-4); font-size: 0.78rem; line-height: 1.7; color: var(--aparte-text-muted)">export function createOrder(cart, customer) {
const lines = cart.items.map(toLine);
const total = lines.reduce((sum, line) =&gt; sum + line.amount, 0);
return { id: nextId(), customer, lines, total };
}
function toLine(item) {
return { sku: item.sku, qty: item.qty, amount: item.qty * item.price };
}</pre>
<aparte-chat style="border-inline-start: var(--aparte-border-width) solid var(--aparte-border); --aparte-message-max-width: none">
<aparte-chat-viewport>
<aparte-chat-bubble message-id="u1" data-role="user" content="Why is total a float here?"></aparte-chat-bubble>
<aparte-chat-bubble message-id="a1" data-role="assistant" name="Assistant" content="Because price is. Money in a float rounds badly at the third order — keep amounts in minor units and divide only when you print."></aparte-chat-bubble>
<aparte-chat-bubble message-id="u2" data-role="user" content="Fix toLine then."></aparte-chat-bubble>
<aparte-chat-bubble message-id="a2" data-role="assistant" name="Assistant" content="Done: amount is now Math.round(item.qty * item.priceCents), and total sums integers."></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>
</div>

A conversation starts with nothing in it, and a composer pinned to the bottom of an empty box reads as a page that failed to load. center-empty on <aparte-chat> keeps the composer in the middle until the first message lands, then slides it down — the element watches its own viewport, so nothing of yours runs. While it is centred the chat carries data-empty, which is the hook for your own welcome copy.

<aparte-chat center-empty style="height: 100dvh">
<aparte-chat-viewport></aparte-chat-viewport>
<aparte-composer></aparte-composer>
</aparte-chat>
/* Your greeting, above the centred composer, gone the moment a reply arrives. */
.welcome { display: none; text-align: center; }
aparte-chat[data-empty] .welcome { display: block; }
Width Open in a tab
Press Send one: the composer slides from the middle to the bottom, and data-empty comes off the chat.
<!-- `center-empty` is a welcome state the element owns: while the transcript is empty
the composer sits in the middle of the chat and the element carries `data-empty`,
which is the hook for your own welcome copy — the greeting below is that hook and
nothing else. The first bubble to land slides it to the normal layout — no script
of yours, no second component, and nothing to undo when the conversation is cleared
again. -->
<style>
.welcome { display: none; text-align: center; padding-block-end: var(--aparte-space-4); color: var(--aparte-text-muted) }
.welcome strong { display: block; font-size: var(--aparte-font-size-xl); color: var(--aparte-text); margin-block-end: var(--aparte-space-1) }
aparte-chat[data-empty] .welcome { display: block }
</style>
<aparte-chat center-empty style="height: 24rem">
<aparte-chat-viewport></aparte-chat-viewport>
<p class="welcome"><strong>What are we building today?</strong>Ask anything, or start from one of these.</p>
<aparte-composer>
<!-- Starters: shown while the transcript is empty, gone with the first message. -->
<aparte-suggestions empty-only
suggestions='["Explain what a transport is", {"label": "Draft a changeset", "prompt": "Draft a changeset for a patch that renames an attribute."}, "Compare the four wrappers"]'>
</aparte-suggestions>
<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>
<button class="aparte-btn aparte-btn--surface" type="button" id="seed" style="margin-block-start: 1rem">Send one</button>
<script>
// Any bubble ends the welcome state, whoever put it there — a client, your own loop,
// or this button. The element watches its own viewport for it.
const seed = document.getElementById('seed');
seed.addEventListener('click', () => {
const viewport = document.querySelector('aparte-chat-viewport');
viewport.appendMessage({ id: 'u1', role: 'user', content: 'What is a transport?', timestamp: Date.now() });
viewport.appendMessage({
id: 'a1',
role: 'assistant',
content: 'The seam between the chat and your model: browser-direct with your own key, or your own /api/chat with the key on the server.',
timestamp: Date.now(),
});
seed.disabled = true;
});
</script>

The attribute needs a viewport somewhere inside, which hand-written markup always has. The one path where it does nothing is framework-managed — React, Vue and Svelte own that subtree and ship their own centred layout. Full details on the element page.

The scrollbar says how far down you are, not which question you are under. Put an <aparte-scroll-rail> inside the chat and it draws one tick per user turn on the transcript’s end edge, marks the one under the reader, and jumps back to any of them on a click — the minimap of a code editor reduced to its ticks:

<aparte-chat style="height: 100dvh">
<aparte-chat-viewport></aparte-chat-viewport>
<aparte-scroll-rail></aparte-scroll-rail> <!-- every="message" for one tick per message -->
<aparte-composer></aparte-composer>
</aparte-chat>

It reads the transcript and never owns it; the viewport keeps its scroll. A click fires a cancelable aparte-scroll-rail-jump (AparteScrollRailJumpDetail, { messageId }) before the scrollIntoView, so a host that pages history in can load it first. every takes an AparteScrollRailEveryuser (the default) or message. The AparteScrollRail class exposes jumpTo(messageId) and currentMessageId for a control of your own — a “back to my question” button, say. It hides under a coarse pointer, where a two-pixel tick is no target, and renders nothing below two ticks. The element page has the live demo and the knobs.

The chat is one flex item like any other. Give the row a height and the chat min-width: 0 so a long code line cannot widen the pane:

.workspace { display: flex; height: 100dvh; }
.workspace nav { width: 260px; flex: none; }
.workspace aparte-chat { flex: 1; min-width: 0; }

Each <aparte-chat> scrolls on its own, so two side by side or one above another need nothing more than a height each. Theming per instance is on the theming guide.

TokenDefaultMoves
--aparte-message-max-width800pxthe centred column — bubbles and the composer; none lifts it
--aparte-viewport-padding--aparte-space-8the transcript’s inset from the chat’s edges
--aparte-chat-bottom-gap--aparte-space-8the space under the composer
--aparte-scrollbar-width6pxthe WebKit scrollbar (Firefox uses scrollbar-width: thin)
--aparte-scrollbar-thumb / -track--aparte-neutral / transparentits colours
--aparte-split-position38%the primary pane’s size — and the live value while the seam is being dragged
--aparte-split-min20remthe primary pane’s floor. Any length or percentage; the browser clamps
--aparte-split-max60%its ceiling
--aparte-split-handle-size4pxthe visible seam
--aparte-split-hit-area12pxthe invisible grab zone around it; it grows to the touch target under a coarse pointer

The complete list, with what reads each one, is the generated CSS variables reference.

Two of these layouts are driven by attributes rather than by tokens, because what they set is a state and not a size:

AttributeOnWhat it does
center-empty<aparte-chat>centres the composer until the first message, and carries data-empty while it does
position<aparte-split>the primary pane’s size, as a percentage. Reflected back on every settle
orientation<aparte-split>horizontal (side by side, the default) or vertical (stacked). It names the container’s axis
primary<aparte-split>start (default) or end — which pane position sizes
collapsed<aparte-split>the primary pane folded to its minimum; Enter on the seam toggles it
breakpoint<aparte-split>the width below which one pane is shown. A length (48rem), or none
pane<aparte-split>start (default) or end — which pane is shown while stacked
disabled<aparte-split>the seam stays drawn, and does nothing: no drag, no keys, no tab stop