Skip to content

Composer

<aparte-composer> — with 7 parts: <aparte-composer-input>, <aparte-composer-send>, <aparte-composer-cancel>, <aparte-composer-action>, <aparte-composer-add-attachment>, <aparte-composer-attachments>, <aparte-composer-toolbar>

The root context for every aparte-composer-* primitive. It imposes no visual layout — the consumer owns the structure — and holds the shared state the parts read: the value, the streaming flag, pending attachments, whether a panel is up.

It renders nothing of its own — no shadow root, no markup, no default children — so an <aparte-composer> with nothing inside is an empty block. The parts that need that state locate it with closest('aparte-composer'), which is why they may sit at any depth and why the opt-in .aparte-composer-shell / .aparte-composer-row wrappers can exist without this element knowing about them. Not every part looks it up, though: <aparte-composer-toolbar> is purely structural — it lays its children out and never resolves this element at all.

WHAT GOES INSIDE — ordinary light-DOM children. Core has no shadow root and no <slot>, so there is no slot name to write: drop in <aparte-composer-input>, <aparte-composer-send>, <aparte-composer-cancel>, <aparte-composer-attachments>, <aparte-composer-add-attachment>, <aparte-composer-action>, <aparte-composer-toolbar>, plus whatever markup you wrap them in. Order and nesting are yours. Two behaviours read the tree rather than a flag, so they depend on what you put in: focus() forwards to the first <aparte-composer-input> descendant, and showPanel() inserts the panel right after it (appending to the host when there is none).

A PANEL is neither markup you write nor a named slot: showPanel() takes the element, stamps it data-aparte-panel and inserts it — inside the descendant you marked data-aparte-panel-host if there is one, else right after the first input — and hidePanel() removes it. One at a time — a second showPanel() evicts the first and calls its onEvict. While one is up the host carries [data-panel-active], which hides <aparte-composer-input> and <aparte-composer-add-attachment> and leaves the attachments strip and the toolbar in place.

It is not a transport either. submit() trims, checks the gates (disabled, empty, no model selected), dispatches aparte-send and clears — nothing here talks to a model, so without AparteClient or a listener of your own a send is a dispatched event and no answer. With no panel up it doubles as the stop button: while streaming it routes to cancel(), which is why the getState example below keeps a custom send button clickable rather than disabling it mid-stream. With a panel up it means “answer the question” instead — it calls the panel’s onSubmit and returns, so neither the stop branch nor a send is reached.

The streaming flag comes from WINDOW lifecycle events, filtered by target: aparte-message-start sets it, and any of aparte-message-done / aparte-message-error / aparte-message-aborted clears it (and evicts an open panel). AparteClient dispatches them on the chat host, bubbling, so they reach both this element’s window listener and the host-bound readers; a host that runs its own loop dispatches the same two (start, done) the same way, with detail.targetId set to this composer’s target — that is the whole contract, documented under “Make the composer follow your turn” in the bring-your-own-loop guide. On a page with two chats, give the composer a target — or put it under a chat host that has an id — otherwise it answers to every chat’s events, and one chat’s Stop resets the other’s composer and evicts its open panel.

Prose first, on purpose: when @element opens a docblock there is no free text left for the analyser to use, and this component’s description came out empty in the manifest and blank on the generated reference page.

aparte-abort and aparte-message-aborted have to be declared by hand and always will: they go out through window.dispatchEvent (they concern the whole page, not this subtree), and the analyser’s fallback only recognises this.dispatchEvent.

Width Open in a tab
The real element, in its own document so this site's CSS cannot reach it. It renders the same example printed below — every framework mounts this same element, so the rendering is what you get in all five; the code is what changes.
<!-- It renders nothing of its own — no shadow root, no default children — so this
markup IS the component. The shell draws the border; the row keeps the controls
on the bottom edge of the text as it grows. Both are opt-in classes: drop them
and the parts still work, they just sit wherever your own layout puts them. -->
<aparte-composer placeholder="Ask anything…">
<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>
// A custom send button. Keep it CLICKABLE while streaming — submit()
// routes to cancel() when a response is in flight, so one button is
// Send/Stop. Disabling it on `streaming` would make "stop" unreachable.
composer.addEventListener('aparte-composer-change', (e) => {
const { streaming, disabled, value, attachments } = e.detail.state;
myButton.textContent = streaming ? 'Stop' : 'Send';
myButton.disabled = disabled || (!streaming && !value.trim() && attachments.length === 0);
});
myButton.addEventListener('click', () => composer.submit()); // send or stop

<aparte-composer-input>

Contenteditable text input primitive.

The element owns its subtree: on connect it writes one .aparte-ci-editor contenteditable and binds its listeners to that node, so children you place inside are replaced. There is nothing to project here — style the generated editor through the CSS variables below, or replace the whole primitive.

Enter submits and Shift+Enter inserts a newline; submit-on-enter="false" on the composer inverts that mapping, and Enter never submits mid-IME-composition — the key that confirms a CJK candidate must not send the message. The editor auto-expands with its content up to max-height, then scrolls. Paste is intercepted: text lands as plain text with its markup stripped, and a pasted image goes to the composer’s attachments.

Without an <aparte-composer> ancestor it still works, and that is deliberate: a submitting Enter then dispatches aparte-composer-submit instead of calling root.submit(), which is how the bubble’s inline editor reuses this primitive. Everything the root owns goes with it though — the mirrored value, the placeholder fallback, the disabled sync and image paste all need the composer. The editor stays editable while a reply streams (the next message is typed while the current one arrives); only the send is gated then — Enter is swallowed, the button is Stop.

Not a <textarea> and not a stand-in for one: being a contenteditable it has no form value, no name and no native validation, and getValue() returns trimmed text with <br> serialized back to newlines. Use it for the chat draft, not as a form control.

<aparte-composer>
<div class="aparte-composer-shell">
<div class="aparte-composer-row">
<aparte-composer-input placeholder="Ask anything…" max-height="320"></aparte-composer-input>
<aparte-composer-send></aparte-composer-send>
</div>
</div>
</aparte-composer>

<aparte-composer-send>

Submit button primitive for &lt;aparte-composer>.

One control, three meanings: send, stop while the root is streaming, and — when an elicitation panel is open — submit the answer; a panel whose mode is 'none' (its options settle on the click) takes the button out of the layout entirely. The panel outranks streaming: while one is open the button stays the answer control and a streaming change is ignored. The icon moves with the meaning (paper plane, square, check), because a paper plane that means “answer” is a lie. All three are decided by the root’s state — its value, attachments, disabled, streaming and the panel payload it broadcasts — not by anything this element owns, which is why it recomputes its chrome rather than re-rendering: a rebuild would put a paper plane back mid-stream, drop out of answer mode, and take the focus off the control most likely to be holding it.

“Empty” counts attachments: a pending attachment with no text still enables the button, because that is a message the composer can send.

It owns its subtree — the button is generated on connect and children placed inside are replaced, so there is nothing to project. The host element itself is display: contents, so it adds no box: the layout comes from whatever flex row you put it in, and the CSS variables below style the inner button.

It needs an <aparte-composer> ancestor: without one the button renders disabled, no root event ever reaches it, and a click has nothing to submit to.

It is not the place to gate on model selection: the opt-in aparteGlobalConfig.setRequireModelSelection() gate already blocks this element’s pointer events through aparte-composer[data-model-gated].

<!-- One button for both halves of the turn: it submits, and while a reply streams it
becomes the stop button. -->
<aparte-composer>
<div class="aparte-composer-row">
<aparte-composer-input></aparte-composer-input>
<aparte-composer-send></aparte-composer-send>
</div>
</aparte-composer>

<aparte-composer-cancel>

Cancel/stop streaming button primitive for &lt;aparte-composer>.

Most composers should not use this element. <aparte-composer-send> already becomes the stop button while a reply streams, so adding this one gives you a second, equally working way to stop; reach for it only when you want stop to live somewhere the send button is not.

It renders hidden, and only the root reveals it — the root.streaming check on connect, then each streaming-change — so it needs an <aparte-composer> ancestor to be reachable at all: standalone, nothing flips hidden and the click has no cancel() to call. A locale or icon-set change is re-read in place rather than re-rendered, for the same reason: a rebuild renders it hidden again, making the stop button vanish mid-turn.

It owns its subtree — the button is generated on connect and children placed inside are replaced, so there is nothing to project. The host element is display: contents and adds no box of its own; the row you put it in provides the layout, and the CSS variables below style the inner button, which is deliberately a quiet action button rather than a filled one.

<!-- Only needed when you want a SEPARATE stop button: <aparte-composer-send> already
turns into one while streaming. This stays hidden until then. -->
<aparte-composer>
<div class="aparte-composer-row">
<aparte-composer-input></aparte-composer-input>
<aparte-composer-cancel></aparte-composer-cancel>
<aparte-composer-send></aparte-composer-send>
</div>
</aparte-composer>

<aparte-composer-action>

Generic action button primitive for &lt;aparte-composer>.

The consumer declares it directly in markup — no global registration needed.

It is the escape hatch for a button core has no opinion about: it renders one icon button wearing .aparte-action-button (the shared icon-button look — colour from --aparte-neutral, hover tint derived from --aparte-primary) and emits aparte-action-click. It carries no behaviour of its own and nothing in core listens for that event, so the app is the only thing that can make it do something. Prefer the dedicated element wherever one exists — <aparte-composer-send>, <aparte-composer-cancel>, <aparte-composer-add-attachment> — since those already talk to the composer.

The host is display: contents, so the <button> rather than this element is the flex child of the surrounding .aparte-composer-row. It subscribes to the nearest composer’s disabled and streaming changes, so it greys out while a turn is running without the app tracking that. Used outside a composer it still mounts and still fires, with composer: null in the detail.

A child already carrying class="aparte-cact-button" suppresses core’s own render — and core then wires nothing to it: no click listener (so no aparte-action-click), no labelaria-label/title write, no icon write, no disabled/streaming sync. Take that path only for a button your own code drives end to end. Any other child is replaced on the first render.

<!-- Inside a composer, because that is what it resolves with `closest()`. `action-id`
is what tells two custom buttons apart: it comes back on the event's detail, and
a second button without one is indistinguishable from the first. -->
<aparte-composer>
<div class="aparte-composer-shell">
<div class="aparte-composer-row">
<aparte-composer-input></aparte-composer-input>
<aparte-composer-action icon="star" label="Favourite" action-id="favourite"></aparte-composer-action>
<aparte-composer-send></aparte-composer-send>
</div>
</div>
</aparte-composer>
<script>
// The event bubbles, so one listener above the composer serves every action.
document.addEventListener('aparte-action-click', (event) => {
if (event.detail.actionId === 'favourite') console.log('starred');
});
</script>

<aparte-composer-add-attachment>

File picker button for &lt;aparte-composer>.

Opens a native file picker on click, then pushes picked files to root.addAttachments(). Also sets up drag & drop on the nearest &lt;aparte-composer> root.

It only COLLECTS files: it never reads, uploads or renders them. <aparte-composer-attachments> draws the pending strip, and sending is the host’s job (event.detail.files on aparte-send) — which is why the default <aparte-chat> shell only includes this button when the attachments attribute is set. With nothing reading the files, an attach button is an affordance core cannot honour (ratified decision #8).

Drag & drop is installed on the composer ROOT, not on this button, so a drop anywhere over the composer attaches and the root carries aparte-is-dragover while a drag is over it. The dashed outline is drawn on .aparte-composer-shell when the markup has one and on the composer element itself when it does not — width from --aparte-focus-outline-width, colour from --aparte-primary, radius from --aparte-radius-input, none of them declared here. The drop handler always calls preventDefault(), even while disabled, so the browser can never navigate away to the dropped file. disabled on the ROOT removes the drop target and greys the button; streaming does neither — a file queued while a reply arrives is part of preparing the next message, and only the send is gated meanwhile.

The label and the icon are not attributes — they come from the config (t('actionUpload') and the paperclip icon), so a locale or icon-provider change rewrites the existing button in place instead of re-rendering it.

A child already carrying class="aparte-caa-button" suppresses core’s own render — and core then wires nothing to it: no click listener (so no picker opens), and no label, icon or disabled writes. Drag & drop still works, since it is installed on the root regardless. Any other child is replaced on the first render. The file input itself is never a child: it is created on document.body per click and removed again.

<!-- Opt-in: nothing consumes the files unless your host does (an AparteClient, or
your own listener reading `event.detail.files` off `aparte-send`). -->
<aparte-composer>
<div class="aparte-composer-row">
<aparte-composer-add-attachment accept="image/*,.pdf"></aparte-composer-add-attachment>
<aparte-composer-input></aparte-composer-input>
<aparte-composer-send></aparte-composer-send>
</div>
</aparte-composer>

<aparte-composer-attachments>

Renders a square thumbnail tile for each file attached to the root composer.

Image files show the actual picture; other files show an extension badge. The filename and a remove (✗) button surface on hover. Clicking an image asks the app to open it full-size (aparte-attachment-preview) — only when the app declared attachmentPreview via aparteGlobalConfig.setHostHandlers(). Automatically hidden when there are no attachments. It reads the nearest &lt;aparte-composer> ancestor; without one it renders nothing and stays hidden.

This is the PENDING strip: what the user has attached and not yet sent. It mirrors composer.attachments and rewrites itself on every attachments-change — it is not the strip under a sent message, which the bubble draws with the same .aparte-thumb tile rules (minus the remove button), so a tile variable set at the theme root reaches both strips, while one set on this element reaches only this one. It owns its innerHTML and therefore projects nothing: children written inside it are discarded on the first render. Removing a tile calls root.removeAttachment() rather than mutating a list of its own, and the image previews are blob URLs minted per render and revoked on the next one and on disconnect.

<!-- The strip hides itself while nothing is attached. Pair it with the picker, and
only if your loop actually reads the files from the send event. -->
<aparte-composer>
<div class="aparte-composer-shell">
<aparte-composer-attachments></aparte-composer-attachments>
<div class="aparte-composer-row">
<aparte-composer-add-attachment></aparte-composer-add-attachment>
<aparte-composer-input></aparte-composer-input>
<aparte-composer-send></aparte-composer-send>
</div>
</div>
</aparte-composer>

<aparte-composer-toolbar>

The composer’s bottom row — the strip a mode picker, a model selector or a token counter belongs in, rather than a bar of your own floating below the chat. Purely structural: it lays its children out in a row and gets out of the way.

Position is the DOM order. margin-inline-start: auto on a child pushes it (and everything after it) to the end of the row. That is the whole placement API on purpose: there is no left/right to be wrong about, so the row reads correctly in a right-to-left locale without the author thinking about it.

The controls the row is made of can be any element, or plain text. Nothing is wrapped or reordered — the children ARE the row, laid out by flex in DOM order, and they may arrive after connection (a framework commits children in its own order). Non-whitespace TEXT counts as content too, so a hand-written row holding a bare token count stays visible instead of tripping the data-empty hide.

The row is not part of the default <aparte-chat> shell — nothing is drawn until you put something in it.

It declares no custom property of its own: the gap, the padding and the top separator come from the global --aparte-space-* and --aparte-border* tokens, so it inherits a theme rather than exposing knobs to re-set.

<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>
<!-- `aparte-model-selector` is NOT part of core: importing
`@aparte/plugin-model-selector` is what defines it. Until then the tag
renders empty and inert with no error, and upgrades by itself when the
definition arrives. Any element of your own works here too. -->
<aparte-composer-toolbar>
<my-mode-picker></my-mode-picker>
<aparte-model-selector style="margin-inline-start:auto"></aparte-model-selector>
</aparte-composer-toolbar>
</div>
</aparte-composer>
AttributeDescription
placeholderFallback placeholder for <aparte-composer-input>, which reads it off this element when it carries none of its own. Changing it here reaches the inputs already on the page too.
disabledDisables the composer’s own controls — the input, send, add-attachment and <aparte-composer-action> buttons each read it. What you put in the toolbar is yours to disable.
targetThe id of the <aparte-chat> this composer drives.
submit-on-enterEnter sends and Shift+Enter breaks the line (the default); set it to the string "false" to swap them. Read lazily by the submitOnEnter getter rather than observed, which is why it was missing from the manifest — and so from every typed surface — while all four wrappers wrote it.
data-panel-activeReflected BY the element while a panel (an elicitation, an approval) is mounted inside it; the shipped stylesheet keys off it. Read-only.
data-panel-modeReflected BY the element while a panel is active: what the send button does meanwhile ('none' removes it — through this attribute, not by reaching into the button). Read-only.
data-model-gatedReflected BY the element when requireModelSelection is on and no model is selected: sending is blocked and the shipped stylesheet greys the composer. Read-only.
PropertyTypeDescription
value (readonly)string
streaming (readonly)boolean
disabled (readonly)boolean
submitOnEnter (readonly)booleanWhen false, Shift+Enter submits and a bare Enter inserts a newline — the inverse of the default. Driven by the submit-on-enter attribute.
attachments (readonly)File[]
placeholder (readonly)string
targetId (readonly)string | null
panelActive (readonly)boolean
MethodDescription
getState(): AparteComposerStateSnapshot of the composer’s observable state. Pair with the aparte-composer-change DOM event to drive a custom send button or footer control that lives outside the composer package:
setValue(value: string): voidSet the composer’s value — both what a send will submit and what the editor shows. <aparte-composer-input> writes through any value it does not already hold, so this prefills the visible field (a template button, a restored draft) as readily as it stages text for an immediate submit().
addAttachments(files: FileList | File[]): voidAppend files to the pending attachments and notify. Does not de-duplicate.
removeAttachment(file: File): voidDrop one pending attachment and notify. Matched by IDENTITY — pass the same File object the composer handed you, not an equal one; two picks of the same file on disk are two distinct objects.
clearAttachments(): voidDrop every pending attachment and notify.
showPanel(panel: HTMLElement, options?: &#123; submitEnabled?: boolean; onSubmit?: () => void; /** * What the send button means for this panel — ’none’ if it has no act * for it, in which case the button is not drawn. See * &#123;@link AparteComposerPanelMode&#125;. */ mode?: AparteComposerPanelMode; /** Called when something other than this owner closes the panel. */ onEvict?: () => void; &#125;): symbolInject a panel into the composer. The send button calls onSubmit when clicked. While a panel is up, the composer is answering a QUESTION, not composing a message — so the affordances that lead nowhere go away with the text input: the attachment picker above all, which stayed clickable while the user was being asked something (“on voyait encore l’icône de upload”, reported from a real session). Ratified decision #8: an affordance nothing can honour is not rendered. What STAYS, deliberately: the attachments strip, because pending attachments are the user’s state and not an action to offer — hiding them would look like losing them; and the toolbar, because switching model still does something. The send button is the part the PANEL decides, through mode. 'submit' keeps it; 'none' says this panel has no act for it and it is not drawn. That third value is what a panel whose options settle on the first click needs — a single-choice question, an approval — and until it existed such a panel left a permanently disabled button beside options that never routed through it. Flip between them at any time with setPanelSubmitEnabled, which is how a panel that grows an act (an “Other…” field, a written instruction) turns the button back on. Declared with an attribute + CSS rather than the inline style.display this used to set on a child: an attribute is themeable, is visible to a consumer’s own rules, and does not clobber a display the consumer had set (the restore wrote '', not the previous value). Returns the TOKEN for this panel. Pass it back to hidePanel so a presenter that has already lost the slot cannot close the panel that replaced it, and supply onEvict to be told when that happens — an owner that is not told is an owner whose promise nobody can settle.
hidePanel(token?: symbol): voidRemove the panel, tell its owner, and restore the composer’s own controls. With a token, this closes the panel only if that token still owns the slot — so a presenter settling late cannot tear down the panel that replaced it. With no token it closes whatever is there, which is what a consumer driving the composer directly means. BOTH forms notify. The no-token form used to call _teardownPanel directly, which nulls _panelOnEvict without ever calling it — so the documented public hidePanel() closed an open approval panel and left its request pending forever. The turn hung on “waiting for you”, and because AparteConfig.requestUserInput chains each request on the previous one, NO further question or approval on that config was ever presented again, for the life of the page. The old JSDoc justified the silent branch as “what reset() needs”. It was not: reset() calls _evictPanel(), which notifies. The branch had no consumer and one failure mode. The two forms differ on purpose, and the difference is who already knows: - With a matching token the OWNER is closing its own panel, which is what <aparte-elicitation>’s close() does right after it resolves. It must NOT be notified — telling it “you were evicted” for a request it just settled would fire onEvict against a finished promise. - With no token somebody else is closing a panel they do not own, so the owner cannot know and has to be told. That includes the presenter’s own defensive hidePanel(undefined) when it settles before showPanel ran: whatever is open then belongs to another request, and that request must not orphan.
setPanelSubmitEnabled(enabled: boolean, mode?: AparteComposerPanelMode): voidUpdate the send button’s state while a panel is active. mode moves with it because both can change on the same event — opening the “Other…” field of a panel whose options settle on the click gives the button an act it did not have — and two separate calls would flash a wrong state between them.
submit(): voidSubmit the current value. Called by aparte-composer-send or programmatically.
cancel(): voidCancel the current streaming response.
reset(): voidReset the composer to its initial state. Clears value, attachments, and hides any active panel. Call this when switching conversations.
focus(): voidFocus the input primitive inside this composer.
EventTypeDescription
aparte-sendCustomEvent&lt;AparteSendEventDetail>A message was submitted: the text, its attachments and the target.
aparte-cancelCustomEventThe stop button was pressed. No detail; the two window events below carry the target.
aparte-composer-changeCustomEvent&lt;AparteComposerChangeEventDetail>Any of value / streaming / disabled / attachments / panel changed, folded into one event.
aparte-abortCustomEvent&lt;AparteAbortEventDetail>Dispatched on window: stop the run for this target.
aparte-message-abortedCustomEvent&lt;AparteMessageAbortedEventDetail>The run for this target ended early — the user pressed Stop, or abort() was called. This element dispatches it on window; AparteClient also dispatches it on the chat host, so it is listenable on either.
AttributeDescription
placeholderPlaceholder text (fallback: reads from aparte-composer)
max-heightMax height in px before scroll (default: 200)
min-heightMin height in px. When omitted, the stylesheet’s min-height governs (44px in aparte.css) — so themes can resize the editor in pure CSS without being fought by an inline height.
disabledMakes the field non-editable; the composer’s own disabled also reaches it.
MethodDescription
getValue(): stringThe editor’s text, with <br> serialized back to newlines — textContent alone would collapse a multi-line draft onto a single line.
setValue(value: string): voidReplace the editor’s content and mirror the value onto the parent composer.
clear(): voidEmpty the editor and mirror the empty value onto the parent composer.
syncPlaceholder(): voidRe-read the placeholder fallback chain. Called by the composer when ITS placeholder changes — this element observes only its own attribute, and the composer’s is one of the three sources it falls back to.
focus(): voidFocus the inner contenteditable rather than the host element.
blur(): voidBlur the inner contenteditable rather than the host element.
focusEnd(): voidFocus the editor and place the caret at the very end of its content.
EventTypeDescription
aparte-composer-submitCustomEventA submitting Enter was pressed with no <aparte-composer> ancestor to submit to; with one it calls root.submit() and dispatches nothing. No detail — the host that placed this primitive reads getValue().
AttributeDescription
iconIcon key for aparteGlobalConfig.getIcon(), or raw SVG/HTML starting with <
labelAccessible label (also used as tooltip)
disabledDisables the button
action-idIdentifies WHICH button fired; carried as AparteActionClickEventDetail.actionId. Read lazily at dispatch time rather than observed, so changing it takes effect on the next click.
EventTypeDescription
aparte-action-clickCustomEvent&lt;AparteActionClickEventDetail>Bubbles up when the button is clicked, carrying which button it was and the composer it belongs to. The type argument is not decoration: a BARE @fires records CustomEvent with no argument, and the bindings generator then emits EventEmitter<void> with a listener that drops $event — so an Angular consumer with two custom buttons could not tell which one fired. detail: { actionId: string, composer: AparteComposer | null }
AttributeDescription
acceptMIME types / extensions passed to the file input (e.g. “image/*,.pdf”)
multipleAllow multiple file selection (default: true)
disabledGreys out the picker. Drops are gated by the composer root’s disabled, not by this one.
EventTypeDescription
aparte-attachment-previewCustomEvent&lt;AparteAttachmentPreviewEventDetail>An attached image was clicked; the app opens it full-size, and only if it declared attachmentPreview.
AttributeDescription
data-emptyReflected BY the element while it holds neither an element child nor non-whitespace text; the stylesheet hides it then. Read-only, do not set it yourself.

Override any of these on :root, on a subtree, or on one instance — custom properties inherit downward. Some are this element’s own; others are site-wide tokens that also style it, and overriding one of those at :root moves everything that reads it. The full set is in the CSS variables reference.

VariableDefaultDescription
--aparte-composer-control-sizevar(--aparte-btn-size-lg)Width and height of the composer’s own control buttons (each is an .aparte-btn--icon, so it needs the opt-in .aparte-composer-row wrapper, which is what carries the size down) and the minimum height of the input’s editor, which needs no wrapper. One knob for the whole control set, so buttons stay aligned with a single line of text and anchored to the bottom once the input grows. It reaches the buttons by declaration, not by out-specifying them, so a panel mounted in the row keeps its own content’s sizing.
--aparte-input-bgvar(--aparte-surface-1)Background of the opt-in .aparte-composer-shell wrapper.
--aparte-input-bordervar(--aparte-border)Border colour of that shell. Its :focus-within colour is --aparte-primary, a global token rather than a composer one.
--aparte-radius-inputvar(--aparte-radius-lg)Corner radius of the shell, and of the dashed outline drawn while files are dragged over the composer.
--aparte-message-max-width800pxMax width of the shell, which is margin: 0 auto at this width — the same width .aparte-message uses, so the composer keeps the transcript’s column. Set on THIS element it moves the shell only: custom properties inherit downward and the transcript is a sibling subtree, so set it on a shared ancestor (the chat host, :root) to move both.
--aparte-viewport-paddingvar(--aparte-space-8)Inline gutter between this element and the chat’s edge — the same token the transcript reads, so the shell keeps .aparte-message’s column BELOW the max-width above as well as above it, where the two would otherwise diverge by the transcript’s whole inset. Only the inline halves are read here: the block axis is spaced by the viewport above and --aparte-chat-bottom-gap below. A container narrower than 520px tightens the transcript’s copy of this token but not the composer’s — a container query cannot reach this element, which is its own container root.
VariableDefaultDescription
--aparte-composer-control-sizevar(--aparte-btn-size-lg)Single-line min-height of the editor. Inside the .aparte-composer-row layout helper the composer’s buttons read the same token, so one value resizes that whole control set and the row stays aligned.
--aparte-input-padding-ycalc((var(--aparte-composer-control-size) - 1lh) / 2)Vertical padding inside the editor.
--aparte-input-padding-xvar(--aparte-space-6)Horizontal padding inside the editor.
--aparte-input-font-sizevar(--aparte-font-size-base)Editor font size.
--aparte-input-line-heightvar(--aparte-line-height-normal)Editor line height — also what the auto-expand measures, so changing it changes the height the editor settles at (until max-height clamps it).
--aparte-textText and caret colour of the editor.
--aparte-input-placeholderColour of the placeholder drawn by :empty::before (falls back to --aparte-text-muted).
--aparte-input-bgField background, applied only when this input is the bubble’s inline editor (.aparte-message[data-editing]) — inside a composer the shell paints the surface instead.
--aparte-input-borderBorder colour of that same edit-mode box.
--aparte-radius-inputvar(--aparte-radius-lg)Corner radius of the edit-mode box.
--aparte-input-focus-borderBorder colour of the edit-mode box while it holds focus (:focus-within).
VariableDefaultDescription
--aparte-composer-control-sizevar(--aparte-btn-size-lg)Width/height of the button inside the .aparte-composer-row layout helper, shared with the input’s single-line height so the row stays aligned. It wins over --aparte-send-btn-size there.
--aparte-send-btn-sizevar(--aparte-btn-size-lg)Width/height of the button outside that row helper. On coarse pointers it is raised to --aparte-touch-target-size.
--aparte-touch-target-size44pxHit-area floor applied to the button under @media (pointer: coarse).
--aparte-radius-send-btnvar(--aparte-radius-md)Corner radius of the button.
--aparte-primaryButton background.
--aparte-primary-hoverButton background on hover, while enabled.
--aparte-on-primaryThe glyph’s colour. Undeclared by default, which means the recipe derives it from --aparte-primary itself, so a theme that changes the fill gets a readable glyph with no second edit. Declare it to choose one — it then applies to every primary control, which is the honest scope.
--aparte-ink-flip0.57Fill lightness at which the derived ink flips from dark to light, for every solid control.
--aparte-ink-dark0.176How dark that derived ink goes. Not 0: at zero lightness OKLCH drops the chroma, and the ink loses the fill’s own hue.
--aparte-send-disabled-bgBackground while disabled (falls back to --aparte-primary, which is then dimmed by opacity).
VariableDefaultDescription
--aparte-composer-control-sizevar(--aparte-btn-size-lg)Width/height of the button inside the .aparte-composer-row layout helper, shared with the composer’s other controls so the row stays aligned.
--aparte-radius-action-btnvar(--aparte-radius-sm)Corner radius of the button.
--aparte-neutralIcon colour at rest (the button’s background is transparent).
--aparte-textIcon colour on hover.
--aparte-surface-2Button background on hover.
VariableDefaultDescription
--aparte-input-action-btn-sizevar(--aparte-btn-size-lg)Square size of the button. On a coarse pointer the stylesheet re-sets it to --aparte-touch-target-size (44px) on .aparte-action-button itself, which wins over a value inherited from your theme.
--aparte-input-action-btn-icon-size20pxSize of the <svg> inside it.
--aparte-radius-action-btnvar(--aparte-radius-sm)Corner radius.
VariableDefaultDescription
--aparte-input-action-btn-sizevar(--aparte-btn-size-lg)Square size of the button. On a coarse pointer the stylesheet re-sets it to --aparte-touch-target-size (44px) on .aparte-action-button itself, which wins over a value inherited from your theme.
--aparte-input-action-btn-icon-size20pxSize of the <svg> inside it.
--aparte-radius-action-btnvar(--aparte-radius-sm)Corner radius.
VariableDefaultDescription
--aparte-attachments-max-height140pxHeight cap on the strip; past it the tiles scroll instead of pushing the composer up.
--aparte-attachment-image-size72pxTile edge. The stylesheet sets 56px on this element (the :root default is 72px, and the sent-message strip re-sets 40px on itself), so a theme-level value reaches neither strip — target aparte-composer-attachments to resize these tiles.
--aparte-thumb-radiusvar(--aparte-radius-lg)Tile corner radius.
--aparte-attachment-chip-bgvar(--aparte-surface-2)Tile background, seen behind a non-image file.
--aparte-attachment-chip-bordervar(--aparte-border)Tile border colour.
--aparte-thumb-name-color#ffffffFilename colour on the hover overlay.
--aparte-thumb-name-scrimlinear-gradient(to top, rgba(0, 0, 0, 0.82), rgba(0, 0, 0, 0))Background behind the filename; a bottom-up black gradient by default, so the name stays legible over any picture.
--aparte-thumb-name-padding14px 5px 4pxPadding of that overlay.
--aparte-thumb-remove-size18pxDiameter of the ✗ button.
--aparte-thumb-remove-inset3pxIts inset from the tile’s top and right edges (physical right, so it does not flip in a right-to-left locale).
--aparte-thumb-remove-bgrgba(0, 0, 0, 0.6)Its background.
--aparte-thumb-remove-bg-hoverrgba(0, 0, 0, 0.85)Its hover background.
--aparte-thumb-remove-color#ffffffIts glyph colour.

The element is the same object everywhere — the tag does not change. What changes is how an attribute is written and how an event reaches you.

<aparte-composer disabled=""></aparte-composer>
el.addEventListener('aparte-send', (e) => use(e.detail));

Installation and the framework-specific traps: React · Vue · Svelte · Angular.