Skip to content

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.

On this page
html
<!-- a box -->
<aparte-chat style="height: 600px"></aparte-chat>
css
/* 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; }
css
/* 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.

css
/* 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; }
Open
The column is a width on the messages, not a box around the chat, so the scrollbar runs down the frame's edge.

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.

html
<aparte-chat overlay-composer></aparte-chat>
Open
The scrollbar runs past the composer to the bottom edge, and the last message stays clear of it.

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:

css
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.

ShapeLikeHow
Reading columnChatGPT, Claude, Geminithe default, unboxed: the section above
Full-width feedSlack, Discordaparte-chat { --aparte-message-max-width: none; }
Side panelan editor’s or a browser’s chat stripa panel with a height, and .panel aparte-chat { --aparte-message-max-width: none; }
A chat beside a previewCanvas, Artifacts, v0<aparte-split>, below
A floating widgeta support chat on a product’s sitethe 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.

css
@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:

html
<aparte-chat class="aparte-chat--compact"></aparte-chat>

In React, Vue, Svelte and Angular it is the chat component’s className or class.

html
<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>
css
.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; } }
Open
Drag the seam, or tab to it and use the arrows.

<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.

ts
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;
});
Open
The reply is an artifact card; the pane beside it is the same document, opened by the app.

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.

html
<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>
css
.welcome { display: none; text-align: center; }
aparte-chat[data-empty] .welcome { display: block; }
Open
Send one message: the composer slides from the middle to the bottom.

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.

html
<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.

html
<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>
Open
The sidebar, the header, the chat. In a narrow frame the sidebar is a drawer behind the header's button.

.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.