Skip to content
Open

The composer is a set of parts you arrange yourself. <aparte-composer> holds the state — the text, the pending files, whether a reply is streaming — and every part reads it from there, at any depth. It draws nothing of its own: the two classes in the example, .aparte-composer-shell and .aparte-composer-row, give it the usual look, and without them the parts still work wherever your layout puts them.

Inside an <aparte-chat>, the message goes to the chat’s client. On its own, listen for aparte-send on the composer.

Open
  1. Pending files
  2. Attach
  3. Input
  4. Send and stop
  5. Toolbar
  6. Action
Open

<aparte-composer-add-attachment> opens the file picker and makes the whole composer a drop target; <aparte-composer-attachments> shows the files waiting to be sent. They reach you on aparte-send, as event.detail.files: reading or uploading them is your app’s job.

Open

<aparte-composer-toolbar> is the row under the field for a model picker, a mode switch or your own buttons. Children keep their DOM order; margin-inline-start: auto on one pushes it, and everything after it, to the end of the row. An <aparte-composer-action> is an icon button that only emits aparte-action-click: what it does is up to you.

Open

The send button becomes a stop button, and a click cancels the turn. The client sets streaming for you; this demo sets it by hand. If stop should live somewhere else as well, add <aparte-composer-cancel>.

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

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

The field and the button read the input and button tokens, so a theme reaches them with no selector: --aparte-input-bg, --aparte-input-border, --aparte-radius-input and --aparte-input-focus-border for the field, --aparte-primary for the button, and --aparte-composer-control-size for the height they share. Every variable is listed under API below.

Enter sends and Shift+Enter starts a new line; submit-on-enter="false" swaps the two. A button of your own can send or stop through composer.submit(), which cancels instead while a reply streams:

js
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());

<aparte-composer> · class AparteComposer

Attributes

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.

Properties

PropertyTypeDescription
value (readonly)string
streaming (readonly)boolean
disabledbooleanThe attribute is the state, so the property writes it. It has to exist: React 19 and Svelte assign the PROPERTY whenever the element has one of that name, and a getter alone made <aparte-composer disabled=""> — a spelling the wrappers type as valid — throw and take the render down. presenceOn because '' is what those templates hand a presence property (#62).
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[]
placeholderstringWrites the attribute the inputs read (see disabled for why the setter exists).
targetId (readonly)string | null
panelActive (readonly)boolean

Methods

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?: { 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 * {@link AparteComposerPanelMode}. */ mode?: AparteComposerPanelMode; /** Called when something other than this owner closes the panel. */ onEvict?: () => void; }): 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.

Events

EventTypeDescription
aparte-sendCustomEvent<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<AparteComposerChangeEventDetail>Any of value / streaming / disabled / attachments / panel changed, folded into one event.
aparte-abortCustomEvent<AparteAbortEventDetail>Dispatched on window: stop the run for this target.
aparte-message-abortedCustomEvent<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.

CSS variables

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.

<aparte-composer-input> · class AparteComposerInput

Attributes

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.

Methods

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.

Events

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

CSS variables

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) - var(--aparte-input-line-height) * 1em) / 2)Vertical padding inside the editor: half of what the control size leaves once one line is taken out (1lh where the browser has it).
--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). Keep it unitless (1.5, not 24px): the editor’s padding reads it as a multiple of 1em where the browser has no lh unit.
--aparte-text—Text and caret colour of the editor.
--aparte-input-placeholder—Colour of the placeholder drawn by :empty::before (falls back to --aparte-text-muted).
--aparte-input-bg—Field 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-border—Border colour of that same edit-mode box.
--aparte-radius-inputvar(--aparte-radius-lg)Corner radius of the edit-mode box.
--aparte-input-focus-border—Border colour of the edit-mode box while it holds focus (:focus-within).

<aparte-composer-send> · class AparteComposerSend

CSS variables

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-primary—Button background.
--aparte-primary-hover—Button background on hover, while enabled.
--aparte-on-primary—The 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-bg—Background while disabled (falls back to --aparte-primary, which is then dimmed by opacity).

<aparte-composer-cancel> · class AparteComposerCancel

CSS variables

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-neutral—Icon colour at rest (the button’s background is transparent).
--aparte-text—Icon colour on hover.
--aparte-surface-2—Button background on hover.

<aparte-composer-action> · class AparteComposerAction

Attributes

AttributeDescription
iconIcon key for aparteGlobalConfig.getIcon(), or raw SVG/HTML starting with <. A key core’s glyph set does not hold renders AS TEXT (icon="star" draws the word star), so bring your own set through setIconProvider or pass the SVG.
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.

Events

EventTypeDescription
aparte-action-clickCustomEvent<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 }

CSS variables

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.

<aparte-composer-add-attachment> · class AparteComposerAddAttachment

Attributes

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.

CSS variables

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.

<aparte-composer-attachments> · class AparteComposerAttachments

Events

EventTypeDescription
aparte-attachment-previewCustomEvent<AparteAttachmentPreviewEventDetail>An attached image was clicked; the app opens it full-size, and only if it declared attachmentPreview.

CSS variables

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.

<aparte-composer-toolbar> · class AparteComposerToolbar

Attributes

AttributeDescription
data-emptyReflected BY the element while it holds neither a visible element child nor non-whitespace text; the stylesheet hides it then. A child that hides itself — hidden, or its own data-empty the way <aparte-context> does before its first usage — does not count: a row whose only child shows nothing is an empty band with a border. Read-only, do not set it yourself.
  • The field is a multi-line textbox, named by its placeholder.
  • The send button’s name follows what it does: Send, Stop while a reply streams, Submit when a question panel is open. All three are locale keys (sendButton, stopButton, submitButton).
  • Enter never sends in the middle of an IME composition, so confirming a Chinese or Japanese candidate does not send the message.
  • Chat — the composer inside a full conversation.
  • Suggestions — starters that fill the composer.
  • Model selector — the picker for the toolbar.