Svelte 4/5 AI chat component, with stores — @aparte/svelte
@aparte/svelte wraps @aparte/core for Svelte 4 and 5: an ergonomic <AparteChat> component, store
factories for state and the client, and a generic <AparteUi> escape hatch.
npm install @aparte/svelte @aparte/core sveltenpm install @aparte/provider-scenario # the scripted model, for the first run below, with no key@aparte/core and svelte are peer dependencies.
<AparteChat> + createAparteChat
Section titled “<AparteChat> + createAparteChat”The createAparteChat store factory owns the messages store and mirrors the imperative API, so you
bind the store and connect the component with bind:this:
<script lang="ts"> import { AparteChat, createAparteChat, type AparteChatImperativeApi } from '@aparte/svelte'; import '@aparte/core/styles.css';
const chat = createAparteChat(); const { messages } = chat; let comp: AparteChatImperativeApi | null = null; $: chat.connect(comp);</script>
<AparteChat bind:this={comp} messages={$messages} centerWhenEmpty onmessagesChange={(m) => chat.onMessagesChange(m)}> <p slot="empty-state">Ask me anything…</p></AparteChat>The user’s message is appended to the thread automatically on send — don’t add it yourself.
onmessageSent is optional and fires after that append, for side-effects only (scroll, analytics,
a backend call).
Every callback also exists as a component event — on:messagesChange={(e) => chat.onMessagesChange(e.detail)}
— which is the Svelte 4 spelling and still works on Svelte 5. Svelte 5 documents createEventDispatcher
as deprecated and recommends callback props, so the callbacks are the path written here; both fire for
the same occurrence, the callback with the payload itself, the event with it under event.detail.
Slots are named slots: empty-state, composer, above-composer,
toolbar (the composer’s bottom row — mode picker, model selector: see
The composer toolbar for an example; use
<svelte:fragment slot="toolbar"> to project several nodes), and
the bubble slot (<div slot="bubble" let:message>) for a fully custom bubble. Every imperative
method (streaming, branch/edit, scrollToBottom) is mirrored on the chat store and reachable via
bind:this.
The wrapper projects only these slots. Anything else you put inside <AparteChat> is
discarded — React and Svelte reject it at compile time, Vue and Angular drop it at runtime.
For full control of the input row, fill the composer slot; for the primitives as
children, use core’s <aparte-chat> directly.
The other five are onaction, onmessagesChange, onmessageAppended, ontypingChange and onconversationCreated (as events: on:action, on:messagesChange, … with the payload under event.detail) — the table with all four frameworks side by side is generated from the wrapper source: Wrapper surface.
Wiring a real model
Section titled “Wiring a real model”The wrapper is provider-agnostic. Register a provider + transport once (see
Providers) and start an AparteClient with createAparteClient — it bridges composer
sends to the model.
Start with the scripted model: it needs no key and no network, and every line below is the same for a real one.
<script lang="ts"> import { AparteChat, createAparteChat, createAparteClient, type AparteChatImperativeApi } from '@aparte/svelte'; import { aparteGlobalConfig, AparteDirectTransport } from '@aparte/core'; import { createScenarioProvider } from '@aparte/provider-scenario'; import { showcase } from '@aparte/provider-scenario/showcase';
aparteGlobalConfig.registerAIProvider(createScenarioProvider({ scenarios: showcase })); aparteGlobalConfig.setTransport(new AparteDirectTransport({ byok: true }));
const chat = createAparteChat(); createAparteClient(); // streams the scripted reply — no key, no network const { messages } = chat; let comp: AparteChatImperativeApi | null = null; $: chat.connect(comp);</script>
<AparteChat bind:this={comp} messages={$messages} onmessagesChange={(m) => chat.onMessagesChange(m)} />Swap the provider for a real one and nothing else changes:
<script lang="ts"> import { AparteChat, createAparteChat, createAparteClient, type AparteChatImperativeApi } from '@aparte/svelte'; import { aparteGlobalConfig, AparteDirectTransport } from '@aparte/core'; import { createOpenAICompatProvider, presets } from '@aparte/provider-openai-compat';
aparteGlobalConfig.registerAIProvider(createOpenAICompatProvider(presets.OPENROUTER)); aparteGlobalConfig.setTransport(new AparteDirectTransport({ byok: true }));
const chat = createAparteChat(); createAparteClient(); // streams replies from the configured provider const { messages } = chat; let comp: AparteChatImperativeApi | null = null; $: chat.connect(comp);</script>
<AparteChat bind:this={comp} messages={$messages} onmessagesChange={(m) => chat.onMessagesChange(m)} />Pass a per-instance config prop to scope providers/transport to a single <AparteChat> instead of
aparteGlobalConfig.
For a wait of your own — independent of what the client is doing, e.g. while your own store
is still answering — pass loading:
<AparteChat loading={isFetchingHistory} />See the wrapper reference for the same prop in React, Vue and Angular.
Any aparté element: typed in the markup
Section titled “Any aparté element: typed in the markup”The aparte-* tags are declared through SvelteHTMLElements, so svelte-check covers both their
attributes and their on: handlers — no AparteUi needed:
<aparte-select searchable="" placeholder="Pick a model" on:aparte-select-change={(e) => use(e.detail.value)}> <aparte-option value="gpt-4o-mini">GPT-4o mini</aparte-option></aparte-select>On Svelte 5 in runes mode, on: on an element is the one form the compiler flags as deprecated
(event_directive_deprecated); write the event attribute instead — onaparte-select-change={(e) => …} —
which svelte-check types the same way.
Presence attributes take '' to set and null to remove, never false — Svelte stringifies what it
sets on a custom element, so searchable={false} would render searchable="false" and an element
testing hasAttribute reads that as on. The rules and the full set are on
Placing elements, typed.
Any OTHER element: <AparteUi>
Section titled “Any OTHER element: <AparteUi>”For an element aparté does not define — one of yours, or a third party’s:
<script lang="ts"> import { AparteUi } from '@aparte/svelte';</script>
<AparteUi name="my-token-counter" props={{ 'data-budget': '8000' }} onelementEvent={(e) => console.log(e.type, e.detail)}/>onelementEvent receives the element’s own CustomEvent. The component event on:elementEvent
still fires too, with that event under e.detail — the Svelte 4 spelling.
It mounts any tag name, which is what a foreign element needs and what aparté’s own no longer do.
Also exported
Section titled “Also exported”createConversationManager— Svelte stores over the coreAparteConversationManager(list / create / archive), for a multi-conversation sidebar.
Testing it
Section titled “Testing it”Vitest — every runner — executes on Node, so @aparte/core resolves to its DOM-free
entry and no <aparte-*> element upgrades under jsdom: the tag stays a plain
HTMLElement and every assertion about it fails for a reason nothing explains. Alias the
specifier to @aparte/core/browser, the
entry with the elements in it.
The whole thing, running
Section titled “The whole thing, running”A complete chat site in Svelte — the conversation sidebar, the header, the settings dialog,
markdown, highlighting, tools, and the scripted model — is in this repository:
apps/examples/svelte5, and apps/examples/svelte4 for the previous major. It
shares its setup with the vanilla, React, Vue and Angular sites through
apps/examples/_shared, so
the framework-specific part is App.svelte and the main.ts that mounts it — everything
else is what an app writes in any framework.