streamdeck-react

Build Stream Deck plugins with React using @fcannizzaro/streamdeck-react. Use for: creating action components, wiring keys/dials/touch input to React, Rollup bundling for Stream Deck, settings sync, shared state, and Takumi image rendering.

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "streamdeck-react" with this command: npx skills add fcannizzaro/streamdeck-react/fcannizzaro-streamdeck-react-streamdeck-react

streamdeck-react

A custom React renderer that turns JSX into rendered images for Elgato Stream Deck hardware. Each action instance gets its own isolated React root with full hooks, state, and lifecycle support.

When to Use This Skill

Use when the user is:

  • Creating or modifying a Stream Deck plugin that uses @fcannizzaro/streamdeck-react
  • Asking about rendering React components on Stream Deck keys or dials, or handling touch input
  • Working with @elgato/streamdeck SDK in a React-based plugin
  • Implementing a custom adapter for web simulation or testing
  • Setting up Rollup bundling for a Stream Deck plugin with native Takumi bindings
  • Scaffolding a brand new plugin and needs a sensible project template or starter example

Architecture (5-Stage Pipeline)

React Tree --> Reconciler --> VNode Tree --> Takumi --> Adapter --> setImage/setFeedback
(JSX+Hooks)   (host config)   (plain JS)   (JSX->PNG)  (bridge)    (hardware/simulator)
  1. Your components render standard React with hooks and state.
  2. A custom react-reconciler manages the fiber tree.
  3. On commit, host nodes form a virtual tree of { type, props, children } with back-pointers for dirty propagation.
  4. The Takumi renderer converts the tree to a PNG/WebP image buffer via a direct VNode-to-Takumi bypass (skips createElement + fromJsx).
  5. The adapter pushes the image to the backend via action.setImage() or action.setFeedback(). The default physicalDevice() adapter wraps the Elgato SDK; custom adapters handle it differently.

4-Phase Skip Hierarchy

Every render passes through a multi-tier skip hierarchy to avoid redundant work:

Phase 1: Dirty-flag check (O(1)) → skip if no VNode mutated
Phase 2: Merkle hash → Image cache lookup → skip if hash matches cached render
Phase 3: Takumi render (main thread or worker) → rasterize
Phase 4: xxHash output dedup → skip hardware push if identical to last frame

Two entry points: renderToDataUri (keys/dials → base64 data URI) and renderToRaw (TouchStrip → raw RGBA Buffer).

Flush Coordinator

When multiple roots request flushes in the same tick, the FlushCoordinator batches them via microtask and processes in priority order:

  • Priority 0 (animating) → 1 (interactive) → 2 (normal) → 3 (idle)
  • Sequential execution ensures higher-priority roots get first access to the USB bus.

Code-First Manifest Generation

The bundler plugins (Rollup and Vite) auto-generate manifest.json from code:

  • Action metadata is defined in defineAction({ info: { name, icon, ... } }) — the bundler plugin auto-extracts it from the module graph at build time via AST analysis.
  • Plugin metadata (uuid, name, author, description, icon, version) is provided via the manifest option in the bundler plugin config.
  • Controllers are auto-derived from key/dial/touchStrip presence on each action.
  • Defaults are applied for OS, Nodejs, SDKVersion, Software, CodePath, Category, States.
  • Actions with info.disabled: true are excluded from the manifest but still work at runtime.
  • The manifest is written to the .sdPlugin directory during writeBundle.
  • Skips write if content unchanged (avoids unnecessary recompilation in watch mode).

No hand-written manifest.json is needed.

Each visible action instance on the hardware gets its own isolated React root. No shared state between roots unless you use an external store (Zustand, Jotai) or the wrapper API.

Adapter Layer

The adapter layer abstracts the @elgato/streamdeck SDK behind a pluggable StreamDeckAdapter interface. This makes the SDK an optional peer dependency and enables alternative backends (web simulator, test harness).

  • physicalDevice() is the default adapter wrapping the real Elgato SDK. It is the only module that value-imports from @elgato/streamdeck.
  • Pass a custom adapter via createPlugin({ adapter: myAdapter() }).
  • All hooks (useOpenUrl, useSwitchProfile, useSendToPI, useShowAlert, useShowOk, useTitle, useDialHint) route through the adapter.
  • AdapterActionHandle is a flat interface unifying Key/Dial/Action. Inapplicable methods (e.g., setImage on dial) no-op.
  • See references/adapter.md for full interface definitions and custom adapter example.

Project Setup

New Plugin

For greenfield projects, prefer the scaffolder first:

npm create streamdeck-react@latest

It asks for the plugin UUID, author, platforms, native targets, starter example, and whether to use React Compiler, then generates a working project.

To use React Compiler via CLI flag:

npm create streamdeck-react@latest --react-compiler true

React Compiler automatically memoizes components at build time, preventing unnecessary re-renders. This is especially beneficial in this environment because every re-render triggers an expensive rasterization pipeline (VNode tree -> Takumi layout -> Rust PNG render -> hardware).

If the user wants to build it manually, use this structure:

A minimal plugin project needs:

my-plugin/
  src/
    plugin.ts           # Entry point
    actions/
      my-action.tsx     # Action component + defineAction with info
  com.example.my-plugin.sdPlugin/
    bin/                # Rollup output goes here
    imgs/               # Action and plugin icons
  rollup.config.mjs
  package.json
  tsconfig.json

Dependencies

# Runtime
npm install @fcannizzaro/streamdeck-react react

# Runtime support used by the Stream Deck SDK
npm install ws

# Build tooling (default -- esbuild)
npm install -D rollup @rollup/plugin-node-resolve @rollup/plugin-commonjs @rollup/plugin-json rollup-plugin-esbuild

# Build tooling (with React Compiler -- replaces esbuild)
# npm install -D rollup @rollup/plugin-node-resolve @rollup/plugin-commonjs @rollup/plugin-json @rollup/plugin-babel @babel/core @babel/preset-typescript @babel/preset-react babel-plugin-react-compiler

# Types (if using TypeScript)
npm install -D @types/react

Also install the platform-specific Takumi native binding packages that match the targets you pass to streamDeckReact({ targets }), for example:

# macOS Apple Silicon
npm install @takumi-rs/core-darwin-arm64

# macOS Intel
npm install @takumi-rs/core-darwin-x64

# Windows x64
npm install @takumi-rs/core-win32-x64-msvc

See references/bundling.md for the full platform matrix.

package.json

Must use "type": "module". Example:

{
  "type": "module",
  "scripts": {
    "build": "rollup -c",
    "dev": "rollup -c -w"
  }
}

tsconfig.json

{
  "compilerOptions": {
    "lib": ["ESNext"],
    "target": "ESNext",
    "module": "Preserve",
    "jsx": "react-jsx",
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "noEmit": true,
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

Core Workflow

Step 1: Define Actions

// src/actions/counter.tsx
import { useState } from "react";
import { defineAction, useKeyDown, useKeyUp, tw } from "@fcannizzaro/streamdeck-react";

function CounterKey() {
  const [count, setCount] = useState(0);
  const [pressed, setPressed] = useState(false);

  useKeyDown(() => {
    setCount((c) => c + 1);
    setPressed(true);
  });

  useKeyUp(() => setPressed(false));

  return (
    <div
      className={tw(
        "flex flex-col items-center justify-center w-full h-full gap-1",
        pressed ? "bg-[#2563eb]" : "bg-[#0f172a]",
      )}
    >
      <span className="text-white/70 text-[12px] font-medium">COUNT</span>
      <span className="text-white text-[36px] font-bold">{count}</span>
    </div>
  );
}

export const counterAction = defineAction({
  uuid: "com.example.my-plugin.counter",
  key: CounterKey,
  info: {
    name: "Counter",
    icon: "imgs/actions/counter",
  },
});

Step 2: Create the Plugin Entry

// src/plugin.ts
import { createPlugin, googleFont } from "@fcannizzaro/streamdeck-react";
import { counterAction } from "./actions/counter.tsx";

const inter = await googleFont("Inter");

const plugin = createPlugin({
  fonts: [inter],
  actions: [counterAction],
});

await plugin.connect();

Step 3: Configure Rollup

Default setup (esbuild):

// rollup.config.mjs
import { builtinModules } from "node:module";
import resolve from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
import json from "@rollup/plugin-json";
import esbuild from "rollup-plugin-esbuild";
import { streamDeckReact } from "@fcannizzaro/streamdeck-react/rollup";

const PLUGIN_DIR = "com.example.my-plugin.sdPlugin";
const builtins = new Set(builtinModules.flatMap((m) => [m, `node:${m}`]));

export default {
  input: "src/plugin.ts",
  output: {
    file: `${PLUGIN_DIR}/bin/plugin.mjs`,
    format: "es",
    sourcemap: true,
    inlineDynamicImports: true,
  },
  external: (id) => builtins.has(id),
  plugins: [
    resolve({ preferBuiltins: true }),
    commonjs(),
    json(),
    esbuild({ target: "node20", jsx: "automatic" }),
    streamDeckReact({
      targets: [{ platform: "darwin", arch: "arm64" }],
      manifest: {
        uuid: "com.example.my-plugin",
        name: "My Plugin",
        author: "Your Name",
        description: "A Stream Deck plugin built with React.",
        icon: "imgs/plugin-icon",
        version: "0.0.0.1",
      },
    }),
  ],
};

With React Compiler (replaces esbuild with Babel):

// rollup.config.mjs
import { builtinModules } from "node:module";
import { babel } from "@rollup/plugin-babel";
import resolve from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
import json from "@rollup/plugin-json";
import { streamDeckReact } from "@fcannizzaro/streamdeck-react/rollup";

const PLUGIN_DIR = "com.example.my-plugin.sdPlugin";
const builtins = new Set(builtinModules.flatMap((m) => [m, `node:${m}`]));

export default {
  input: "src/plugin.ts",
  output: {
    file: `${PLUGIN_DIR}/bin/plugin.mjs`,
    format: "es",
    sourcemap: true,
    inlineDynamicImports: true,
  },
  external: (id) => builtins.has(id),
  plugins: [
    resolve({ preferBuiltins: true }),
    commonjs(),
    json(),
    babel({
      babelHelpers: "bundled",
      extensions: [".js", ".jsx", ".ts", ".tsx"],
      exclude: "**/node_modules/**",
      plugins: ["babel-plugin-react-compiler"],
      presets: ["@babel/preset-typescript", ["@babel/preset-react", { runtime: "automatic" }]],
    }),
    streamDeckReact({
      targets: [{ platform: "darwin", arch: "arm64" }],
      manifest: {
        uuid: "com.example.my-plugin",
        name: "My Plugin",
        author: "Your Name",
        description: "A Stream Deck plugin built with React.",
        icon: "imgs/plugin-icon",
        version: "0.0.0.1",
      },
    }),
  ],
};

For production builds, pass explicit targets. In watch mode, streamDeckReact() can infer the current supported host target.

manifest.json is auto-generated. You do not need to write or maintain it by hand. Action metadata is extracted from defineAction({ info }) calls at build time.

Step 4: Build and Verify

The manifest.json is auto-generated during the build. Action metadata comes from defineAction({ info }) calls, and plugin metadata from the bundler plugin's manifest option.

Critical: The uuid in each defineAction() must start with the plugin UUID prefix (e.g., "com.example.my-plugin.").

Step 5: Dev

npx rollup -c -w

Install the .sdPlugin folder in the Stream Deck app.

If your package.json has a dev script configured, you can also just run bun dev (or npm run dev / pnpm dev).

Hook Quick Reference

CategoryHooksPurpose
EventsuseKeyDown, useKeyUpKey press/release
EventsuseDialRotate, useDialDown, useDialUpEncoder rotation/press
EventsuseTouchTapTouch strip tap
EventsuseDialHintSet encoder trigger descriptions
GesturesuseTapSingle tap (auto-delayed when useDoubleTap is active)
GesturesuseLongPressKey held for configurable duration (default 500ms)
GesturesuseDoubleTapTwo rapid taps within configurable window (default 250ms)
SettingsuseSettings, useGlobalSettingsBidirectional settings sync
LifecycleuseWillAppear, useWillDisappearAction mount/unmount
ContextuseDevice, useAction, useCanvasDevice/action/canvas metadata
ContextuseStreamDeckAdapter and action handle
SDKuseOpenUrl, useSwitchProfileSystem actions
SDKuseSendToPI, usePropertyInspectorPI communication
SDKuseShowAlert, useShowOk, useTitleKey overlays
UtilityuseInterval, useTimeout, usePreviousTimers and helpers
UtilityuseTickAnimation frame loop
AnimationuseSpring, useTweenPhysics and easing-based value animation
AnimationSpringPresets, EasingsBuilt-in spring presets and easing functions

See references/hooks.md for full signatures and usage.

Component Quick Reference

ComponentElementPurpose
BoxdivFlex container with shorthand props (center, padding, gap, direction)
TextspanText with shorthand props (size, color, weight, align, font)
ImageimgImage with required width/height, optional fit
IconsvgSingle SVG path icon with path, size, color
ProgressBardivHorizontal progress bar with value/max
CircularGaugesvgRing/arc gauge with value/max/size/strokeWidth
ErrorBoundary--Catches errors, renders fallback

All components are optional convenience wrappers. Raw div, span, img, svg elements work directly.

See references/components.md for full props tables.

Styling

Three approaches, all valid:

  1. Tailwind classes via className -- resolved by Takumi at render time (no CSS build step):

    <div className="flex items-center justify-center w-full h-full bg-[#1a1a1a]">
    
  2. tw() utility for conditional classes (like clsx):

    <div className={tw('w-full h-full', pressed && 'bg-green-500')}>
    
  3. Inline style for exact control:

    <div style={{ width: '100%', height: '100%', background: '#1a1a1a' }}>
    

State Management Decision Guide

NeedSolution
Simple per-action stateuseState / useReducer
Persist per-action settings across reloadsuseSettings<T>()
Plugin-wide shared configuseGlobalSettings<T>()
Shared state across actions (no provider needed)Zustand store in module scope
Shared state with provider patternJotai/React Context via wrapper on createPlugin or defineAction

Encoder / Dial Actions

For Stream Deck+ encoders, provide a dial component in defineAction. If omitted, the key component is used as fallback on encoder slots.

export const volumeAction = defineAction({
  uuid: "com.example.my-plugin.volume",
  key: VolumeKey,
  dial: VolumeDial,
  info: {
    name: "Volume",
    icon: "imgs/actions/volume",
    encoder: {
      layout: "$A0",
      triggerDescription: {
        rotate: "Adjust volume",
        push: "Mute / Unmute",
      },
    },
  },
});

The info.encoder block tells the Stream Deck UI about dial interactions. Controllers are auto-derived: if dial or touchStrip is present, ["Encoder"] is used; if key is also present, ["Keypad", "Encoder"].

For touch interaction on Stream Deck+, use useTouchTap() inside the mounted action root. Treat touch as input handling, not as a separate primary rendering surface.

Critical Gotchas

  1. Fonts are mandatory -- the renderer cannot access system fonts. Use googleFont("Inter") to download TTF fonts from Google Fonts (cached to .google-fonts/ on disk). Alternatively, load font files manually via readFile. Supported formats depend on the backend: native-binding supports .ttf, .otf, .woff, .woff2; WASM mode only supports .ttf and .otf.
  2. plugin.connect() must be called last -- after createPlugin() and all setup.
  3. UUID prefix -- every action uuid in defineAction() must start with the plugin UUID prefix (e.g., "com.example.my-plugin."). The manifest is auto-generated from these.
  4. streamDeckReact({ targets }) is required for production builds -- it copies the Takumi .node binaries into output. Without them, the plugin crashes on startup.
  5. Install ws and matching @takumi-rs/core-* packages -- they must line up with the targets passed to streamDeckReact({ targets }). When using the WASM backend (takumi: "wasm"), install @takumi-rs/wasm instead and native binding packages are not needed.
  6. No animated images -- each setImage call is a static frame. Use useTick for manual animation loops, or the higher-level useSpring and useTween hooks for physics-based and easing-based animation.
  7. WASM backend limitations -- takumi: "wasm" is available for environments where native addons can't load (WebContainers, browsers). It force-disables worker threads and does not support WOFF/WOFF2 fonts (use TTF/OTF only). Pass takumi: "wasm" to both createPlugin() and streamDeckReact() to skip native binary copying at build time.
  8. Design for 72x72 minimum -- smallest key size. Use useCanvas() to adapt to larger devices.
  9. Use simple layouts -- this is not a browser DOM. Stick to flex layouts, fixed sizes, and simple elements (div, span, img, svg, p).
  10. Animation FPS -- Stream Deck hardware refreshes at max 30Hz. The useTick, useSpring, and useTween hooks default to 30fps (clamped). Design animations accordingly.

Verification Checklist

When scaffolding or modifying a @fcannizzaro/streamdeck-react plugin, verify:

  • @fcannizzaro/streamdeck-react and react are in dependencies
  • ws is installed for the Stream Deck SDK runtime
  • Matching @takumi-rs/core-* packages are installed for every streamDeckReact({ targets }) entry
  • package.json has "type": "module"
  • tsconfig.json has "jsx": "react-jsx"
  • At least one font is loaded via googleFont() or manual readFile and passed to createPlugin()
  • Every defineAction() has info: { name, icon } for manifest generation
  • Every defineAction() UUID starts with the plugin UUID prefix
  • rollup.config.mjs or vite.config.ts includes streamDeckReact({ manifest: { uuid, name, author, ... } })
  • streamDeckReact({ targets }) is set for production builds
  • Encoder actions have info.encoder with layout and triggerDescription
  • plugin.connect() is called after createPlugin()
  • Build completes without errors: npx rollup -c
  • manifest.json is auto-generated in the .sdPlugin directory after build
  • If React Compiler is enabled: output bundle contains react.memo_cache_sentinel (proof compiler is active)

DevTools

A browser-based inspector for debugging plugins during development. When enabled, the plugin starts an HTTP + SSE (Server-Sent Events) server on localhost (port range 39400-39499) and the browser UI auto-discovers running plugins by scanning that range.

Enabling

const plugin = createPlugin({
  devtools: true, // starts the devtools server (port derived from plugin UUID)
  fonts: [
    // ...your fonts
  ],
  actions: [
    /* ... */
  ],
});

Opening the DevTools

Panels

PanelDescription
ConsoleIntercepted console.log/warn/error/info/debug output
NetworkIntercepted fetch requests and responses
ElementsVNode tree inspector with element highlighting on the physical device
PreviewLive rendered images for every active action and touch bar
EventsEventBus emissions (keyDown, dialRotate, touchTap, etc.)
PerformanceRender pipeline metrics: flush counts, skip rates, cache stats, render timing

Key Details

  • Element highlighting -- hover a node in the Elements tree to highlight it with a cyan overlay on the Stream Deck hardware.
  • Multi-plugin support -- discovers and switches between multiple running plugins.
  • Automatic production stripping -- all devtools code, the ws dependency, and instrumentation hooks are removed from the bundle when NODE_ENV=production (non-watch builds). Zero overhead in release builds.

Detailed References

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

Charging Ledger

充电记录账本 - 从截图提取充电信息并记录,支持按周、月查询汇总。**快速暗号**: 充电记录、充电账本、充电汇总。**自然触发**: 记录充电、查询充电费用、充电统计。

Registry SourceRecently Updated
General

qg-skill-sync

从团队 Git 仓库同步最新技能到本机 OpenClaw。支持首次设置、定时自动更新、手动同步和卸载。当用户需要同步技能、设置技能同步、安装或更新团队技能,或提到「技能同步」「同步技能」时使用。

Registry SourceRecently Updated
General

Ad Manager

广告投放管理 - 自动管理广告投放、优化ROI、生成报告。适合:营销人员、电商运营。

Registry SourceRecently Updated