Placing elements, typed
<AparteChat> gives you the whole turn in one tag. Everything else — the model selector, the
conversation list, a composer you compose yourself — is a custom element you place, and this page
is about placing those with your compiler on your side.
What “typed” means here
Section titled “What “typed” means here”Every core element’s attribute surface is declared once in @aparte/core and consumed by all
four wrappers. The registry is AparteElementAttributes, one entry per tag, with
AparteElementTagName as its key union:
import type { AparteElementAttributes, AparteElementTagName, AparteSelectAttributes } from '@aparte/core';
// One entry per tag. The wrappers derive their own typing from this, so an element added// to core is typed in every framework the moment it lands here.type SelectAttrs = AparteElementAttributes['aparte-select']; // = AparteSelectAttributestype EveryTag = AparteElementTagName; // 'aparte-chat' | 'aparte-select' | …
const preset: AparteSelectAttributes = { placeholder: 'Pick a model', searchable: true };The per-element interfaces are exported individually too, when you want to type your own wrapper
around one: AparteChatAttributes, AparteChatViewportAttributes, AparteChatBubbleAttributes,
AparteChatStatusAttributes, AparteComposerAttributes, AparteComposerInputAttributes,
AparteComposerActionAttributes, AparteComposerAddAttachmentAttributes,
AparteComposerToolbarAttributes, AparteConversationListAttributes, AparteSelectAttributes,
AparteOptionAttributes, AparteOptgroupAttributes, AparteProgressSpinnerAttributes, and
AparteNoAttributes for the four that observe nothing.
An element that does not come from core — from a plugin, or one of yours — is not in this registry, and that is the boundary rather than an omission. See Your own element, or a plugin’s.
The one thing to know about presence attributes
Section titled “The one thing to know about presence attributes”An aparté element is attribute-driven: it reacts to an attribute being present, not to a
property being assigned. In a template that means false is the wrong value to write, because
React, Vue and Svelte all stringify what they set on a custom element — searchable={false} renders
searchable="false", and code that tests hasAttribute reads that as on.
So in those three, a presence attribute is '' | null | undefined, and the types enforce it. Write
'' to set and null (or undefined) to remove. AparteTemplateAttrs and AparteAttrValue are
the mapping that does it, exported in case you build your own template integration:
import type { AparteTemplateAttrs, AparteAttrValue, AparteSelectAttributes } from '@aparte/core';
type InATemplate = AparteTemplateAttrs<AparteSelectAttributes>;// searchable?: '' | null | undefined ← not boolean, on purpose// placeholder?: stringtype Presence = AparteAttrValue<boolean>; // '' | null | undefinedAngular is the exception, and its directives take a real boolean — see below.
The aparte-* tags are typed JSX intrinsics as soon as you import from @aparte/react. Nothing to
register:
// A composer you compose yourself, slotted into <AparteChat>.<div className="aparte-composer-row"> <aparte-composer-input placeholder="Ask anything…" max-height={320} /> <aparte-composer-send /></div>Attribute names are the HTML ones (max-height, message-id, data-role). A wrong VALUE type is a
type error;
so is an attribute the element does not observe.
Events reach you by ref, and they are typed through the DOM because @aparte/core augments
HTMLElementEventMap:
const select = document.querySelector('aparte-select');select?.addEventListener('aparte-select-change', (e) => { // e.detail is AparteSelectChangeDetail — value, label, previousValue console.info(e.detail.value, e.detail.previousValue);});Declared through GlobalComponents, so vue-tsc checks them in any template once the package is
imported:
<aparte-select placeholder="Pick a model" searchable="" @aparte-select-change="e => pick(e.detail.value)"> <aparte-option value="gpt-4o-mini">GPT-4o mini</aparte-option></aparte-select>Remember :searchable="null" to remove rather than :searchable="false".
Svelte
Section titled “Svelte”Declared through SvelteHTMLElements, so svelte-check covers them:
<aparte-select placeholder="Pick a model" searchable="" on:aparte-select-change={(e) => pick(e.detail.value)}> <aparte-option value="gpt-4o-mini">GPT-4o mini</aparte-option></aparte-select>Angular
Section titled “Angular”Angular is the one wrapper that ships code for this, for two structural reasons: its template
compiler rejects a tag nothing claims, and [placeholder]="x" writes a property — which on an
attribute-driven element is a silent no-op, or a throw on one of <aparte-composer>’s eight
getter-only accessors.
So each element has a standalone directive whose selector is the tag. Import the ones you use, or all of them at once:
import { Component } from '@angular/core';import { APARTE_ELEMENT_DIRECTIVES } from '@aparte/angular';
@Component({ selector: 'app-picker', standalone: true, imports: [...APARTE_ELEMENT_DIRECTIVES], template: ` @if (showPicker) { <aparte-select [searchable]="true" placeholder="Pick a model" (selectChange)="use($event.value)" ></aparte-select> } `,})export class PickerComponent { protected readonly showPicker = true; protected use(value: string): void { console.info(value); }}Three things that follow from the directive, and none of them work through <aparte-ui>:
- No
CUSTOM_ELEMENTS_SCHEMA. The directive claims the tag, so you keep template checking for every other unknown tag in that file — which the schema switches off wholesale. @ifand@forwork on the element, and so does content projection, because the tag is really in the template.- Inputs take
boolean, not'':[searchable]="true"goes through Angular’sbooleanAttributeand the directive writes or removes the attribute for you.
Outputs emit the event’s detail, which is the Angular idiom — (selectChange)="pick($event.value)".
When you need the event itself (to call stopPropagation), add a plain host listener.
There is one directive per element, named Aparte<Element>Directive — every component
page under Components shows the exact symbol in its Angular tab — and
APARTE_ELEMENT_DIRECTIVES imports them all at once.
<aparte-chat> has no directive on purpose: AparteChatComponent already claims that tag and
renders the whole turn.
Your own element, or a plugin’s
Section titled “Your own element, or a plugin’s”The typing above covers @aparte/core’s elements — the ones each wrapper depends on. Nothing else
is in it, including aparté’s own plugins, and that is deliberate: a third-party plugin’s author
cannot add a line to @aparte/core, so shipping typing for our plugins would give our packages a
privilege theirs could never have. The rule is symmetric instead — whoever owns the element owns
its contract and its bindings.
Two mechanisms, and they are the same amount of work for us as for you.
React, Vue and Svelte: type the tag from your own package
Section titled “React, Vue and Svelte: type the tag from your own package”All three learn a tag through module augmentation, and the augmentation does not have to come
from us. Put it in your own .d.ts and it applies exactly when your package is in the program —
install it and the tag is typed, don’t and it isn’t. TypeScript enforces that, nobody has to.
AparteTemplateAttrs is exported for this: it takes any interface of yours and gives back the
template spelling, so you inherit the presence-attribute rule rather than rediscovering it.
import type { AparteTemplateAttrs } from '@aparte/core';
interface MyWidgetAttributes { label?: string; compact?: boolean }
declare module 'vue' { interface GlobalComponents { 'my-widget': import('vue').DefineComponent<AparteTemplateAttrs<MyWidgetAttributes>>; }}Events need nothing from us at all — augment HTMLElementEventMap with your own detail type and
e.detail is typed everywhere, in every framework, the same way core’s own events are.
Angular: a directive, and it is six lines
Section titled “Angular: a directive, and it is six lines”Angular has no types-only path: claiming a tag needs a directive class, which is runtime code. The
one non-obvious part is already exported — applyElementProps is core’s attribute-versus-property
rule, which is what makes a presence attribute land as attr="" and a false remove it:
import { Directive, ElementRef, Input, booleanAttribute, inject } from '@angular/core';import { applyElementProps } from '@aparte/core';
@Directive({ selector: 'my-widget', standalone: true })export class MyWidget { private readonly host = inject(ElementRef<HTMLElement>); @Input() set label(v: string | undefined) { this.write('label', v); } @Input({ transform: booleanAttribute }) set compact(v: boolean) { this.write('compact', v); } private write(name: string, value: unknown): void { applyElementProps(this.host.nativeElement, { [name]: value }); }}A plugin that wants to spare its users those six lines ships them itself, and
@aparte/plugin-model-selector is the worked
example: one subpath per framework, @angular/core an optional peer, and the directive generated
from its own manifest by the same script that generates core’s. Run
scripts/gen-element-bindings.mjs against your package’s manifest and you get the same output.
If you would rather not, CUSTOM_ELEMENTS_SCHEMA still works, and so does <aparte-ui> below.
One thing to know either way: a hyphenated tag is legal HTML whether or not anything defines it, so an element whose package you never imported mounts empty and inert with no error, and upgrades on its own the moment the definition arrives. That is what makes lazy plugin loading work — and it means the types promise a shape, never a definition.
<aparte-ui> is the escape hatch, not the default
Section titled “<aparte-ui> is the escape hatch, not the default”Every wrapper still ships AparteUi, a pass-through that mounts any element by name and
forwards its events. It exists for an element aparté does not define — one of yours, or a
third-party web component:
<AparteUi name="my-token-counter" props={{ 'data-budget': '8000' }} onElementEvent={log} />name is a string, props is an untyped bag, and the element is created imperatively — so no
control flow or projection reaches it. For core’s elements the typed surface above is strictly
better; for anything else, the two mechanisms in the previous section beat it as soon as you care
about types. <aparte-ui> earns its place when you want none of that ceremony for a one-off.
On the server
Section titled “On the server”A custom element extends HTMLElement, so it cannot exist without a DOM — but importing
@aparte/core on a server is fine, and that is the part worth stating plainly because the
two facts sound contradictory.
A node export condition resolves the same specifier to a DOM-free entry, so
import '@aparte/core' works in Node, in an Electron main process, or during an SSR pass
(Next, Nuxt, SvelteKit, Angular Universal) with no DOM shim:
// Same specifier. The `node` condition picks the DOM-free build.const { AparteClient, createAparteChatHandler, contentToText } = await import('@aparte/core');You keep the client, the chat host, the transports and createAparteChatHandler, the
conversation and message runtime, config, the parsers, and every type. You lose the
custom elements themselves; registerAllComponents() is a safe no-op there.
Reading src/index.ts is misleading on this point — that is the browser entry, the one
that defines the elements, and the workspace resolves it first by design. The contract is
enforced by pnpm check:node-import, which imports the built packages in real Node on every
CI run, rather than being promised here.
What each wrapper does about it, which is not the same thing
Section titled “What each wrapper does about it, which is not the same thing”The core contract above is uniform. What the four wrappers do on top of it is not, and a page that implied otherwise would send a Nuxt reader looking for a bug that is a missing line:
| Wrapper | On a server |
|---|---|
| React | AparteChat.tsx opens with 'use client', so the Next App Router keeps it out of the server pass for you. |
| Angular | provideAparte() guards autoConnect with typeof window !== 'undefined', so Angular Universal boots without touching the DOM. |
| Vue | Nothing in the wrapper. Under Nuxt, import it in a client-only context yourself. |
| Svelte | Nothing in the wrapper. Under SvelteKit, same — keep the import on the client. |
Nothing here is a hard failure of the library: the elements are browser-only by nature, and the two wrappers with no guard simply leave that to you. It is written down so that leaving it to you is a decision you can see rather than one you discover.
Testing your components
Section titled “Testing your components”The same node condition that makes the server safe is what breaks a test runner, and the
symptom does not look like a resolution problem at all: vitest, jest and friends run on
Node, so they take the node condition, get the DOM-free entry, and no <aparte-*>
element ever upgrades. Your jsdom document.createElement('aparte-chat') returns a plain
HTMLElement, every assertion about the element’s own properties fails, and nothing on the
page says why.
@aparte/core/browser is the entry with the elements in it, by name. Point your runner at
it:
import { defineConfig } from 'vitest/config';
export default defineConfig({ test: { environment: 'jsdom', alias: [ // Node resolves `@aparte/core` to the DOM-free entry — correct for SSR, useless // under jsdom, where you want the elements to register. // // The array form matches on a REGEX, and that is the point: an object alias is a // PREFIX alias, so `'@aparte/core': '@aparte/core/browser'` would also rewrite // `@aparte/core/icons` and `@aparte/core/styles.css` to paths under `/browser` // that the exports map does not carry. { find: /^@aparte\/core$/, replacement: '@aparte/core/browser' }, ], },});registerAllComponents() says so out loud if you forget: called on the DOM-free entry with
a DOM present, it logs one warning naming this specifier. It is a warning and not a throw —
the environment is legal, only surprising.
Two notes. The main entry is deliberately left alone: . must keep resolving node first,
because that is what makes import '@aparte/core' safe in Next, Nuxt, SvelteKit and Angular
Universal. And @aparte/core/package.json is exported too, so a config that would rather
compute the path than hardcode it can call require.resolve('@aparte/core/package.json').
Where the facts come from
Section titled “Where the facts come from”The attribute and event surface of every element is in the generated element reference, including each event’s detail type. Both are produced from the custom-elements manifest, which is built from the element source — so the reference, the types on this page and the elements themselves cannot drift apart.