Fluxta

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:

src/index.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:

src/main.tsx
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

src/App.tsx
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.

FieldFor
TextFieldText. multiline for a textarea, type="password" for secrets.
NumberFieldNumbers. Reports undefined when cleared, never 0.
SelectFieldOne choice out of a list. Searches itself when the list is long.
CheckboxFieldOne on/off setting, with its explanation underneath.
ColorFieldA colour, written back as hex or rgba().
TemplateFieldText that may carry Run Variables.
FieldA 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

ComponentWhat it is
EditorPageThe page: padding, text colour, even spacing between settings.
SectionA titled group, with an optional description and action buttons.
ListRowOne configured thing: name, badges, details, buttons.
EmptyStateWhat a list says when it has nothing in it yet.
StatusBadgeA pill for a state: ok, pending, error, idle.
StatusDotThe same states, as a dot.
PluginEditorLayoutA 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:

src/index.css
@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: "./".

On this page