Getting started
Start
You do not clone Single Studio, and you do not need a copy of this repository. A studio is your own repository with @single-studio/core as a dependency.
Press Use this template on Single-Studio-Template, name your repository, and clone it.
With gh instead, the same two steps also switch Pages on, which is the one thing you would otherwise have to do by hand before the first deploy:
gh repo create my-studio --template FourCourtJester/Single-Studio-Template --public --clone
gh api -X POST repos/<you>/my-studio/pages -f build_type=workflowThen, in the repository:
npm install
npm run devnpm, pnpm or yarn — the template ships with npm because it comes with Node, and nothing the framework does needs a particular package manager.
Open the printed URL. That is the control surface. The header's menu holds the setup a board needs and a show does not: Browser sources lists every graphic's URL with a copy button, alongside the image store and the collaboration settings.
Start over is the last item, under a rule of its own, and holds the three ways to undo:
- Reset the show — every name, score, timer and toggle back to its default, on every machine in the room. The image library is kept: the show is what happened tonight, the library is what somebody spent an afternoon filing, and one button that loses both is one nobody dares press.
- Disconnect from collaboration — leave the room and drive the show from this machine alone. Nothing is deleted and the graphics do not miss a frame; the invite link still works if you want to come back.
- Shut somebody out — a brand new key, which is a brand new room. Offered only on an encrypted show, because that is the only kind with a key to rotate.
- Reset this machine — the show, the images, the room, your name, everything this browser has stored, and then a reload. It leaves the room before it wipes anything, so the other machines keep the show. This is the one to reach for after a rebuild.
Emptying the image library is not in there. It lives in the image store beside the images, because it is the only one of these whose blast radius is a thing you are looking at.
Each asks twice: one click arms the button, it says Click to confirm, a second does it, and a few seconds of silence disarms it. Deliberately not window.confirm — the board's main home is an OBS custom browser dock, where a native dialog may never be drawn, and a guard that silently reads as "they said no" is a guard that quietly stops the button working.
Each copied URL carries ?layer-name=SS - <studio> - <Source>, which is the name OBS gives the browser source. The display name is title-cased from the source's key, so a key written for a URL (lower-third) reads as English in a scene list (Lower Third) without anybody maintaining a second copy of it.
Wire a studio into OBS
Control surface — Docks → Custom Browser Docks, pointed at the app root (
.../#/).Run it as a dock, not in a separate browser. The dock and your graphics share one show because they are in the same place. A separate browser window is a separate place, with a separate copy of everything.
Each graphic — one Browser source per URL from the control page. Set the resolution to your canvas (usually 1920x1080).
Shutdown source when not visible is safe to enable if you want the memory back. The graphic is rebuilt from scratch each time its scene returns, and nothing is painted until the store has answered — see Loading and reload.
Use Copy rather than retyping what is on screen. What it puts on the clipboard carries an OBS parameter the displayed URL leaves off:
http://localhost:4173/?layer-name=Demo%20scoreboard#/source/scoreboardlayer-nameis what OBS names the source from. Routing is hash-based, so every source on a studio shares one origin and one path — without the parameter OBS sees a dozen copies of the same page, which is how a scene ends up full oflocalhost,localhost (2),localhost (3). It sits ahead of the hash because that is where OBS looks, and it is plain text: edit it if you want the source called something else.
What the template gives you
Pressing Use this template leaves you with a working studio, not a skeleton — a control surface, a graphic, and a Pages deploy already wired. npm install pulls @single-studio/core from npm like any other dependency.
Two folders are the ones you will live in:
| Folder | Holds |
|---|---|
src/control/ | The operator's board — Control.jsx composes panels/ |
src/sources/ | One file per graphic — each becomes a browser source |
Then, as you need them:
src/mutations/ | How your show's data changes — see Your own data |
src/studio/ | The studio itself: what it is called, what it registers, and the worker that holds the show |
src/css/ | Tailwind, and anywhere you want your own CSS |
Name it
src/studio/config.js is the one file worth editing before anything else:
export const STUDIO_NAME = 'My Studio'
export const STUDIO_ID = 'my-studio'STUDIO_NAME is the label — it titles the board, and OBS shows it in front of every browser source (SS - My Studio - Lower Thirds / Single). Change it whenever you like.
STUDIO_ID is where the show is filed. It names the databases and the shared worker that the board, the previews and every browser source find each other through, so changing it after you have built something starts you on an empty show. Pick it once.
The rest is wiring that already works. Have a look around the template repository if you want the full tour.
Add a graphic
Create src/sources/MyGraphic.jsx, default-exporting a component. That is the whole step. It appears in the control surface's Browser sources list with a copy button and the name OBS should give it — no list to add it to, nothing to register.
The filename decides the address, and folders group:
| File | URL |
|---|---|
src/sources/Scoreboard.jsx | #/source/scoreboard |
src/sources/LowerThird.jsx | #/source/lower-third |
src/sources/lower-thirds/Single.jsx | #/source/lower-thirds/single |
src/sources/game/Scoreboard.jsx | #/source/game/scoreboard |
The group carries through to what OBS calls the source — SS - My Studio - Lower Thirds / Single — so a scene list sorts the way your folders do.
Rename the file and the URL changes with it. That is the point: the folder is the only place a graphic is named, so there is nothing to keep in step and nothing to forget to update.
src/sources/is only for graphics. Everything in it becomes a browser source, so a shared plate, a hook or a helper belongs somewhere else —src/components/is the obvious home. A file left insources/by mistake turns up in the operator's list and in OBS, which is a confusing way to find out.
Add state
Nothing to declare. Write to any path and read it back:
<Field name="home.name" label="Home" /> {/* writes variables.home.name */}
<Variable name="home.name" fallback="Home" /> {/* reads it */}Namespaces (variables, toggles, timers) are fixed per component: Variable reads variables, Toggle reads toggles, Timer reads timers. You never pass one -- the name you give a component is the part after it.
Use a counter
Stepper and the increment/decrement mutations store values so that concurrent edits add up rather than overwrite each other. Use them for anything numeric an operator adjusts — scores especially.
step sizes the buttons, so a sport scoring in threes is <Stepper step={3} …/> and nothing else changes.
Typing a number into the field sets it rather than adding, and that is the point — going from 3 to 10 by pressing + seven times is not a control. It is a different intention, so it gets a different write: an absolute value lands on top of whatever anybody else was adding at that instant, because that is what correcting a value means.
Add a mutation
// src/mutations/index.js
export const mutations = {
'my:new-period'(ctx) {
ctx.write([['variables.period', String(Number(ctx.read('variables.period') || 0) + 1)]])
ctx.add('variables.home.fouls', 0)
},
}const mutate = useVelcroMutate()
<button onClick={() => mutate('my:new-period')}>New period</button>A mutation is one transaction, so however many paths it touches, the graphics see one change rather than a sequence of them. Your own data covers the rest: what ctx gives you, how to store a list that two operators can both add to without one of them losing an entry, and how to pull data in from a scoring API or a socket rather than a person.
Component reference
Two entry points, because a graphic and the operator's board are separate worlds and nothing needs both in one file:
import { Scene, Toggle, Variable } from '@single-studio/core/source' // in src/sources/
import { Field, Panel, Toggle } from '@single-studio/core/control' // in src/control/That separation is what lets each side use the right name — Toggle is a button on the board and the thing being toggled on air. Hooks, mutations and toolkits are on the root @single-studio/core, since those genuinely are shared.
The full generated reference, with every prop, is api.md.
Source — what goes on air:
| Component | Reads | Notes |
|---|---|---|
Scene | — | Root of a graphic. vars maps CSS custom properties to paths. |
Variable | variables.<name> | Text. fit shrinks it to stay on one line. |
Image | variables.<name> | A bundled path, a URL, or a library entry. Preloads and decodes before swapping. |
Toggle | toggles.<name> | Shows or hides its children. |
Timer | timers.<name> | Any of the three clocks; reads the stored shape. Clears itself when done. limit marks a count-up that overran. |
ImageList | variables.<name> | A row of images from a multi-valued path. Same loading rules as Image. |
Slideshow | the image library | A folder of pictures, playing. Picks the one on screen off the time in the room, so every output agrees. |
Tally | variables.<name> | A count said in icons — three demolitions is three marks. of fills a race instead. Only the change animates. |
Clock | — (local) | Wall clock. Never replicates. |
Ticker | variables.<name> | Crawl at a constant px/sec, swaps text between passes. |
Variable, Timer and Clock render a <span>, so a value can sit inside a sentence without breaking the line it is on. Pass as for anything else — as="div" if you want it to fill its own row. Every other component is a container and renders a <div>.
Every one of these except Clock, Ticker and Slideshow takes a transition prop — see Transitions. Slideshow cross-fades between pictures instead, over --ss-fade; anything beyond that is a rule of your own on .ss-slide[data-on].
Control — the operator's board:
| Component | Writes | Notes |
|---|---|---|
Field | variables.<name> | One line of text. Staged until saved. |
TextArea | variables.<name> | Several lines. Enter makes a line break; the save shortcut saves. |
ImagePicker | variables.<name> | Preview, dropdown of library entries, and a magnifier to browse. |
ImageSelect | variables.<name> | Pick by picture. multiple + max for a composition. |
ImageToggle | toggles.<name> | Toggle with a picture. from reads the face off a path. |
AssetLibrary | — | Manage images: add by URL or file, rename, delete. |
Select | variables.<name> | options of strings or { label, value }. Staged until saved. |
ColorPicker | variables.<name> | Swatch, hex field and optional presets. Staged until saved. |
Stepper | variables.<name> | Numeric −/+, sized by step. Type in it to set a value outright. |
Cycle | variables.<name> | Steps through options, wrapping to unset. |
Toggle | toggles.<name> | group names a radio group; buttons sharing one are exclusive. |
SwapButton | any paths | Trades values pairwise, outermost first. Asks first; confirm={false} opts out. |
ResetButton | any paths | Unsets them. Reads "Reset label". Asks first; confirm={false} opts out. |
Confirm | — | A destructive button that arms on the first click and acts on the second. |
Countdown | timers.<name> | Counts down a duration. Typed unless duration presets it. |
CountdownTo | timers.<name> | Counts down to a wall-clock time, not a duration. |
Stopwatch | timers.<name> | Counts up. Start, pause, reset. |
Leaderboard | variables.<name> | One delimited string; paste view and table view. Staged. |
SaveButton | — | Commits every staged edit. Hosts the save and discard shortcuts. |
Panel | — | Titled group. Children wrap in a flex row. |
Break | — | Forces a line break inside a Panel. |
Working with other people
A studio is one operator by default and needs nothing. To bring in others, press Collaborate on the board: it wants a free Supabase project's URL and public key, and gives you a link to send each operator, which they paste into an OBS dock.
That is the whole setup, and working with other people walks through it — including why Supabase, what it costs, and what it gives up.
Clocks
Three of them, because a broadcast asks three different questions:
| Control | Question |
|---|---|
Countdown | "five more minutes" |
CountdownTo | "we go live at 19:00" |
Stopwatch | "how long have we been on?" |
<Countdown name="round" label="Round" /> {/* operator types it */}
<Countdown name="break" label="break" duration="5:00" /> {/* a fixed preset */}
<CountdownTo name="showtime" label="Doors open" />
<Stopwatch name="match" label="Show elapsed" />Countdown grows an input unless you hand it a duration, because a fixed five minutes is a guess about somebody else's show. The input takes whatever an operator would naturally type — 90, 1:30, 1:02:03 — rather than insisting on a format. Give it a duration when the length never varies and one press should start it.
All three are read by the same component, whichever kind you used:
<Timer name="round" fallback="--:--" />Every machine shows the same number. A second operator's clock agrees with the one running OBS, down to the second, without either of them having to be in sync with the other. A graphic that OBS destroys and rebuilds comes back showing the right time rather than starting over, and pausing then resuming picks up exactly where it stopped.
A countdown shows 00:00 for a second, then takes itself off air. The number the whole thing exists to reach gets its full second on screen, and then the graphic leaves on its own — nobody has to remember to dismiss it mid-show. It goes at the same moment on every machine.
Images
Four ways in, all handled by the same component.
Templated from a value — "Single Studio" resolves logos/single-studio.svg:
<Image name="home.name" src="/logos/:value:.svg" slug fallback="/logos/placeholder.svg" />Images you ship with the studio live in public/ — public/logos/acme.svg is /logos/acme.svg on air. Drop them in, commit them, and they deploy with everything else.
The value is the URL — paste a link into a Field and it is on air:
<Image name="sponsor.url" fallback="/logos/placeholder.svg" />An operator's upload, stored locally and referenced by content hash:
<Image name="guest.photo" />paired with a picker on the board:
<ImagePicker name="guest.photo" label="Headshot" />Image does not care which of the four it is given — a bundled path, a URL and a library entry are all treated the same.
What separates this from an <img> tag is what happens between images. A new URL is loaded and decoded off-screen first, and only swapped in once it can paint. The previous image stays up meanwhile. Setting src directly leaves a hole on air for however long the network takes — fine in a web page, not over a live scene.
The rest is failure handling that a broadcast needs and a web page does not:
| Behaviour | Why |
|---|---|
Falls back to fallback when a load fails | A missing image should read as "no image", not as a broken-image glyph |
| Keeps the previous image while a new one loads | Never blank a working graphic to wait for the network |
referrerpolicy="no-referrer" | Many hosts block hotlinking by Referer |
Warns loudly on http:// from an https:// page | Mixed content is blocked silently and is the most common way this fails |
Use https:// URLs. A studio deployed to GitHub Pages is served over https, so an http:// image is blocked as mixed content. The console says so explicitly.
The image library
Framing and logos belong in the repo — they do not change between shows. A guest headshot that lands five minutes before air does not: there is no time to patch a repo, and it usually arrives as a file.
The library is where those live. Two ways in, and both produce a named entry:
- Paste a URL — the bytes stay wherever they are.
- Drop or choose files, or a whole folder — the bytes are stored locally, content-addressed.
A graphic points at the entry by name — "ada-okafor" — rather than at a link or a file path. That is the name an operator recognises under pressure, and it means repointing a slot is a rename in the library rather than an edit everywhere it is used.
<AssetLibrary /> {/* the manager, as a panel */}
<ImagePicker name="guest.photo" label="Headshot" /> {/* choose one */}
<Image name="guest.photo" /> {/* on air */}Groups
A key can be a path, and the slash is the whole organisation scheme:
players/ada-okafor
players/kim-nakamura
logos/acmeThe part before the last slash becomes a group — a heading in the library, and a heading in every picker's dropdown. A hundred images in one flat list is a scroll an operator has to read; the same hundred under a handful of prefixes is a menu they can aim at.
Groups are deliberately not a second concept with their own storage and their own editing screen. The key already existed, renaming it already worked, and a graphic still points at one string — so re-filing an image is renaming it, and nothing downstream knows the difference.
Three ways to file something:
| You do this | You get |
|---|---|
Type players/ada as the name | That exact key |
Type players and add several files | players/<each filename> |
| Drop or choose a folder | <folder>/<each filename>, nesting preserved |
The name field means "name" for one file and "group" for several, which is the same field meaning the same thing at two scales.
Sharing a library
When collaboration is on, the library's index replicates and its bytes do not. Every machine sees every entry by name; one that does not hold the file marks it elsewhere and greys it.
The rule that makes that safe rather than a trap: files are added on the machine running OBS, and URLs by anybody. A file's bytes exist only where they were dropped, and the machine that has to draw a graphic is the machine going to air — so the one place a file can usefully be added is the one place it can be drawn. A URL is a reference rather than bytes, so it works from anywhere and replicates for free.
That means an entry marked elsewhere on a remote operator's board is not a warning. It lives on the studio machine, it will go to air, and the only thing missing is their own preview. The board says so in those words.
How many images can it hold?
There is no limit in the framework. The practical ones:
- Browser storage. The browser gives a studio a large share of free disk, so hundreds of megabytes of photos is not a problem. A show's worth of headshots is nowhere near it.
- Duplicates are free. The same photo filed under two names is stored once. Re-adding a folder you already have costs nothing but entries.
- Adds are sequential, one file read at a time, so a folder of a hundred does not spike memory the way reading all hundred at once would. There is a progress readout above four files, and one unreadable file is reported rather than losing the rest of the batch.
ImagePicker gives a preview, a dropdown of the library's keys for a fast swap between segments, and a magnifier joined onto the dropdown that opens the library as a modal for adding, renaming and deleting. The same AssetLibrary component serves both.
Playing a folder
Slideshow points at a group rather than a list, so loading the show is dropping a folder on the board:
<Slideshow group="slides" every={9} order="shuffle" />Two things worth knowing before you put one on air:
An empty group renders nothing at all — no element, not an empty one, and no timer running behind it. That means there is nothing to style for the empty case: if you want a holding card while the folder fills up, put it in the studio as a sibling, where it can say what this show is waiting for.
It follows the library while it plays. Pictures added to the group during a programme join the deck without a reload, and removing one takes it out. Loading the show and running it are the same act, which is the point of pointing at a group instead of writing a list.
It only plays what the machine it is running on can actually paint. A file lives in the browser that added it, so a picture dropped on a producer's laptop shows up in everyone's library but only plays where its bytes are — see the box above. Images added by URL have no bytes to be missing and play everywhere.
Picking by picture
When the choice is a picture — a faction crest, a commander portrait, a map — reading nine words is slower than recognising nine images, and a draft does not wait. ImageSelect is Select laid out as a grid of tiles, and ImageToggle is Toggle with a picture on it.
<ImageSelect name="home.faction" label="Faction" options={FACTIONS} />
<ImageSelect name="home.army" label="Army" options={UNITS} multiple max={5} />
<ImageToggle name="sponsor" label="Sponsor" image="./logos/acme.svg" />
<ImageToggle name="sponsor" label="Sponsor" from="variables.sponsor.url" image="./logos/placeholder.svg" />ImageToggle's from points its face at a path rather than a fixed file, which is what a switch for something the operator also chooses wants: the sponsor toggle should wear the sponsor they picked. image stays as the fallback for before anything is chosen.
Each ImageSelect option is { value, label, image }. The stored value is the plain one — a source reading variables.home.faction cannot tell which control wrote it — so the usual templating still applies:
<Image name="home.faction" src="./factions/:value:.svg" />
<ImageList name="home.army" src="./units/:value:.svg" limit={5} />multiple stores an array in the order the picks were made, which is what an army composition or a ban list is, and max stops the grid rather than silently dropping the overflow. ImageList puts that array on air, giving each entry the full Image treatment.
These are buttons, so they act immediately. Pass staged to an ImageSelect that should wait for a save with the text fields — a draft assembled off-air and revealed on the cut.
Two properties worth knowing:
- Adding and going to air are separate. Bytes land in the library immediately, because a file arriving is not a broadcast change. The selection is staged like any other field, so nothing appears until save — an operator can line up the next guest mid-segment and commit on the cut.
- Bytes deduplicate, keys do not. The same photo filed under two names stores its bytes once, and deleting one key leaves the other working.
Images are stored on the machine, separately from the show itself — the show carries the name of the picture, not the picture.
Bytes stay on the machine that holds them. A file added on the OBS machine does not travel to a remote operator's board, and deliberately so: moving it would buy a preview, not correctness, because the machine that has to draw it is the one that already has it. That is why files are the OBS machine's to add — see the rule that removes most of the problem. URL entries replicate fine, since they are just strings.
Driving anything else from a value
Scene maps CSS custom properties to paths, which covers everything the framework does not have a component for — colours, offsets, widths, radii:
<Scene vars={{ '--accent': 'variables.sponsor.color' }}>
<div style={{ borderLeft: '6px solid var(--accent, #0ea5e9)' }} />
</Scene>A path holding nothing is left unset rather than blanked, so the var() fallback still applies.
On the board, ColorPicker is what fills that path. A swatch to pick from, a hex field to paste a brand colour into, and an optional row of presets — because most shows have four colours, and nobody should have to know that amber is #f59e0b:
<ColorPicker name="sponsor.color" label="Accent" fallback="#f59e0b" presets={['#f59e0b', '#0ea5e9', '#e11d48']} />Both halves write the same path and stay in step, and it stages like any other field — typing #f5 mid-hex would otherwise put a half-parsed colour on air.
Transitions
Broadcast graphics do not snap between values; they animate out, swap, and animate back in. Every source component does that already — the interesting part is how.
<Toggle name="lowerthird" transition="slide-right ease-back opaque" />
<Variable name="home.name" transition="flip ease-sharp" />
<Image name="home.faction" transition="wipe" />
<Toggle name="showtime" transition="bounce" />The prop is a space-separated list of variant names, and what each one looks like is CSS — so a new motion costs a rule rather than a code path.
cut is the one that does nothing:
<Variable name="clock" transition="cut" />A clock that fades every second is a clock drawing attention to itself once a second. Leaving the prop off is not the same thing — the default is fade.
Every transitioning element is in exactly one of four phases:
| Phase | Meaning |
|---|---|
inactive | Off. Not showing, not moving |
entering | On its way in |
active | On air, at rest |
exiting | On its way out |
The phase is on the element as data-state and as an ss- class, so you can style or inspect any of them.
| Motion | What it does |
|---|---|
fade | The default. Opacity only. |
cut | No animation at all. Swaps, the way a mixer cuts |
slide-up | Arrives travelling upward (starts below its place) |
slide-down | Arrives travelling downward |
slide-left | Arrives travelling leftward (starts to the right) |
slide-right | Arrives travelling rightward |
zoom | Scales up into place |
flip | Rotates in about its top edge |
wipe | Reveals left to right, clipped rather than faded |
bounce | Drops in and settles twice; dips and lifts out (keyframes) |
| Modifier | What it does |
|---|---|
opaque | Move without also fading — a pure slide |
ease-out | Fast then settling. The safe default for a reveal |
ease-back | Overshoots and comes back. Reads as a bounce |
ease-in | Slow then fast. For exits |
ease-sharp | Hard in, hard out. Mechanical, good for a wipe |
ease-linear | No easing |
Two custom properties tune the rest:
<Toggle name="sponsor" transition="slide-up ease-out opaque" style={{ '--ss-shift': '14rem', '--ss-duration': '480ms' }} />--ss-shift— how far a slide travels (default1.5rem)--ss-duration— how long a phase takes (default300ms);--ss-dropforbounce
Use a length for --ss-shift, not a percentage. A percentage transform resolves against the element's own size, and a hidden Toggle has no content in it — so 150% of a collapsed box is zero and the graphic sits parked exactly where it will land. It still animates correctly on the way in and out, when content is present, which is what makes the mistake so easy to miss.
Two more things worth knowing before you build a scene around this:
- Pin blocks that reveal independently. If two graphics share a flex row, one sliding in shoves the other sideways to make room. On air that reads as a bug rather than as a transition. The demo's
Matchscene positions each bottom block absolutely for exactly this reason. - Do not centre a transitioning element with a transform.
-translate-x-1/2and a variant that animates transform will fight, and the variant wins. Centre with a wrapper. - A keyframe variant needs keyframes in both directions. An entrance animation ends with
both, so the animation itself is holding the final transform; take it away on the way out and the element drops straight to the off-phase value with nothing to interpolate from. It teleports and then fades in place.bouncehas anss-bounce-outfor this reason, and a custom keyframe variant needs one too.
Writing your own
A variant is a class name and nothing else. transition="stinger" puts ss-stinger on the element; the state machine adds ss-inactive, ss-exiting, ss-entering or ss-active beside it, and never touches a transform itself. So a new motion is a rule in your own stylesheet:
@layer components {
/* Off, and on the way out or in: where the element sits when not showing. */
.ss-stinger.ss-inactive,
.ss-stinger.ss-exiting,
.ss-stinger.ss-entering {
opacity: 0;
transform: translateX(-100%) skewX(-12deg);
}
/* On air: no transform, which is what everything animates towards. */
.ss-stinger.ss-active {
opacity: 1;
transform: none;
}
}<Toggle name="stinger" transition="stinger ease-sharp">
<StingerCard />
</Toggle>Three things make that work, and each is a mistake worth making once:
- Wrap it in
@layer components, matching the framework's own rules — see Styling for why the layer matters. - The same declarations for
inactive,exitingandentering. Those three are one visual state — "not showing" — reached from three different directions. Givingenteringits own values makes the element jump at the moment it starts arriving. - Only
activeis the resting state. Set the transform tononethere rather than restating it, so the variant composes with--ss-shiftand with whatever a studio's own rule does.
A variant is ordinary CSS, so Tailwind can supply the values — see Styling.
Easing and duration come from the modifiers and custom properties above, so a variant only has to describe where the element is — not how long it takes to get there. That split is why stinger ease-back works without you writing an easing.
Pick per element rather than per scene. In the demo's scoreboard a name flips over, a score slides up and overshoots, and the badge plainly fades — because a logo swapping with a flourish reads as a mistake.
Sizing for the dock
An OBS dock is either a narrow column pinned down one side or most of a monitor, and the same board has to work at both. Panel handles that by default: children grow to fill the row, wrap when they cannot, and never push the panel wider than the dock.
<Panel title="Teams">
<Field name="home.name" label="Home" />
<Stepper name="home.score" label="Home score" />
<Break /> {/* force a row break */}
<Leaderboard name="standings" /> {/* compound controls take a row */}
</Panel>Retune where it wraps with --ss-control-min (default 12rem), globally or per panel:
.ss-control {
--ss-control-min: 9rem;
} /* pack tighter */
.ss-panel:has(.ss-leaderboard) {
--ss-control-min: 100%;
} /* one per row */Two rules make this work and are worth knowing if you write your own layout: min-width: 0 is what lets a flex item shrink below its content at all, and max-width: 100% is what stops a wide control from forcing a horizontal scrollbar across the whole board. A control with a hard min-w-* will escape a narrow dock — that is the one thing to avoid.
The smoke test asserts a 260px dock has no horizontal scroll and that no control escapes it.
Saving
Free-text controls stage their edits and commit on save. Typing does not reach air: an operator revises mid-word, and every intermediate state of that would otherwise be on screen.
| Action | Effect |
|---|---|
| Ctrl/Cmd + S | Commit every staged edit |
| Enter (in a field) | Commit every staged edit |
| Escape (in a field) | Abandon that field's edit |
| Discard (red ✕) | Abandon all staged edits |
Ctrl/Cmd+S is only the default. If it collides with something else you use, or your keyboard makes it awkward, change it: menu → Keyboard shortcuts, press the key you want, done. You can also put discard on a key, which ships unbound.
Shortcuts are yours rather than the show's, so rebinding one does not move anybody else's. They are stored with the studio, which means they come along if you move it to another machine. A choice with no modifier, like F2, works fine; a plain letter also works, but only while no text field has focus, since otherwise you would be typing it. The dialog says so when you pick one, and warns you if the browser would take the combination before the board ever sees it.
Buttons — Stepper's −/+, Toggle, ImageToggle, ImageSelect, Countdown, CountdownTo, Stopwatch, Cycle — act immediately. Each is a single deliberate press with no half-finished state to protect, and all of them are undone by pressing again.
ResetButton and SwapButton ask first, because those two are not. A reset unsets values with no undo, and a swap puts both team names and both scores on the wrong side of a live scoreboard. One press arms the button and it says so, a second does it, and a few seconds of silence disarms it — so a board left alone never sits with a live "wipe the show" under the cursor. Pass confirm={false} to either one for the single press, where the worst outcome is small and speed matters more.
Stepper's field is the exception among them, and stages like the text ones do: typing "10" passes through "1", and a board that wrote every keystroke would put a 1 on air on the way to a 10. Enter or clicking away commits it; Escape abandons it.
A save is one mutation, so every staged path lands in a single transaction and the whole board changes on air together.
Save and Discard are icons — an amber floppy disk and a red ✕ — sized to match, and Discard only appears when there is something to discard. There is no count of pending changes: the dirty dot on each edited control says which, which is the half worth knowing.
ControlPage renders a SaveButton in its header automatically. To put one somewhere else, or to read the pending count yourself:
import { SaveButton, useDraft, useDraftCount } from '@single-studio/core'
const pending = useDraftCount()
const { save, revert } = useDraft()Browser requirements
A studio needs a reasonably current browser:
| Browser | Minimum |
|---|---|
| Chrome / Edge | 83 |
| Firefox | 114 |
| Safari | 16 |
| OBS | 28 |
Every one of those shipped by mid-2023, so in practice anything current works. The OBS dock is Chromium, and remote operators on the collaboration path can use whatever they already have.
If a browser is too old, the board says so plainly instead of loading a page where nothing updates — it names what is missing and the version needed. You can replace that screen with onUnsupported, or ask first:
import { getSupport } from '@single-studio/core'
const { ok, missing, persistent } = getSupport()persistent: false means the browser will not keep anything after a reload — private windows and some locked-down profiles. It is not a failure: the show runs normally for the session, it just starts fresh next time.
Phones and tablets are not supported yet. A studio needs a browser feature that mobile Safari and Chrome for Android do not provide, so the board will refuse to load rather than half-work. See the note below.
A hand-held control surface
Running the operator's board from a phone is a reasonable thing to want, and it is on the list. It is not a small change: the store is shared between the board and every graphic by a mechanism phones do not implement, so supporting them means a second way of sharing it. Until then, a remote operator needs a laptop.
Styling
Tailwind v4. A studio's CSS entry needs three lines:
@import 'tailwindcss';
@import '@single-studio/core/styles.css';
@source '../../node_modules/@single-studio/core/dist';If your editor underlines @source as an unknown rule, it is wrong — Tailwind v4 adds at-rules the built-in CSS checker has not been taught. The template ships a .vscode/settings.json that silences it, and recommends the Tailwind IntelliSense extension, which understands them properly.
Splitting it up
Two ways, and they are not equivalent — one of them keeps a graphic's CSS out of every other graphic.
Import it from the graphic, and it becomes that graphic's own stylesheet:
// src/sources/Podium.jsx
import './podium.css'Vite gives each graphic its own chunk, and CSS imported this way rides along with it: a browser source pointed at the scoreboard never downloads the podium's rules. Measured on the demo, a one-rule file came out as its own 0.05 kB asset.
It needs no boilerplate. Do not import Tailwind or the framework's styles at the top — plain rules are all it takes, and the utility classes in your JSX already come from index.css, which every page loads.
The exception is @apply and theme(), which need to know your theme. Point them at it with @reference, which reads it without emitting anything:
/* src/sources/podium.css */
@reference '../css/index.css';
.podium-plate {
@apply rounded-lg bg-sky-600;
}Forgetting it fails the build rather than silently dropping the rule, and the error names @reference — so this is a thing you meet once.
@import 'tailwindcss' is the wrong fix, and it is the one that looks right. It works, and it copies the whole utility layer into that graphic's stylesheet: the same file went from 0.22 kB to 17.84 kB. @reference gives the same result at the smaller size.
Import it from index.css, and it is part of the one stylesheet every page loads:
@import 'tailwindcss';
@import '@single-studio/core/styles.css';
@import './game.css';
@source '../../node_modules/@single-studio/core/dist';Right for anything shared — brand colours, a plate every graphic uses, @theme tokens. CSS requires @import to come before any other rule, so these stay at the top, above @source.
The rule of thumb: if only one graphic uses it, import it from that graphic. Otherwise index.css.
That @source line is required. Tailwind scans your files for utility classes, and the framework's components live in node_modules — without it their classes get stripped from your build.
@single-studio/core/styles.css carries only behaviour-critical rules: transparent backgrounds for sources, the Transition state classes, their timing and motion variants, and the ticker keyframes.
CSS owns the timing. Transition reads the duration back off computed style, so there is no duration prop to keep in sync — retune it any of three ways:
<Variable name="home.name" className="duration-500" /> {/* Tailwind utility */}.scoreboard .ss-transition {
transition-duration: 700ms;
} /* your own rule */
.scoreboard {
--ss-duration: 900ms;
} /* custom property */Tailwind, and the @layer rule
The template ships with Tailwind, and every framework component takes a className, so utilities work on anything:
<Variable name="home.name" className="text-6xl font-bold tracking-tight" />Write your own rules inside @layer components, the same layer the framework uses:
@layer components {
.ss-stinger.ss-inactive {
@apply opacity-0 -translate-x-full;
}
}That is load-bearing rather than tidy. Unlayered CSS beats layered CSS whatever the specificity, so a rule written outside a layer outranks every Tailwind utility on the same element — and duration-500 would quietly do nothing. Both the framework's rules and yours sit in the layer, so ordinary specificity decides, and the last one written wins.
Tailwind is not required. It is what the template happens to use, and nothing in the framework depends on it.
Reduced motion applies to your control surface, not to graphics. On a browser source, animation is broadcast content, and the preference being read belongs to the operator's machine — nobody watching the stream has a say in it. Honouring it there would let one person's OS setting strip the animation from everyone's screen.
Deploy
The template ships a Pages workflow, so there is nothing to run. Switch Settings → Pages → Source to GitHub Actions, push to main, and the board lands at https://<you>.github.io/<your-repo>/#/ — the URL you paste into an OBS custom browser dock, and the one every invite link is built from.
That switch is the one manual step, and the workflow cannot make it for you: enabling Pages from inside an action needs a personal access token with repo scope, which is a worse thing to keep around than one click. If you would rather not click, gh is already signed in as you and the call is idempotent — a repository that already has Pages on answers 409:
gh api -X POST repos/<you>/<your-repo>/pages -f build_type=workflowTo build it yourself:
npm run build # dist/Asset paths are relative (base: './'), so a build works at a Pages repo subpath, on a custom domain, or opened off disk — which matters because OBS loads these URLs directly.
Troubleshooting
My graphics show fallback text and never update
The board and the graphics have to be in the same place. Check both:
- The control surface is an OBS custom browser dock, not a separate browser window.
STUDIO_IDinsrc/studio/config.jsmatches the one the worker is given. A mismatch logs a warning in the browser console.
A source is blank in OBS but works in a browser
The browser source URL is missing its #/source/... part. Copy it again from the board's Browser sources menu — the copy button includes everything needed.
I renamed my studio and the show emptied
STUDIO_ID is the name your show is filed under, so renaming it starts a fresh one. Nothing is lost: put the old STUDIO_ID back and the show returns.
If you only meant to retitle it, change STUDIO_NAME instead — that one is a label and nothing reads it back.
Do graphics survive being hidden in OBS?
Yes, and it is tested. A graphic is destroyed and rebuilt every time its scene comes back, and nothing paints until the real values have arrived — no flash of fallback text over the programme.
Keep the dock open, though. It is what holds the show while the graphics are unloaded. If the dock is closed and every graphic is hidden at once, the next one to appear reloads the show from disk — slower, but nothing is lost.
The board will not load on my phone
Not supported yet — see browser requirements.