# Follow a teammate

Build cursors & following with the room's shared state and capability-aware controls.

Import: @slices.sh/realtime/headless

Public parts: 14

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

### 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.CursorArea
    Room.CursorSurface
      Room.Cursors
        Room.Cursor
          Room.CursorLabel
          Room.CursorMessage
      Room.CursorChat
        Room.CursorChatInput
        Room.CursorChatSent
      Room.CursorHints
        Room.CursorHint
  Room.Peers
    Room.Follow
  Room.Following
    Room.StopFollowing
```

## Minimal composition

```tsx
"use client"

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

export function Example() {
  return (
    <Room.Root room="studio">
      <Room.CursorArea>
        <Room.CursorSurface />
        <Room.Cursors>
          <Room.Cursor>
            <Room.CursorLabel />
          </Room.Cursor>
        </Room.Cursors>
        <p>Move your pointer here.</p>
      </Room.CursorArea>
      <Room.StopFollowing>Stop following</Room.StopFollowing>
    </Room.Root>
  )
}

```

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

## Complete API reference

### Room.CursorArea

scope

A cursor surface and what is drawn around it — the chips for pointers scrolled out of
view — which need the scrolling element itself.

Import: @slices.sh/realtime/headless

```tsx
RootProps
```

Source: @slices.sh/realtime/src/headless/cursor-area.tsx

### Room.CursorSurface

content · Element: div

The area pointers are shared over, and the thing that scrolls under them.

Positions travel as fractions of this element's content, so two people with different window
sizes see the pointer in the same place, and scrolling never moves a pointer off what it
points at. It has to be positioned for the cursors inside it to sit anywhere, which is the
skin's business and the one thing a skin here must not forget.

Following somebody drifts it to keep their pointer in the middle; anything you do yourself —
a wheel, a press, Escape — gives the view back. `/` opens a bubble at your pointer, where the
pattern offers cursor chat.

Import: @slices.sh/realtime/headless

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

```tsx
PartProps<"div", { following: boolean }>
```

Source: @slices.sh/realtime/src/headless/cursor-surface.tsx

### Room.Cursors

scope

Everyone else's pointer, each in its own scope. Yours is never in here.

Import: @slices.sh/realtime/headless

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

```tsx
CursorsProps

type CursorsProps = RootProps & {
  /**
   * One pointer per person — whichever of their tabs moved last — and none from your own
   * other tabs, which are you. Off, every tab is a pointer of its own.
   */
  perPerson?: boolean
}
```

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

### Room.Cursor

content · Element: div

One pointer, gliding between the samples the network gives it on a spring of the page's
one frame loop. → ./use-pointer-spring.ts

Just after somebody is picked to follow it says `data-state="pinged"` for a moment, which a
skin rings so the person can find the arrow on a busy page.

At rest it says so on the element: `data-idle="label"` after three seconds, which a skin
folds the name away on, and `"dim"` after ten. Positions are fractions of the surface's
content, so a pointer on a paragraph stays on that paragraph when you scroll.

Import: @slices.sh/realtime/headless

Room.Cursor must be inside <Room.Cursors>.

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

```tsx
PartProps<"div">
```

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

### Room.CursorLabel

content · Element: span

Whose pointer it is. Their colour is `--peer` and the ink that reads on it `--peer-ink`:
white on a dark colour and near-black on a light one, never white on yellow.

Import: @slices.sh/realtime/headless

Room.CursorLabel must be inside <Room.Cursors>.

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

Source: @slices.sh/realtime/src/headless/cursor-label.tsx

### Room.CursorMessage

content · Element: span

What somebody typed at their pointer, for the few seconds it is said; `--peer` is their colour.

Import: @slices.sh/realtime/headless

Room.CursorMessage must be inside <Room.Cursors>.

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

```tsx
PartProps<"span", { text: string }>
```

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

### Room.CursorChat

content · Element: div

Your own bubble at your pointer: open while you type, and holding what you said for a few
seconds after. It follows the pointer while it is a draft and stays put once sent, where
the words were said.

Import: @slices.sh/realtime/headless

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

```tsx
PartProps<"div", { sent: boolean }>
```

Source: @slices.sh/realtime/src/headless/cursor-chat.tsx

### Room.CursorChatInput

input · Element: input

What you are about to say at your pointer. Enter says it; Escape takes it back.

Import: @slices.sh/realtime/headless

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

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

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

### Room.CursorChatSent

gate

What you said at your pointer, for the seconds it stays there.

Import: @slices.sh/realtime/headless

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

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

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

### Room.CursorHints

scope

The pointers scrolled out of view, each as a chip on the edge nearest to it.

Worked out ten times a second rather than every frame: a chip is a signpost, and a
signpost that trembles along with the pointer it points at is harder to click.

Import: @slices.sh/realtime/headless

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

Room.CursorHints must be inside <Room.CursorArea>.

```tsx
RootProps
```

Source: @slices.sh/realtime/src/headless/cursor-hints.tsx

### Room.CursorHint

action · Element: button

A chip on the edge for a pointer out of view, pointing towards it. Pressing it follows
them, which scrolls to where they are. `state.angle` is in radians, for an arrow to turn;
`--peer` and `--peer-ink` are their colour and the ink that reads on it.

Import: @slices.sh/realtime/headless

Room.CursorHint must be inside <Room.CursorHints>.

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

```tsx
PartProps<"button", { angle: number; name: string }>
```

Source: @slices.sh/realtime/src/headless/cursor-hint.tsx

### Room.Follow

action · Element: button

Follow this person — their pointer on a board, their tiles in a call — or stop.
`aria-pressed` and `data-active` while following. Disabled on your own row: there is no
following yourself, and a list that includes you stays one list.

Import: @slices.sh/realtime/headless

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

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

```tsx
FollowProps

type FollowProps = PartProps<"button", { following: boolean }> & {
  /** Called with the person pressed, as the view starts or stops following them. */
  onUserClick?: (peer: Peer) => void
}
```

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

### Room.Following

gate

You are following somebody: who, and their colour, for the frame that says so.

Import: @slices.sh/realtime/headless

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

```tsx
GateProps<{ name: string; color: string }>
```

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

### Room.StopFollowing

action · Element: button

Take the view back.

Import: @slices.sh/realtime/headless

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

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

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