# Join a room and see who is here

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

Import: @slices.sh/realtime/headless

Public parts: 37

## 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
```

Only for "A room conversation": `npm install @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
Join.Root room="standup"
  Join.Waiting
    Join.Name
    Join.Toggle kind="video"
    Join.Level
    Join.Inside
      Join.Faces max={4}
    Join.Submit
  Join.Joined
    Room.Root room="standup"
      Room.Joining
      Room.Failed
      Room.MissingConfiguration
      Room.Full
      Room.Left
        Room.Rejoin
      Room.Present
        Room.Reconnecting
        Room.Connected
          Room.Roster
            Room.RosterSearch
            Room.NoMatches
            Room.Peers
              Room.Peer
                Room.PeerAvatar
                Room.PeerName
                Room.PeerStatus
                Room.PeerActivity
                Room.NotMe
                Room.Unreachable
                Room.IsMe
          Room.Me
          Room.PeerCount
          Room.MorePeers
          Room.Alone
          Room.Announcer
          Room.Leave
```

## Who's here

```tsx
"use client"

// services/index.ts comes from https://slices.sh/r/services.json (or any provider setup)
import "@/services"
import { Room } from "@slices.sh/realtime/headless"

export function Example() {
  return (
    <Room.Root room="lobby">
      <Room.Peers>
        <Room.Peer>
          <Room.PeerAvatar />
          <Room.PeerName />
        </Room.Peer>
      </Room.Peers>
      <Room.Reconnecting>Reconnecting…</Room.Reconnecting>
    </Room.Root>
  )
}

```

People in the same room see each other. The provider handles presence and reconnection.

## A room conversation

```tsx
"use client"

// services/index.ts comes from https://slices.sh/r/services.json (or any provider setup)
import "@/services"
import { Room } from "@slices.sh/realtime/headless"
import { Composer } from "@slices.sh/composer"

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

```

Room.Root is Composer's sink. Combine this input with the room's transcript parts.

## Join a room

```tsx
"use client"

import "@/services"
import { Join, Room } from "@slices.sh/realtime/headless"

export function Example() {
  return (
    <Join.Root room="studio">
      <Join.Waiting>
        <Join.Name aria-label="Your name" />
        <Join.Toggle kind="audio">Microphone</Join.Toggle>
        <Join.Submit>Join room</Join.Submit>
      </Join.Waiting>
      <Join.Joined>
        <Room.Root room="studio">
          <Room.Peers>
            <Room.Peer>
              <Room.PeerName />
            </Room.Peer>
          </Room.Peers>
        </Room.Root>
      </Join.Joined>
    </Join.Root>
  )
}

```

Configure realtime. Media, captions and moderation controls hide when the provider does not grant their capability.

## Complete API reference

### Join.Root

scope

Root holds the preview tracks and the chosen name, and hands them to the room. Toggle kind="camera" | "microphone" switches the preview. Level writes the preview microphone's level as --level for a meter; @slices.sh/voice-canvas can draw the same signal as bars. Name is the name field, prefilled from auth when it is configured. Inside counts who is in the room and Faces shows them. Submit joins.

Import: @slices.sh/realtime/headless

```tsx
JoinRootProps

type JoinRootProps = RootProps & RealtimeRootProps & {
  room: string
  /** Once somebody has gone in: where a page sends them to the room, when it is elsewhere. */
  onJoined?: () => void
}
```

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

### Join.Waiting

gate

Still at the door.

Import: @slices.sh/realtime/headless

Join.Waiting must be inside <Join.Root>.

```tsx
GateProps
```

Source: @slices.sh/realtime/src/headless/join-waiting.tsx

### Join.Name

input · Element: input

The name you go in under. Left empty, you go in as the animal its placeholder shows.

Import: @slices.sh/realtime/headless

Join.Name must be inside <Join.Root>.

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

Source: @slices.sh/realtime/src/headless/join-name.tsx

### Join.Toggle

action · Element: button

The camera or the microphone, at the door. `data-active` while it is on.

Its own part rather than `Room.MediaToggle`, which is disabled until there is a room to send
into: here there never is one, and the whole point is to try the camera before there is.

Import: @slices.sh/realtime/headless

Join.Toggle must be inside <Join.Root>.

```tsx
JoinToggleProps

type JoinToggleProps = PartProps<"button", { on: boolean }> & { kind: "audio" | "video" }
```

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

### Join.Level

content · Element: span

Your microphone at the door, as `--level` from 0 to 1: say something and watch it move.

Import: @slices.sh/realtime/headless

Join.Level must be inside <Join.Root>.

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

Source: @slices.sh/realtime/src/headless/join-level.tsx

### Join.Inside

gate

Who is already in, in words: "Amara and Ben are in this call". Nothing where the room could
not be seen into, because saying "nobody is here" about a room nobody could look at would
be a guess dressed as a fact. → docs/rules/capability-honesty.md

Import: @slices.sh/realtime/headless

Join.Inside must be inside <Join.Root>.

```tsx
GateProps<Inside>

type Inside = { names: string[]; count: number; full: boolean }
```

Source: @slices.sh/realtime/src/headless/join-inside.tsx

### Join.Faces

scope

The faces of whoever is inside, each in a peer's scope, so `Room.PeerAvatar` and
`Room.PeerName` draw them as they would in the room.

Import: @slices.sh/realtime/headless

Join.Faces must be inside <Join.Root>.

```tsx
JoinFacesProps

type JoinFacesProps = RootProps & {
  /** The most faces to show; the words beside them say how many more. */
  max?: number
  group?: ComponentType<{ children?: ReactNode }>
}
```

Source: @slices.sh/realtime/src/headless/join-faces.tsx

### Join.Submit

action · Element: button

Goes in, with the camera, microphone and name chosen here. `data-pending` once pressed,
while the page the room is on loads.

Import: @slices.sh/realtime/headless

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

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

Source: @slices.sh/realtime/src/headless/join-submit.tsx

### Join.Joined

gate

Through the door: where the room itself goes, when it is on the same page.

Import: @slices.sh/realtime/headless

Join.Joined must be inside <Join.Root>.

```tsx
GateProps
```

Source: @slices.sh/realtime/src/headless/join-joined.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

### Room.Joining

gate

Still finding the room.

Import: @slices.sh/realtime/headless

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

```tsx
GateProps
```

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

### Room.Failed

gate

The room could not be joined, for a reason nothing here can mend.

`denied` is told apart from the rest because the remedies are opposite: one is a
permission, the other is a retry. A full room is not here at all — it is `Room.Full`,
because it is the one refusal that mends itself when somebody leaves. A server that lacks
configuration is not here either: it is `Room.MissingConfiguration`, which says so in words
for somebody to report, instead of this one's "could not be reached" beside it.

Import: @slices.sh/realtime/headless

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

```tsx
GateProps<{ denied: boolean }>
```

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

### Room.MissingConfiguration

gate

The server that holds the rooms lacks a variable it needs, such as its room table.

`Configuration.Missing` for this family, narrowed to the room service: a call that also
transcribes could have a transcription key missing, and that is the captions' notice, not a
room's. It stands where `Room.Failed` would, and works outside a root as well, because a
door has learned it from its first look at the room before anybody has joined anything.

Import: @slices.sh/realtime/headless

```tsx
GateProps<{ scopes: readonly string[] }>
```

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

### Room.Full

gate

The room is full.

Its own gate rather than a kind of failure, because it is the one refusal that is nobody's
fault and fixes itself: a mesh holds as many people as `services/realtime/config.ts` says
it holds, and the next person to leave makes room. The transport keeps retrying, so what
belongs here is a sentence and a way to try now — never an apology for a broken app.

Import: @slices.sh/realtime/headless

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

```tsx
GateProps
```

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

### Room.Left

gate

This browser closed the room on purpose.

The service calls it `idle`, which is also what it calls a room nothing has been tried on
yet, and the two owe a person opposite screens: one is a spinner, and this one is a door
back in. Without it a meeting somebody left became an empty grid with a live control bar.

Import: @slices.sh/realtime/headless

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

```tsx
GateProps
```

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

### Room.Rejoin

action · Element: button

Take the room again.

Nothing at all when the room cannot be reopened — one handed in from outside is somebody
else's to reopen — because a button that cannot work is worse than no button.
→ docs/rules/capability-honesty.md

It takes the focus when it appears into a page whose focus has fallen to nothing, which is
what pressing Leave does: the button that had the focus is gone with the rest of the room,
and somebody on a keyboard was left starting again from the top of the page. (WCAG 2.4.3)

Import: @slices.sh/realtime/headless

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

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

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

### Room.Present

gate

In the room, or on the way in: everything but having left it or been refused it.

What a room is drawn inside — its tiles, its controls, what it says about a camera — so
that leaving takes all of it away and `Room.Left` has the screen to itself. Without it the
grid and the control bar stayed up behind "You left the meeting", still showing everybody
who was in it. Being refused (unreachable, denied, full: a full room is a failed status)
takes the room away the same way, and so does no service to join (an optional root without
one), so `Room.Failed` and `Room.Full` are not drawn under a
"Live" header and controls that reach nobody.

Import: @slices.sh/realtime/headless

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

```tsx
GateProps
```

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

### Room.Reconnecting

gate

The link dropped and is being rebuilt.

Its own state rather than a kind of failure, because nothing is lost and nobody has to do
anything: a banner offering "Try again" here would be offering what is already happening.

Import: @slices.sh/realtime/headless

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

```tsx
GateProps
```

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

### Room.Connected

gate

In the room, with a live signalling path.

Import: @slices.sh/realtime/headless

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

```tsx
GateProps
```

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

### Room.Roster

scope

A list of people somebody can search, for a room too big to scan.

Import: @slices.sh/realtime/headless

```tsx
RootProps
```

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

### Room.RosterSearch

input · Element: input

Narrows the roster around it to the names that contain what is typed.

Import: @slices.sh/realtime/headless

Room.RosterSearch must be inside <Room.Roster>.

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

Source: @slices.sh/realtime/src/headless/roster-search.tsx

### Room.NoMatches

gate

Somebody searched the roster and nobody's name has that in it.

Import: @slices.sh/realtime/headless

Room.NoMatches must be inside <Room.Roster>.

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

```tsx
GateProps<{ query: string }>
```

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

### Room.Peers

scope

Who is here, each in their own scope: you first, then everybody in the order they came.

Peers are connections, so two tabs are two of them. Collapsing per person is a display
decision and lives here rather than in the wire format, which is why it is a prop. Inside
a `Room.Roster`, only the names matching its search are listed.

Import: @slices.sh/realtime/headless

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

```tsx
PeersProps

type PeersProps = RootProps & {
  /** One row per person rather than per connection: two tabs become one entry. */
  perPerson?: boolean
  /** Whether you are in the list. A face pile usually wants you out of it. */
  includeMe?: boolean
  /**
   * The most items a pile may take, counting its "+N". Past it the pile shows one face fewer
   * and `Room.MorePeers` takes the last place, so the row keeps the width it was given.
   */
  max?: number
  /** Only those a pile of this size leaves out: what its "+N" opens. */
  overflowOf?: number
  /** @deprecated A hard cut with no room kept for the count; use `max`. */
  limit?: number
  /**
   * What the items are grouped in. A Motion presence group here is what lets a face leave
   * rather than vanish, without this part knowing anything about animation.
   */
  group?: ComponentType<{ children?: ReactNode }>
}
```

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

### Room.Peer

content · Element: li

One peer's row or tile. `data-active` while the connection is actually live.

Import: @slices.sh/realtime/headless

Room.Peer must be inside <Room.Peers>.

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

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

### Room.PeerAvatar

content · Element: span

A peer's face: their picture once it has loaded, their initials until then and for good if
it never does, with their colour as `--peer` and the ink that reads on it as `--peer-ink`,
for the skin to paint with. → ./peer-look.ts

The picture is painted as the face's background and the initials are dropped when it
arrives, so a skin's size, shape and colour work unchanged and a slow or dead address
costs nothing but the picture. → ./use-peer-picture.ts

Named after the person rather than after the two letters standing in for them: a pile of
these is the whole of a presence list, and without a name it reads out as "AB CD EF".
The name is the peer's own and is never a word a skin or a copy chooses, so it is locked
here rather than asked for. (WCAG 1.1.1)

Import: @slices.sh/realtime/headless

Room.PeerAvatar must be inside <Room.Peers>.

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

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

### Room.PeerName

content · Element: span

Peers repeats the people here, up to max, and Peer is one scope. PeerName, PeerAvatar (with --peer colour), PeerStatus (the data path, in words), PeerActivity (here, idle, away) describe the peer in scope. PeerCount counts them. IsMe, NotMe and Me pick out you. MorePeers gives { hidden, names } for the ones a pile leaves out. Unreachable renders for a peer this browser has no route to, which needs a relay. Roster, RosterSearch and NoMatches make a searchable list for a big room.

Import: @slices.sh/realtime/headless

Room.PeerName must be inside <Room.Peers>.

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

Source: @slices.sh/realtime/src/headless/peer-name.tsx

### Room.PeerStatus

content · Element: span

Whether this peer's data path is open, in words.

The dot beside an avatar carries the most interesting fact on the screen — recorded in
the roster, or actually reachable — and carries it in a colour, which is no use to
anybody who cannot see it. Usually rendered visually hidden beside the dot. (WCAG 1.4.1)

`reachable` is `null` while the link is still being made, which is a third thing and not
a shade of the other two: for a second or two after somebody joins, nobody knows yet.
→ docs/rules/capability-honesty.md

Import: @slices.sh/realtime/headless

Room.PeerStatus must be inside <Room.Peers>.

```tsx
PartProps<"span", { reachable: boolean | null }>
```

Source: @slices.sh/realtime/src/headless/peer-status.tsx

### Room.PeerActivity

content · Element: span

Where a peer's attention is — here, idle, away — and what they say they are doing.

One part for the dot and the words both, because the dot is the words in a colour: a skin
draws the dot from `data-state` and a copy says it from the state, so the two can never
disagree. `data-state` is absent while the peer has said nothing, which draws no dot rather
than a green one nobody earned. → docs/rules/capability-honesty.md

Import: @slices.sh/realtime/headless

Room.PeerActivity must be inside <Room.Peers>.

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

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

type ActivityState = { status: PeerStatus | null; activity: string | null }
```

Source: @slices.sh/realtime/src/headless/peer-activity.tsx

### Room.NotMe

gate

The person in scope is somebody else — where a control about them makes no sense for you.

Import: @slices.sh/realtime/headless

Room.NotMe must be inside <Room.Peers> or <Room.Seats>.

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

```tsx
GateProps
```

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

### Room.Unreachable

gate

The room names this peer and this browser has no route to them.

Told apart from a camera that is off, which looks identical and means the opposite: one
is a choice somebody made and the other is a network that needs a relay. A mesh over STUN
alone cannot reach a peer behind a symmetric NAT at all, and the only honest thing a tile
can do is say so. → resources/turn/README.md

Also told apart from a link that is still being made, which looks identical again and is
what every peer's first second in the room looks like: a badge that appeared the instant
somebody joined and went away when their channel opened would accuse the network of
something ordinary, and be ignored by the time it was true.
→ hooks/realtime/use-link-settled.ts

Import: @slices.sh/realtime/headless

Room.Unreachable must be inside <Room.Peers>.

```tsx
GateProps
```

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

### Room.IsMe

gate

The peer in scope is you — or your other tab, which is you too.

Import: @slices.sh/realtime/headless

Room.IsMe must be inside <Room.Peers>.

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

```tsx
GateProps
```

Source: @slices.sh/realtime/src/headless/is-me.tsx

### Room.Me

gate

You, in the scope of a peer: for the one place on a screen that is yours alone, beside
the faces of everybody else rather than first among them. Nothing until you have joined.

Import: @slices.sh/realtime/headless

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

```tsx
GateProps
```

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

### Room.PeerCount

content · Element: span

How many are here and reachable, counting you. → per-person.ts `reachableIn`

Import: @slices.sh/realtime/headless

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

```tsx
PeerCountProps

type PeerCountProps = PartProps<"span", { count: number }> & {
  /** People rather than connections, so somebody in two tabs is counted once. */
  perPerson?: boolean
}
```

Source: @slices.sh/realtime/src/headless/peer-count.tsx

### Room.MorePeers

content · Element: span

The people a pile leaves out: how many, and who.

Who as well as how many, because a "+5" nobody can expand is a number. The names reach the
content function, so a screen can say them to a screen reader and open them in a list.
Nothing at all when everybody fits.

Import: @slices.sh/realtime/headless

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

```tsx
MorePeersProps

type MorePeersProps = PartProps<"span", MorePeersState> & {
  /** The same number the pile beside it was given as its `max`. */
  max?: number
  /** @deprecated The pile's old hard cut; use `max`. */
  limit?: number
  perPerson?: boolean
  includeMe?: boolean
}

type MorePeersState = { hidden: number; names: string[] }
```

Source: @slices.sh/realtime/src/headless/more-peers.tsx

### Room.Alone

gate

You are in the room and nobody else is: the moment a screen can say what it needs a second
connection for. Another tab of yours is somebody here, which is how a person alone tries a
room out. Somebody the roster names that nothing reaches is not here, as Room.PeerCount does not
count them. Not while joining or failed, when nobody being here is not yet, or not ever, the
story.

Import: @slices.sh/realtime/headless

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

```tsx
GateProps
```

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

### Room.Announcer

content · Element: p

What just happened in the room, for somebody who cannot see it happen.

A polite live region, always in the DOM and usually empty: a region that appears with its
first sentence is a region a screen reader was not watching when the sentence arrived.
The words are the pattern's, because they are words. → docs/rules/words-in-copy-looks-in-skin.md

Import: @slices.sh/realtime/headless

```tsx
PartProps<"p", { news: RoomNews | null }>
```

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

### Room.Leave

action · Element: button

Close the room. Nothing to do once it is closed, which is what `Room.Rejoin` is for.

Import: @slices.sh/realtime/headless

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

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

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