UI Kit
Ready-made React components for Action and Plugin Editors.
An editor is a plain web page, so it can be built with anything. @fluxta/sdk/ui
is a set of React components built for exactly this job: each one carries its own
label, hint, error, spacing and behaviour, so an editor is a list of its settings
rather than a pile of assembled controls.
import { EditorPage, TextField, useActionSettings } from "@fluxta/sdk/ui";It is the recommended way to build an editor, and the only supported one — the app's own design system is internal and unpublished. Every export here is a promise kept across SDK versions.
Setup
One stylesheet, imported once in the editor's entry CSS:
@import "@fluxta/sdk/ui/styles.css";That is the whole styling setup. It carries the theme, the components' styles and the page rules, and it needs no build of your own — no Tailwind, no PostCSS. (You can still add Tailwind if you want to write your own classes; see Styles of your own.)
Then one provider at the root of the page:
import { ActionEditorProvider } from "@fluxta/sdk/ui";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ActionEditorProvider>
<App />
</ActionEditorProvider>
</StrictMode>,
);The provider owns the connection to Fluxta and hands it to every hook and
component below it. Use PluginEditorProvider instead on a
Plugin Editor page. A generated project
already has both files written.
React 19
The components are React 19 components. react and react-dom are peer
dependencies of the SDK — a generated project already has them.
A whole editor
import {
EditorPage,
SelectField,
TextField,
useActionSettings,
usePluginData,
} from "@fluxta/sdk/ui";
type Settings = { scene?: string; message?: string };
export default function App() {
const { values, set } = useActionSettings<Settings>();
const scenes = usePluginData({ event: "get-scenes" }, "scenes");
return (
<EditorPage>
<SelectField
label="Scene"
options={scenes.data}
loading={scenes.loading}
value={values.scene}
onChange={set("scene")}
placeholder="Choose a Scene"
/>
<TextField
label="Message"
multiline
value={values.message ?? ""}
onChange={set("message")}
/>
</EditorPage>
);
}There is no load effect, no save handler, no id to thread from a label to an
input, and no class names. That is the point of the kit.
Fields
Every field takes label, hint and error, and reports its value directly —
onChange is called with the value, not with an event.
| Field | For |
|---|---|
TextField | Text. multiline for a textarea, type="password" for secrets. |
NumberField | Numbers. Reports undefined when cleared, never 0. |
SelectField | One choice out of a list. Searches itself when the list is long. |
CheckboxField | One on/off setting, with its explanation underneath. |
ColorField | A colour, written back as hex or rgba(). |
TemplateField | Text that may carry Run Variables. |
Field | A control of your own, with the same label, hint and error. |
<TextField
label="Host"
value={host}
onChange={setHost}
placeholder="127.0.0.1"
hint="Where OBS is listening."
error={problem}
/>NumberField
Not an <input type="number">, on purpose. That control draws stepper arrows
no stylesheet can hide across every browser, and it hides invalid text in a
"bad input" state where its own value reads as empty — so a controlled React
field sees no change, leaves the DOM alone, and the letters someone typed stay
on screen above a value that no longer matches them.
NumberField is a text input with the keystrokes filtered instead: letters
never land, a minus sign is refused outright when min is zero or more, and
half-typed values (1.) are left alone until they mean something. An empty
field reports undefined, never 0 — a cleared Volume must not mute the
input, and a cleared limit must not cap a reward at zero redemptions. A value
outside min/max is brought back inside it when the field is left.
SelectField and live lists
A list that comes from the plugin's sidecar can be empty, still loading, or
missing the very value that was saved last time. SelectField handles all
three, so an editor does not have to.
<SelectField
label="Scene"
options={scenes.data}
loading={scenes.loading}
value={values.scene}
onChange={set("scene")}
missingLabel="A Scene that no longer exists"
emptyMessage="Connect OBS to see its Scenes."
/>Prop
Type
The important one is the last. A saved value that has dropped out of the list — OBS is closed, the Scene was renamed, the reward was deleted — stays selected and is labelled rather than silently disappearing. An emptied field would become true on the next Save, losing a setting to a program that happened not to be running.
Whether search appears is left to the length of the list on purpose: the author writing the editor cannot know whether a streamer has three Scenes or forty.
TemplateField
The one field that knows about Fluxta rather than only looking like it: text
whose value may contain
Run Variable references, with a picker
that inserts one at the cursor, so nobody has to remember an address or its
{{ }} syntax.
<TemplateField
label="Message"
value={values.message ?? ""}
onChange={set("message")}
placeholder="Hello, {{twitch.chat-message.user}}!"
hint="Insert a value from an earlier step with the picker."
multiline
/>It reads the variable list from the Action Editor itself and stays current as
the user reorders steps, so getRunVariables and onRunVariablesChange never
need calling. When there is nothing to insert — an Action on an ordinary button
is not reacting to anything — the picker says so instead of opening an empty
menu. Typing a reference by hand keeps working either way.
Action Editors only
A Plugin Editor is not positioned inside anyone's Action list, so it has no
Run Variables to offer, and TemplateField refuses to render there.
Layout
| Component | What it is |
|---|---|
EditorPage | The page: padding, text colour, even spacing between settings. |
Section | A titled group, with an optional description and action buttons. |
ListRow | One configured thing: name, badges, details, buttons. |
EmptyState | What a list says when it has nothing in it yet. |
StatusBadge | A pill for a state: ok, pending, error, idle. |
StatusDot | The same states, as a dot. |
PluginEditorLayout | A settings screen with its sections down the left. |
<Section
title="Connections"
description="Each Connection is one OBS Studio instance to control."
actions={<Button variant="outline" onClick={add}>Add</Button>}
>
{connections.length === 0 ? (
<EmptyState>No Connection configured yet.</EmptyState>
) : (
connections.map((connection) => (
<ListRow
key={connection.id}
title={connection.name}
description={`${connection.host}:${connection.port}`}
badges={<StatusBadge tone="ok">Connected</StatusBadge>}
actions={<Button variant="outline" onClick={...}>Edit</Button>}
/>
))
)}
</Section>PluginEditorLayout takes the sections as data and keeps track of which one is
open. Given a single section it draws no navigation at all, so a plugin with one
screen does not get an empty sidebar beside it.
<PluginEditorLayout
title="Platforms"
sections={[
{ id: "account", label: "Account", tone: "ok", content: <Accounts /> },
{ id: "commands", label: "Commands", count: 4, content: <Commands /> },
]}
/>Dialogs
Dialog owns its own buttons, its busy state and its refusals. Return a string
from onSubmit to refuse — it is shown above the buttons and the dialog stays
open; return nothing and it closes.
<Dialog
open={!!editing}
onOpenChange={() => setEditing(undefined)}
title="Command"
description="Matched against the first word of a message."
onSubmit={() => {
const problem = validate(draft);
if (problem) return problem;
send({ event: "save-command", command: draft });
}}
>
<TextField label="Command" value={draft.name} onChange={rename} />
</Dialog>useConfirm asks a yes-or-no question and resolves to the answer. The dialog is
already mounted by the provider, so there is no open state to keep:
const confirm = useConfirm();
const remove = async () => {
const sure = await confirm({
title: "Delete this reward?",
description: "Its redemption history goes with it, for good.",
confirmLabel: "Delete for good",
destructive: true,
});
if (sure) send({ event: "delete-reward", id });
};A dialog covers the editor, not the app
Editors run in their own frame, so a dialog opens inside it. That reads well on a Plugin Editor, which fills the settings page. An Action Editor is already a panel inside one of the app's own dialogs, and a dialog within that is cramped — lay the form out on the page there instead.
Hooks
useActionSettings
Loads the Action's settings on mount and hands them back whenever the user presses Save.
const { values, set, update, loaded } = useActionSettings<Settings>({
mode: "toggle",
});Prop
Type
Defaults are read once, on the first render; saved settings are folded over them, and keys the editor no longer knows about are kept rather than erased.
usePluginData
Asks the plugin's sidecar something and keeps the answer — the whole ask-and-listen exchange of Messaging in one line.
const inputs = usePluginData(
values.connection
? { event: "get-inputs", connectionId: values.connection }
: null,
"inputs",
);The second argument says which message answers. A string is the answering
message's event, and the data is the field named after it
({ event: "inputs", inputs: [...] } gives the array). Pass a function instead
when the field is named something else, or when matching needs more:
const status = usePluginData({ event: "get-status" }, (message) =>
isPluginMessage(message) && message.event === "status"
? message.status
: undefined,
);Pass null as the request for a question that cannot be asked yet — no
Connection chosen, no Scene picked. Nothing is sent and loading stays false.
A string response also correlates: a message answers only if it agrees with the
request on every field the two share. That is what keeps a cascade honest —
asking for one Scene's Sources and then another leaves two requests in flight,
and the first answer back is not necessarily the one still wanted. Echo
connectionId, scene or whatever else identifies the question in the answer,
and the wrong one is ignored.
refresh() asks again, for a Refresh button or after a write.
The Editor itself
useActionEditor(), usePluginEditor() and useEditor() return the instance
the provider owns, for everything the hooks above do not cover — sending a
message, reading or writing plugin-wide settings:
const editor = useEditor();
<Button onClick={() => editor.sendToPlugin({ event: "refresh-rewards" })}>
Refresh
</Button>;Styles of your own
Nothing in the kit needs styling, but every component takes a className, and
the fields take a controlClassName for the control itself. To write utility
classes, add Tailwind to the editor app and put its import — and the kit's theme
— above the stylesheet:
@import "tailwindcss";
@import "@fluxta/sdk/ui/theme.css";
@import "@fluxta/sdk/ui/styles.css";theme.css registers Fluxta's colours as real Tailwind theme colours in your
own build, so bg-muted/30 and text-muted-foreground mean what they mean in
the app. Without it those classes compile to nothing: the shipped stylesheet
carries the colour values baked into the classes the components happen to use,
not a live theme.
The kit also re-exports the handful of bare controls that need no assembling —
Button, Input, Textarea, Label, Checkbox, Badge — for the field the
kit does not have. Put one in a Field and it keeps the label, hint and error
the ready-made fields have:
<Field label="Balance" hint="Anything can go in here.">
<input type="range" value={balance} onChange={...} />
</Field>Using something else
Technically nothing here is required: the editor is a web page, and Fluxta neither knows nor cares what rendered it. Your own components, a different framework, or plain HTML all work.
Use the kit anyway. An editor opens inside the app, next to Fluxta's own controls, and one built from scratch reads as a foreign page — wrong spacing, wrong focus rings, a light form on a dark app. The kit also follows the app's theme without any work on your part.
The one thing that cannot change is how the bundle is served: it loads from a
nested path, so asset references must be relative. With Vite, base: "./".