@playspace/react

PlaySpace therapy surfaces as React components.

npm types

npm install @playspace/react

React 18 and 19. Every component is a client component. Built on @playspace/embed, which you can use directly on any other framework.


The whole integration

'use client'
import { PlaySpaceProvider, Sandtray } from '@playspace/react'

export function PlayPanel({ sessionId }: { sessionId: string }) {
  const fetchToken = async () => {
    const res = await fetch('/api/playspace/token', {
      method: 'POST',
      body: JSON.stringify({ sessionId }),
    })
    return (await res.json()).token
  }

  return (
    <PlaySpaceProvider fetchToken={fetchToken}>
      <Sandtray />
    </PlaySpaceProvider>
  )
}

Open that as a clinician in one browser and as a client in another, and both people are in the same tray. Figures move under both cursors. It autosaves.

You wrote no real-time synchronisation, no presence layer, and no role branching.


The one idea worth understanding

The token carries the role, so components do not.

<Sandtray /> takes no role prop. The session token minted for the person at the screen says clinician or patient, and every component renders the corresponding side.

A clinician gets the figure library, the tool palette, the navigation rail, saves and session controls. A client gets the play surface and their cursor. Neither sees the other's interface.

There is no flag to pass, and therefore no flag to get wrong. You cannot accidentally render a clinician's controls to a seven-year-old.


Three composition levels

Pick by how much of the experience you want to own.

Level 1 — the whole session.

<PlaySpaceProvider fetchToken={fetchToken}>
  <PlayroomSession />
</PlaySpaceProvider>

Level 2 — individual surfaces. You own navigation and layout.

<PlaySpaceProvider fetchToken={fetchToken}>
  {tab === 'sand'  && <Sandtray />}
  {tab === 'house' && <Dollhouse />}
  {tab === 'draw'  && <Whiteboard />}
</PlaySpaceProvider>

Level 3 — headless. You own everything visual.

const { role, session, surfaces, open } = usePlaySpace()
const { artifacts } = useArtifacts({ subject: { externalId: 'client_55130' } })

Most partners ship level 2. Level 1 is the fastest route to a demo. Level 3 is for teams with a design system they will not compromise.


Components

Live play — synchronised between participants in real time.

Component Notes
<Sandtray /> The flagship. Figure library, sand tools, saves. Needs 640×480
<Dollhouse /> Rooms, furniture, family figures. layout, rooms
<Whiteboard /> background, tools. A clinician can restrict a client's tools live
<GameLibrary /> The licensed catalog, filterable. Clinician-only
<Game slug="…" /> One game
<Playroom /> Every live surface in one environment, clinician steering
<PlayroomSession /> Playroom plus video, presence and session controls

Content — no live session required, so these work anywhere in your product.

Component Notes
<StorybookCreator /> onCreated fires before any page is written
<StorybookReader /> Reading position persists across sessions
<StorybookLibrary /> Browse and create
<WorksheetCanvas /> mode: collaborative, solo, review
<WorksheetLibrary />
<FormFill /> onSubmit carries the response identifier and score
<FormBuilder /> Clinician-only

Generation — each renders its own progress state, so you build no spinner.

Component Notes
<PlayStudio /> A clinician describes a game and gets a playable one
<ModelStudio /> A three-dimensional figure from text or a photograph
<ContentStudio /> Every generation surface behind one tabbed interface

Full props in index.d.ts and the React SDK guide.


Token renewal, which you do not write

fetchToken is called on mount and again at eighty percent of the current token's lifetime. The new token is swapped in underneath a live surface — a mid-interaction sandtray is never remounted, and no work is lost.

This is why the default token lifetime is fifteen minutes rather than eight hours. A clinician can leave the panel open across a lunch break and come back to a working session; a stolen token is useless within the quarter hour.

If fetchToken throws, the provider retries with backoff and emits token_refresh_failed after three attempts.

Use token instead of fetchToken only for something short-lived. A static token does not renew, and the failure mode is a clinician stranded mid-session.


Hooks

const { ready, role, subject, session, surfaces, open, close, end } = usePlaySpace()

const { artifacts, isLoading, error, refresh } = useArtifacts({ subject })
const { job, isLoading } = useGenerationJob(jobId)
const { usage } = useUsage()

usePlaySpace().role is how you branch your own interface — for example, rendering your navigation for a clinician and nothing for a client:

const { role, surfaces } = usePlaySpace()

return (
  <>
    {role === 'clinician' && <YourSurfaceNav surfaces={surfaces} />}
    <Sandtray />
  </>
)

useUsage() exists so you can warn at eighty percent of quota in your own chrome. A clinician who discovers an exhausted allowance mid-session with a seven-year-old will contact you, not us.


Events

<PlaySpaceProvider
  fetchToken={fetchToken}
  onEvent={(event) => {
    if (event.type === 'artifact.ready') attachToChart(event.payload.artifactId)
    if (event.type === 'session.ended')  recordDuration(event.payload.durationMs)
  }}
/>

ready, surface.opened, surface.closed, participant.joined, participant.left, artifact.created, artifact.ready, job.progress, session.ended.

At-most-once, and never carrying clinical content — identifiers, counts, durations and enumerated values only. Never a name, a page of story text or a form answer.


Errors

A fixed taxonomy, so you can tell our failure from your own and from the browser's.

<PlaySpaceProvider
  fetchToken={fetchToken}
  onError={(error) => {
    switch (error.code) {
      case 'token_refresh_failed': alertOncall('playspace token endpoint down'); break
      case 'render_blocked':       showExtensionNotice(); break
      default:                     log(error.code, error.requestId)
    }
  }}
/>

token_expired, token_invalid, token_refresh_failed, origin_not_allowed, capability_missing, entitlement_denied, role_not_permitted, surface_unavailable, network_error, render_blocked.

Handlers must be idempotent — an error may fire more than once for one underlying cause.

render_blocked means a browser extension or a third-party storage restriction blocked the frame. It is not your bug and not ours; surface the message rather than debugging it.


Sizing

The host sets width and, optionally, a maximum height. The surface owns its own height.

<div style={{ width: '100%', maxHeight: '80vh', overflow: 'auto' }}>
  <Sandtray />
</div>

A fixed container height clips. Setting height on the component is honoured but overrides the surface's own layout, which is rarely what you want.

The sandtray and dollhouse need 640 by 480. Below that they render a message asking the person to enlarge the window rather than an unplayable tray.


Server components

The provider is a client component, and so is everything under it. Render the identifier on the server and pass the token callback down:

// page.tsx — server component
export default async function Page({ params }) {
  const session = await getSession(params.id)
  return <PlayPanel sessionId={session.playspaceId} />
}

Do not fetch a token in a server component and pass its value as a prop. It gets serialised into the page payload, cached where you did not intend, and will have expired by the time anyone reads it.


Theming

<PlaySpaceProvider theme={{ accent: '#2563eb', radius: 'lg', font: 'inherit' }} />

Chrome, controls and focus rings adopt your accent, radius and font. The play content itself — the tray, the figures, the illustrations — is not themeable, because it is designed for children and its visual language is part of the clinical product rather than decoration.

colorScheme accepts light, dark or system and defaults to system.


Not on React?

@playspace/embed is the transport this package is built on. Same surfaces, same events, same error taxonomy, no component layer.

import { mount } from '@playspace/embed'

const surface = mount(document.querySelector('#play'), {
  surface: 'sandtray',
  fetchToken: () => fetch('/api/playspace/token').then((r) => r.text()),
})

Vue and Svelte wrappers are planned and will be thin — this package is roughly four hundred lines over the transport, most of it the renewal contract.


Documentation