Skip to content
SLICES / TECHNICAL FOLIO Nº 001FULL-STACK ASSEMBLIES — OPEN SOURCE

Headless / Input components

Message composer

One generic form for every place a message is sent: a chat, a room, a comment thread, a plain form posted to a server action. It knows nothing about chats, uploads or dictation. It collects fields, holds the submit while any field is busy, and hands the message to the nearest sink or to its own onSubmit. Two contracts, field and sink, are all it relies on. It replaces Chat.Composer, Room.Composer and Upload.Composer, and the chat-attachments and chat-dictation bridges.

8 public parts · 2examples

@slices.sh/composerView as Markdown ↗

Your design. Our behavior.

Explore the tree to see what each part controls. Gates reveal the relevant state; roots outline their scope.

Behavior / inputGateGenericRenderer · not headlessScope
COMPONENT ANATOMYHover · focus · tap
ONE POSSIBLE DESIGNStatic preview

Composer.Surface · Generic

Composer.SurfaceA wider place than the form that still hands files to it: a drop or a paste anywhere on a chat panel goes to the field named `attachments` of the Composer inside it. The field is found when the event arrives, through the forms below, so nothing here knows about uploads.

Installation

Choose what your component needs. Run these commands in an initialized Next.js app.

0

Before you start

The steps below run in a Next.js app with shadcn set up (it has a components.json). In an existing Next.js app, such as one from create-next-app, set shadcn up first:

npx shadcn@latest init -t next -b base -p nova

Or start fresh: the slices CLI creates the app and sets it up.

npx slices.sh install
1

Install the headless library

npm

Add the React parts to your app, then compose them with your own elements.

npm install @slices.sh/composer@^0.1.4

Import from @slices.sh/composer.

These parts run locally. No service installation or provisioning is needed.

Start small

Parts bring the behavior. Use their default elements, or supply your own with render.

example.tsx
"use client"import { Composer } from "@slices.sh/composer"export function Example() {  return (    <Composer.Root      onSubmit={async (message) => {        console.log(message)      }}    >      <Composer.Input aria-label="Comment" />      <Composer.Submit>Post comment</Composer.Submit>      <Composer.Pending>Sending…</Composer.Pending>    </Composer.Root>  )}

Replace console.log with your application action. No service is required when you supply onSubmit.

API reference

Every public part used by this experience is listed below. Gates render children without adding an element. Element parts accept native element props and render; function children receive the state declared in their props. Scopes provide context rather than a visual element.

Composer.Rootscope · Generic · <form>

The form every message is sent from. It knows fields by name and one destination, and nothing about chats, rooms, uploads or dictation. → docs/v2/target-docs/DESIGN.md §5 The check for a destination is a component of its own, so a Composer under a switched-off sink renders nothing without having called a hook it will not use.

Props

1 documented props · Native <form> attributes supported

Composer.Root props
PropTypeDefault
children

Content rendered inside this part.

ReactNode—

Required props are marked. A dash means no explicit default is declared in this component.

Source: @slices.sh/composer/src/root.tsx

Composer.Inputinput · Generic · <textarea>

The text of the message, and a field named `text` (`name="…"` changes the key). A textarea rather than an input, because Shift+Enter has to mean something. Enter submits, Shift+Enter breaks the line, and an IME's Enter commits a candidate rather than sending half a word. No `rows`: the skin sizes it with `field-sizing: content`, so it grows with the message and stops at a maximum. Escape stops what the destination is doing and Backspace takes back the last chip it holds beside the draft (`stop` and `removeLast` of the sink), but only from an empty input, and never while composing, while a wrapper holds the field busy, or after a wrapper took the key. It is busy while a wrapper has set `aria-busy="true"` on it, which is how `Dictation.Field` holds the submit while listening without either part knowing the other. It takes `value` and `onChange` from such a wrapper when it is given them, and holds its own text when not.

Props

2 documented props · Native <textarea> attributes supported

Composer.Input props
PropTypeDefault
children

Content rendered inside this part.

ReactNode—
render

Replace the default element with your own while retaining the part’s behavior and accessibility.

ReactElement—

Required props are marked. A dash means no explicit default is declared in this component.

Source: @slices.sh/composer/src/input.tsx

Composer.Submitaction · Generic · <button>

The form's only submit button: everything else in a composer is `type="button"`. → docs/rules/one-submit-per-form.md It stays mounted and enabled while the submit is held, so focus never drops and the layout never moves; `aria-disabled` and `data-held` say it instead, and a press does nothing. Stopping a streaming reply is not a submit: that is the sink's own part.

Required scope

Composer.Submit must be inside <Composer.Root>.

Props

2 documented props · Native <button> attributes supported

Composer.Submit props
PropTypeDefault
children

Content rendered inside this part.

ReactNode | ((state: { held: boolean; pending: boolean }) => ReactNode)—
render

Replace the default element with your own while retaining the part’s behavior and accessibility.

ReactElement—

Required props are marked. A dash means no explicit default is declared in this component.

Source: @slices.sh/composer/src/submit.tsx

Composer.Surfacecontent · Generic · <div>

A wider place than the form that still hands files to it: a drop or a paste anywhere on a chat panel goes to the field named `attachments` of the Composer inside it. The field is found when the event arrives, through the forms below, so nothing here knows about uploads. With no such field (the service is off, the sink takes no files, there is no Composer) every event goes by untouched. Events are forwarded even when an element inside already took them: the field decides what an event that its own drop area handled means, since it counts enters and leaves across both.

Props

3 documented props · Native <div> attributes supported

Composer.Surface props
PropTypeDefault
field

The field that takes what lands here.

string"attachments"
children

Content rendered inside this part.

ReactNode—
render

Replace the default element with your own while retaining the part’s behavior and accessibility.

ReactElement—

Required props are marked. A dash means no explicit default is declared in this component.

Source: @slices.sh/composer/src/surface.tsx

Composer.Errorgate · Generic

Renders after the sink rejected, or `accept` refused a field. The draft is still there.

Required scope

Composer.Error must be inside <Composer.Root>.

Props

1 documented props

Composer.Error props
PropTypeDefault
children

Content rendered inside this part.

ReactNode | ((state: { error: globalThis.Error; code: string | null }) => ReactNode)—

Required props are marked. A dash means no explicit default is declared in this component.

Source: @slices.sh/composer/src/gates.tsx

Composer.Pendinggate · Generic

Renders while `sink.submit` is in flight: a slow room, a server action.

Required scope

Composer.Pending must be inside <Composer.Root>.

Props

1 documented props

Composer.Pending props
PropTypeDefault
children

Content rendered inside this part.

ReactNode—

Required props are marked. A dash means no explicit default is declared in this component.

Source: @slices.sh/composer/src/gates.tsx

Composer.Heldgate · Generic

Renders while the submit is held: a field is busy, or the sink says `hold` and is.

Required scope

Composer.Held must be inside <Composer.Root>.

Props

1 documented props

Composer.Held props
PropTypeDefault
children

Content rendered inside this part.

ReactNode | ((state: { reason: "field" | "sink"; fields: string[] }) => ReactNode)—

Required props are marked. A dash means no explicit default is declared in this component.

Source: @slices.sh/composer/src/gates.tsx

SinkProvidercontent · Generic

Offers a sink to every Composer below it. A root that can receive messages renders this around its children. `null` is a sink switched off: an optional root whose service is missing says so, and a Composer below renders nothing instead of throwing `slices/no-sink`.

Props

2 documented props

SinkProvider props
PropTypeDefault
valueRequired
Sink | null—
children
ReactNode—

Required props are marked. A dash means no explicit default is declared in this component.

Source: @slices.sh/composer/src/sink.tsx

When something is missing

No service required
These parts run locally. Their parent contexts and required props still apply.
Missing capability or parent scope
Follow each part’s context requirements and the example composition. Capability-aware controls hide when the provider cannot perform their action.
Read the setup guide →