# Team conversation

Build room chat with the room's shared state and capability-aware controls.

Import: @slices.sh/realtime/headless

Public parts: 33

## Installation

### 1. Install the service registry

services/index.ts, which `import "@/services"` loads. A provider setup writes the same file; skip this if your app has one.

```bash
npx shadcn@latest add "https://slices.sh/r/services.json"
```

### 2. Install the headless library

```bash
npm install @slices.sh/realtime@^0.1.6 @slices.sh/composer@^0.1.4
```

### 3. Install the service

Install the provider configuration via shadcn or the slices CLI. Skip this step if your app already has the service configured. Provider commands and provisioning: https://slices.sh/docs/services/realtime.md

## Anatomy

### Composition

```text
Room.Root room="standup"
  Room.Transcript
    Room.NoMessages
    Room.Timeline
      Room.Marker
      Room.Run
        Room.RunMessages
          Room.Message
            Room.MessageAuthor
            Room.MessageAvatar
            Room.MessageBody
            Room.MessageTime
            Room.MessageSegments
              Room.SegmentText
              Room.SegmentLink
            Room.Quote
            Room.Reply
            Room.Sending
            Room.NotSent
              Room.Retry
            Room.MessageReactions
              Room.ReactionToggle
            Room.ReactTo emoji="❤️"
    Room.Messages
    Room.NewMessages
      Room.ScrollToLatest
  Room.Composer
    Room.ReplyDraft
      Room.ReplyingTo
      Room.CancelReply
    Room.MessageInput
    Room.MessageField
    Room.Send
  Room.Typing
```

## Send messages

```tsx
"use client"

import "@/services"
import { Room } from "@slices.sh/realtime/headless"
import { Composer } from "@slices.sh/composer"

export function Example() {
  return (
    <Room.Root room="studio">
      <Room.Messages>
        <Room.Message>
          <Room.MessageAuthor />
          <Room.MessageBody />
        </Room.Message>
      </Room.Messages>
      <Composer.Root>
        <Composer.Input aria-label="Message" />
        <Composer.Submit>Send</Composer.Submit>
      </Composer.Root>
    </Room.Root>
  )
}

```

Room.Root receives the Composer message and publishes it to the room.

## Complete API reference

### Room.Transcript

content · Element: div

The scrolling region a conversation arrives in.

A log, so a screen reader reads a message as it lands rather than never: `role="log"` is
the one live region defined for exactly this, and it announces additions instead of
re-reading the transcript. `data-state` is `pinned` or `scrolled`, which is what a skin
shows a "jump to latest" over.

The region rather than the list, and the messages keep a `<ul>` of their own inside it: a
role replaces whatever the element was, so a `<ul role="log">` is a list no longer — the
bubbles inside it stop being items and a screen reader reads the conversation as one run
of text with no boundaries in it. (WAI-ARIA, "Presentational Children" and role conflict)

A tab stop, because a region that scrolls and cannot be focused cannot be scrolled by
anybody using a keyboard: the whole conversation above the fold is unreachable, which is
WCAG 2.1.1 and the reason a chat log is the APG's own example of this. It needs a name,
and the name is a word, so the screen gives it. (WCAG 2.4.3, 4.1.2)

Import: @slices.sh/realtime/headless

Room.Transcript must be inside <Room.Root>.

```tsx
PartProps<"div", { pinned: boolean; unseen: number }>
```

Source: @slices.sh/realtime/src/headless/transcript.tsx

### Room.NoMessages

gate

Nothing has been said since you arrived, which for this service is the usual start.

Import: @slices.sh/realtime/headless

Room.NoMessages must be inside <Room.Root>.

```tsx
GateProps
```

Source: @slices.sh/realtime/src/headless/no-messages.tsx

### Room.Timeline

scope

The conversation in order: each turn of one person's messages, and each marker between —
somebody arriving, leaving, starting to present. Each item gates itself: `Room.Run` draws
a turn and `Room.Marker` a marker, so the list is listed once and branches nowhere.

Import: @slices.sh/realtime/headless

Room.Timeline must be inside <Room.Root>.

```tsx
TimelineProps

type TimelineProps = RootProps & {
  /** What the items are grouped in: a Motion presence group lets a message arrive, not appear. */
  group?: ComponentType<{ children?: ReactNode }>
}
```

Source: @slices.sh/realtime/src/headless/timeline.tsx

### Room.Marker

content · Element: li

Something that happened between messages: somebody arrived, left or started presenting.
`data-state` is the event. The words are the pattern's, from the state.

Import: @slices.sh/realtime/headless

Room.Marker must be inside <Room.Timeline>.

```tsx
PartProps<"li", ChatMarker>
```

Source: @slices.sh/realtime/src/headless/marker.tsx

### Room.Run

content · Element: li

One person's turn: their consecutive messages within five minutes, under one name.

The first message is in scope for the whole turn, so `Room.MessageAuthor`, `Room.MessageAvatar`
and `Room.MessageTime` placed on the turn say who and when once. `data-state` is `mine` or
`theirs`, which is what a skin sides a turn by.

Import: @slices.sh/realtime/headless

Room.Run must be inside <Room.Timeline>.

```tsx
PartProps<"li", { mine: boolean }>
```

Source: @slices.sh/realtime/src/headless/run.tsx

### Room.RunMessages

scope

The messages of one turn, each in its own scope with where it is: sending, sent or failed.

Import: @slices.sh/realtime/headless

Room.RunMessages must be inside <Room.Run>.

```tsx
RunMessagesProps

type RunMessagesProps = RootProps & {
  /** What the messages are grouped in: a Motion presence group lets each one arrive. */
  group?: ComponentType<{ children?: ReactNode }>
}
```

Source: @slices.sh/realtime/src/headless/run-messages.tsx

### Room.Message

content · Element: li

One message. `data-state` is `mine` or `theirs`, which is what a skin sides by; a message of
yours still on its way is `data-pending`, and one that never went is `data-invalid`.
`data-flash` is set for a moment when a quote of it is followed here.

Import: @slices.sh/realtime/headless

Room.Message must be inside <Room.Messages>.

```tsx
PartProps<"li", { mine: boolean }>
```

Source: @slices.sh/realtime/src/headless/message.tsx

### Room.MessageAuthor

content · Element: span

Who said it.

Looked up in the roster rather than carried on the message, because a name the sender put
in the message would be the name they had when they sent it — and a rename mid-conversation
would leave half the transcript addressed to somebody who no longer exists.

Import: @slices.sh/realtime/headless

Room.MessageAuthor must be inside <Room.Messages>.

Room.MessageAuthor must be inside <Room.Root>.

```tsx
PartProps<"span">
```

Source: @slices.sh/realtime/src/headless/message-author.tsx

### Room.MessageAvatar

content · Element: span

The face of whoever said it, their picture or else their initials, with their colour as
`--peer` and its ink as `--peer-ink`.

Its own part rather than `Room.PeerAvatar` inside a message, because a message outlives
the person: somebody who left is still in the transcript and is in nobody's roster, and a
part that needed a peer scope would have to throw for them. Here they keep a colour
derived from the id the message carries, so the conversation does not change shape when
they close the tab. → ./peer-look.ts

Import: @slices.sh/realtime/headless

Room.MessageAvatar must be inside <Room.Messages>.

Room.MessageAvatar must be inside <Room.Root>.

```tsx
PartProps<"span">
```

Source: @slices.sh/realtime/src/headless/message-avatar.tsx

### Room.MessageBody

content · Element: span

Transcript is the chat's scroller, role="log". Timeline lists turns and markers in order; Run is one person's consecutive messages under one name and RunMessages its messages; Marker is an arrival, a departure or a presentation. Messages and Message are the flat alternative. MessageAuthor, MessageAvatar, MessageBody and MessageTime describe the message in scope, and MessageSegments splits its text into SegmentText and SegmentLink. NoMessages, NewMessages and ScrollToLatest handle the empty list and the reader who scrolled up. Nothing is kept: the chat lives as long as the room.

Import: @slices.sh/realtime/headless

Room.MessageBody must be inside <Room.Messages>.

```tsx
PartProps<"span">
```

Source: @slices.sh/realtime/src/headless/message-body.tsx

### Room.MessageTime

content · Element: time

When it was said.

`RoomMessage.at` has been on the wire since the first version of this service and no
screen ever showed it. A `<time>` with the full moment in `dateTime` and the hour and
minute as text: the long form is what assistive technology and a copy-paste get, and the
short form is what a bubble has room for.

Import: @slices.sh/realtime/headless

Room.MessageTime must be inside <Room.Messages>.

```tsx
PartProps<"time">
```

Source: @slices.sh/realtime/src/headless/message-time.tsx

### Room.MessageSegments

scope

A message's text as words and links, each in its own scope.

Import: @slices.sh/realtime/headless

Room.MessageSegments must be inside <Room.Messages>.

```tsx
RootProps
```

Source: @slices.sh/realtime/src/headless/message-segments.tsx

### Room.SegmentText

content

Words, as they were typed. Nothing for a link, which `Room.SegmentLink` draws.

Import: @slices.sh/realtime/headless

Room.SegmentText must be inside <Room.MessageSegments>.

```tsx
PartProps<null>
```

Source: @slices.sh/realtime/src/headless/segment-text.tsx

### Room.SegmentLink

content · Element: a

A link somebody sent, opened somewhere else: a chat that navigated away from itself on a
click would be a meeting somebody just left. `rel` keeps the other page from reaching back.

Import: @slices.sh/realtime/headless

Room.SegmentLink must be inside <Room.MessageSegments>.

```tsx
PartProps<"a">
```

Source: @slices.sh/realtime/src/headless/segment-link.tsx

### Room.Quote

action · Element: button

What this message answers, as it was when it was answered: a button that scrolls to the
original while the list still holds it. Nothing on a message that answers nobody.

The name is looked up in the roster, like an author's, and the one the quote carried is
for somebody who has since left. → ./message-author.tsx

Import: @slices.sh/realtime/headless

Room.Quote must be inside <Room.Root>.

```tsx
PartProps<"button", QuoteState>

type QuoteState = { name: string; excerpt: string }
```

Source: @slices.sh/realtime/src/headless/quote.tsx

### Room.Reply

action · Element: button

Answer this message: the composer quotes it, and the field takes the cursor.

Import: @slices.sh/realtime/headless

Room.Reply must be inside <Room.RunMessages>.

Room.Reply must be inside <Room.Root>.

```tsx
PartProps<"button">
```

Source: @slices.sh/realtime/src/headless/reply.tsx

### Room.Sending

gate

This message of yours is waiting for the room: reconnecting, or not joined yet.

Import: @slices.sh/realtime/headless

```tsx
GateProps
```

Source: @slices.sh/realtime/src/headless/sending.tsx

### Room.NotSent

gate

This message of yours never found its way to the room, and is still here to try again.

Import: @slices.sh/realtime/headless

```tsx
GateProps
```

Source: @slices.sh/realtime/src/headless/not-sent.tsx

### Room.Retry

action · Element: button

Send a failed message again, under the id it already has, so it cannot arrive twice.

Import: @slices.sh/realtime/headless

Room.Retry must be inside <Room.RunMessages>.

Room.Retry must be inside <Room.Root>.

```tsx
PartProps<"button">
```

Source: @slices.sh/realtime/src/headless/retry.tsx

### Room.MessageReactions

scope

The emoji left on this message, each once with everybody who left it.

Import: @slices.sh/realtime/headless

Room.MessageReactions must be inside <Room.Messages>.

Room.MessageReactions must be inside <Room.Root>.

```tsx
RootProps
```

Source: @slices.sh/realtime/src/headless/message-reactions.tsx

### Room.ReactionToggle

action · Element: button

One emoji on a message and how many left it: pressing it adds yours or takes it back.

Import: @slices.sh/realtime/headless

Room.ReactionToggle must be inside <Room.MessageReactions>.

Room.ReactionToggle must be inside <Room.Root>.

```tsx
PartProps<"button", ReactionState>

type ReactionState = { emoji: string; count: number; names: string[]; mine: boolean }
```

Source: @slices.sh/realtime/src/headless/reaction-toggle.tsx

### Room.ReactTo

action · Element: button

Leave this emoji on the message in scope, or take it back.

Import: @slices.sh/realtime/headless

Room.ReactTo must be inside <Room.Messages>.

Room.ReactTo must be inside <Room.Root>.

```tsx
PartProps<"button"> & { emoji: string }
```

Source: @slices.sh/realtime/src/headless/react-to.tsx

### Room.Messages

scope

What has arrived while this browser has been in the room, and nothing else.

A late joiner sees an empty list, which is what the service promises rather than a bug.
→ services/realtime/ports.ts

Import: @slices.sh/realtime/headless

Room.Messages must be inside <Room.Root>.

```tsx
RootProps
```

Source: @slices.sh/realtime/src/headless/messages.tsx

### Room.NewMessages

gate

Something arrived while somebody was reading further up.

Only then: a button offering to jump to the bottom of a list that is already at the
bottom is a button that does nothing, and one offering "new messages" when none have
arrived is a lie. → docs/rules/capability-honesty.md

Import: @slices.sh/realtime/headless

Room.NewMessages must be inside <Room.Transcript>.

```tsx
GateProps<{ unseen: number }>
```

Source: @slices.sh/realtime/src/headless/new-messages.tsx

### Room.ScrollToLatest

action · Element: button

Back to the newest message, and following again from there.

Import: @slices.sh/realtime/headless

Room.ScrollToLatest must be inside <Room.Transcript>.

```tsx
PartProps<"button", { unseen: number }>
```

Source: @slices.sh/realtime/src/headless/scroll-to-latest.tsx

### Room.Composer

action · Element: form

The one form a message is sent from.

It reads its input by name on submit rather than tracking every keystroke, so the input
stays uncontrolled and a skin may put anything around it.

Import: @slices.sh/realtime/headless

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

```tsx
PartProps<"form">
```

Source: @slices.sh/realtime/src/headless/composer.tsx

### Room.ReplyDraft

content · Element: div

Where a composer says which message it is answering, for as long as it is: nothing while it
writes to everybody. `Room.ReplyingTo` names the message and `Room.CancelReply` lets it go.
Answering another message is a new draft, so it arrives as one.

Import: @slices.sh/realtime/headless

Room.ReplyDraft must be inside <Room.Root>.

```tsx
PartProps<"div"> & { group?: GateGroupComponent }
```

Source: @slices.sh/realtime/src/headless/reply-draft.tsx

### Room.ReplyingTo

content · Element: span

The message being answered: who said it and a line of what they said, for its children.

Import: @slices.sh/realtime/headless

Room.ReplyingTo must be inside <Room.Root>.

```tsx
PartProps<"span", ReplyingState>

type ReplyingState = { name: string; excerpt: string }
```

Source: @slices.sh/realtime/src/headless/replying-to.tsx

### Room.CancelReply

action · Element: button

Write to everybody again rather than answer one message. Escape in the field does the same.

Import: @slices.sh/realtime/headless

Room.CancelReply must be inside <Room.Root>.

```tsx
PartProps<"button">
```

Source: @slices.sh/realtime/src/headless/cancel-reply.tsx

### Room.MessageInput

input · Element: input

What is being typed. `name="message"` is what the composer reads.

Import: @slices.sh/realtime/headless

```tsx
PartProps<"input">
```

Source: @slices.sh/realtime/src/headless/message-input.tsx

### Room.MessageField

input · Element: textarea

What is being typed, over as many lines as it takes: Enter sends, Shift+Enter breaks the
line, and nothing is sent in the middle of composing a character with an input method.
`name="message"` is what the composer reads. Typing here tells the room you are.

Choosing a message to answer brings the cursor here, and Escape stops answering it.

Import: @slices.sh/realtime/headless

```tsx
PartProps<"textarea">
```

Source: @slices.sh/realtime/src/headless/message-field.tsx

### Room.Send

action · Element: button

The one submit in the composer: everything else beside it is `type="button"`.
→ docs/rules/one-submit-per-form.md

Enabled while the room is joining or reconnecting as well as connected, because a message
typed then is kept and sent the moment the room is back, drawn as sending until it goes.
Disabled only where it could never go: a room that refused, failed or was left.
→ docs/rules/capability-honesty.md

Import: @slices.sh/realtime/headless

Room.Send must be inside <Room.Root>.

```tsx
PartProps<"button", { connected: boolean }>
```

Source: @slices.sh/realtime/src/headless/send.tsx

### Room.Typing

gate

Somebody else is typing, and who. Not announced: a live region saying it per keystroke is
noise, and the message itself is what gets said when it lands.

Your other tab is you, whether or not the screen counts people: a line saying you are
typing tells you nothing, and under your own name it reads "You is typing".

Import: @slices.sh/realtime/headless

Room.Typing must be inside <Room.Root>.

```tsx
GateProps<{ names: string[] }>
```

Source: @slices.sh/realtime/src/headless/typing.tsx

### Room.Root

scope

The room everything under here is in.

Joining happens in the provider's effect rather than here, so a component that renders
twice before it commits does not put two peers in the roster for one tab.

A root inside a root for the same room shares its connection, and its follow too: a
presence list placed on a canvas has to move the canvas when somebody on it is picked, and
two follows would be two lists of whom somebody is following. A root inside a root for the
same room needs no service of its own, and says nothing when there is none.

Optional with no service, or off, there is no room: its status is "off", so every gate stays
shut and `Room.Present` hides what the room is drawn inside, and its sink is switched off, so
a Composer inside renders nothing. → ../hooks/use-realtime-scope.ts

Import: @slices.sh/realtime/headless

```tsx
RoomRootProps

type RoomRootProps = RootProps & RealtimeRootProps & {
  room: string
  /**
   * Who this browser shows itself as. Without one, a visitor gets an animal of their own; and
   * somebody signed in is their session's name and picture whatever this says.
   */
  profile?: PeerProfile
  /**
   * One person across this browser's tabs: every tab takes the same name, the one a screen
   * that counts people shows once. Off, each tab is somebody of its own.
   */
  perPerson?: boolean
  /** Off, the transport never asks for a camera or a microphone. */
  media?: boolean
  /** Captions start shown; each person can still hide them for themselves. */
  captions?: boolean
}
```

Source: @slices.sh/realtime/src/headless/room-root.tsx
