Place the chat on your page
Size the chat, fill the page like ChatGPT, float the composer, put a preview beside it, centre the welcome state, add a rail for long threads, and build the whole page around it.
Give the chat a height
Section titled “Give the chat a height”<!-- a box -->
<aparte-chat style="height: 600px"></aparte-chat>/* under a header, in a flex column */
.app { display: flex; flex-direction: column; height: 100dvh; }
.app aparte-chat { flex: 1; min-height: 0; height: auto; }/* the whole window */
html, body { height: 100%; margin: 0; }
aparte-chat { height: 100dvh; }The message list is what scrolls, not the page, so the chat needs a height to scroll inside:
<aparte-chat> fills its parent, and a chat with nothing sized above it grows with its messages
and never scrolls. min-height: 0 lets a flex item shrink below its content, and 100dvh
follows a phone’s browser bar as it hides and shows. Keep the message list as the scroller: the
chat follows a streaming reply, offers the jump back to the bottom and stops following when the
person scrolls up, all from that list.
Fill the page, like ChatGPT
Section titled “Fill the page, like ChatGPT”/* not this: a boxed chat puts its scrollbar inside the page */
.app { max-width: 820px; margin: 0 auto; }
/* this: the chat spans the page, its messages keep a centred column */
.app { display: flex; flex-direction: column; height: 100dvh; }
:root { --aparte-message-max-width: 48rem; }<!-- 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 messages and the composer share one centred column, --aparte-message-max-width wide, inside
a list that spans the chat. Leave the chat unboxed and the scrollbar lands on the window’s edge
while the reading column stays centred.
Float the composer over the transcript
Section titled “Float the composer over the transcript”<aparte-chat overlay-composer></aparte-chat><!-- 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>With overlay-composer, the message list takes the whole chat and the composer floats over its
bottom, so the scrollbar runs the full height. Every other child of the chat, except the
scroll rail, floats at the bottom with the composer, and the list
measures that stack to keep the last message, and the jump-to-bottom button, clear of it. The
framework packages take it as overlayComposer. Set it in the first markup: it is read when the
chat mounts.
A composer of your own, floated without the attribute, sets the space it covers itself:
aparte-chat-viewport { --aparte-bottom-inset: 96px; }.
A header that floats over the top of the transcript sets the space it covers the same way:
aparte-chat, [data-aparte-chat] { --aparte-top-inset: 56px; }The first message then starts below it, and a jump to a message, from the
scroll rail or scrollIntoView, lands below it. Write the header
outside the chat, positioned over it: a child of an overlay-composer chat floats at the bottom
with the composer.
Pick a shape
Section titled “Pick a shape”| Shape | Like | How |
|---|---|---|
| Reading column | ChatGPT, Claude, Gemini | the default, unboxed: the section above |
| Full-width feed | Slack, Discord | aparte-chat { --aparte-message-max-width: none; } |
| Side panel | an editor’s or a browser’s chat strip | a panel with a height, and .panel aparte-chat { --aparte-message-max-width: none; } |
| A chat beside a preview | Canvas, Artifacts, v0 | <aparte-split>, below |
| A floating widget | a support chat on a product’s site | the Support widget template builds one |
A message list or a composer narrower than 520px switches to its narrow spacing by itself — a container query, not a media query — so a side panel and a phone get the same spacing. That spacing is set on the messages themselves, so a value you put on the chat does not reach them there: change it in a container query of your own.
@container (max-width: 520px) {
.aparte-message { --aparte-message-padding-inline: 12px; }
}The same spacing at any width is a class on the chat, for a widget or a panel wider than 520px:
<aparte-chat class="aparte-chat--compact"></aparte-chat>In React, Vue, Svelte and Angular it is the chat component’s className or class.
Put a preview beside the chat
Section titled “Put a preview beside the chat”<div class="page">
<nav class="pane-switch">
<button class="aparte-btn aparte-btn--sm" type="button" data-aparte-split-pane="start">Chat</button>
<button class="aparte-btn aparte-btn--sm" type="button" data-aparte-split-pane="end">Preview</button>
</nav>
<aparte-split id="workspace" position="38">
<aparte-chat></aparte-chat>
<section class="aparte-split__pane">your preview</section>
</aparte-split>
</div>
<script type="module">
import '@aparte/core';
import '@aparte/core/styles.css';
const split = document.getElementById('workspace');
const saved = localStorage.getItem('split-position');
if (saved) split?.setAttribute('position', saved);
split?.addEventListener('aparte-split-resize', (event) => {
localStorage.setItem('split-position', String(event.detail.position));
});
</script>.page { display: flex; flex-direction: column; height: 100dvh; }
.page aparte-split { flex: 1; min-height: 0; }
.pane-switch { display: none; }
@media (max-width: 48rem) { .pane-switch { display: flex; gap: 0.5rem; } }<!-- 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 (30rem here, narrower than the 48rem default so this frame shows
both panes; restated in the style rule so 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: 30rem) {
#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="30rem" 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="<body style='margin:0; padding:28px; font:15px/1.6 system-ui, sans-serif; color:#1b1b1f; background:#fbf9f5'><h1 style='margin:0 0 10px; font-size:20px'>Your pane</h1><p style='margin:0; color:#6b6b76'>A preview frame, an editor, a canvas — whatever the chat is building. It is a real iframe, and the drag keeps tracking across it.</p></body>"></iframe>
</section>
</aparte-split><aparte-split> holds two panes and the seam between them; position is the
primary pane’s size, as a percentage: the first pane, unless primary="end". It keeps nothing
itself: aparte-split-resize fires once the position settles, never during a drag, which is when
you save it. A pane holds a chat, never the other way round.
Under its breakpoint, 48rem by default, it shows one pane at a time. A control carrying
data-aparte-split-pane="start" or "end" switches the pane with no script, and the media query
above hides those controls where both panes show. single shows one pane at every width — the
chat alone, until there is something to preview.
With an artifact in the pane
Section titled “With an artifact in the pane”import { aparteGlobalConfig } from '@aparte/core';
import { setupArtifacts, createArtifactTool, artifactHandler } from '@aparte/plugin-artifacts';
import type { ArtifactInput } from '@aparte/plugin-artifacts';
declare function showInPane(artifact: ArtifactInput): void; // your preview
const split = document.querySelector('aparte-split');
setupArtifacts();
aparteGlobalConfig.registerTool(createArtifactTool(), async (call) => {
const result = await artifactHandler(call);
showInPane(result.structuredContent as ArtifactInput);
if (split) split.single = false; // open the pane beside the chat
return result;
});<!-- 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.apartePlugins.artifacts` 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.apartePlugins.artifacts;
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>When the model writes a document, @aparte/plugin-artifacts shows it as a
card in the reply. To open it beside the chat too, start the split with single and register the
tool again with a handler of yours: it returns the plugin’s result, so the card still renders, and
it puts the same document in the pane. The Research assistant
template does this with its report.
Centre the welcome state
Section titled “Centre the welcome state”<aparte-chat center-empty style="height: 100dvh">
<aparte-chat-viewport></aparte-chat-viewport>
<aparte-composer>
<p class="welcome">What can I help with?</p>
<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>.welcome { display: none; text-align: center; }
aparte-chat[data-empty] .welcome { display: block; }<!-- `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>center-empty holds the composer in the middle of an empty chat and slides it down with the first
message. The slide waits for a person: a conversation a script restores before the first key,
pointer or send lands at once, without moving the page under someone who did nothing; the chat
says it is past that point with data-glide. While the chat is empty it carries data-empty, the hook for the greeting, which goes
away as that message lands;
<aparte-suggestions empty-only> shows its starters on the same
condition. In the framework packages the switch is centerWhenEmpty, the greeting goes in
emptyState (React) or the empty-state slot (Vue, Svelte, Angular), shown only while the chat is
empty, and the hook for anything else is data-aparte-empty.
Jump back in a long thread
Section titled “Jump back in a long thread”<aparte-chat style="height: 100dvh">
<aparte-chat-viewport></aparte-chat-viewport>
<aparte-scroll-rail></aparte-scroll-rail>
<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><aparte-scroll-rail> draws one tick per question along the message
list, marks the one being read, and jumps to any of them on a click; every="message" ticks every
message. It shows from the second tick on, and stays hidden on a touch screen, where its ticks are
too thin to press. A click fires aparte-scroll-rail-jump (detail.messageId) first: cancel it to load
older history, then call rail.jumpTo(messageId) yourself and let that second event through.
Outside the chat — beside a framework component, say — give the chat an id and the rail a
target naming it.
The whole page: sidebar, header and chat
Section titled “The whole page: sidebar, header and chat”<div class="aparte-app-shell" style="height: 100dvh">
<aparte-sidebar>
<div class="aparte-sidebar__header">
<span class="aparte-sidebar__brand">My app</span>
<button class="aparte-btn aparte-btn--icon aparte-btn--sm" type="button"
aria-label="Toggle the sidebar" data-aparte-sidebar-toggle><aparte-icon name="menu"></aparte-icon></button>
</div>
<div class="aparte-sidebar__search aparte-field-group">
<input class="aparte-field aparte-field--sm" type="search" placeholder="Search"
aria-label="Search conversations" data-aparte-sidebar-search />
</div>
<div class="aparte-sidebar__body">
<aparte-conversation-list></aparte-conversation-list>
</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">New chat</span>
<div class="aparte-app-header__actions"></div>
</header>
<main class="aparte-app-shell__main">
<aparte-chat></aparte-chat>
</main>
</div><!-- 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. This frame is
narrower than 48rem, so the sidebar is a drawer behind that toggle; open the demo on
its own page and the window is wide enough for the column. -->
<div class="aparte-app-shell" style="height: 24rem">
<aparte-sidebar collapsed>
<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>
<!-- The header's toggle shows only while the sidebar is closed (or under 48rem):
on a wide window, this one is what closes it. -->
<button class="aparte-btn aparte-btn--icon aparte-btn--sm" type="button" aria-label="Toggle the sidebar" data-aparte-sidebar-toggle>
<aparte-icon name="menu"></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>.aparte-app-shell lays out the page: the sidebar beside, the
header above, the chat in the rest. <aparte-sidebar>
collapses, and becomes a drawer on a narrow screen, with a scrim, Escape to close and the focus
handed back. Its search field filters the conversation list by
title, and any control with data-aparte-sidebar-toggle opens and closes it: the header’s button
shows while the sidebar is closed, and always on a narrow screen, so the one in the sidebar’s own
header is what closes it on a wide one. None of it needs script. A split goes in __main the same
way as a chat. Settings belong in the kit’s dialog, and where the
conversations come from is Save conversations.
When the shell is static HTML, link @aparte/core/styles.css from the <head> so it never paints
bare — Register the components shows the line.