Skip to content

Custom Widget

Custom widgets let you build an OBS browser source with React, TypeScript and CSS. Use the Synchra API for chat messages, activities, settings and stored values.

  1. Open the Synchra Dashboard.
  2. Go to Widgets and create a Custom widget.
  3. Edit the project files, then choose Apply to update the preview.
  4. Use Test event below the preview to send sample chat messages or activities while editing.
  5. Click Widget URL and add it to OBS as a Browser Source.

Use Save to keep applied changes.

function CustomWidget() {
  const latest = synchra.useLatestChatMessage()
  if (!latest) return null

  return (
    <main className="latest-message">
      <strong>{latest.viewer_display_name}</strong>
    </main>
  )
}

synchra.render(<CustomWidget />)
function RecentMessages() {
  const messages = synchra.useChatMessages({ limit: 10 })

  return (
    <main>
      {messages.map((message) => (
        <div key={message.id}>
          {synchra.providerLogo(message.provider)}{" "}
          <strong>{message.viewer_display_name}:</strong>{" "}
          {synchra.assembleParts(message.message_parts)}
        </div>
      ))}
    </main>
  )
}

synchra.render(<RecentMessages />)

Set font-size on a wrapper to change the logo size:

<span style={{ fontSize: "3rem" }}>
  {synchra.providerLogo(message.provider)}
</span>
function LatestSupport() {
  const activity = synchra.useLatestActivity({
    activityGroups: ["subscription", "subscription_gift", "donation"],
  })

  if (!activity) return null

  return (
    <main className="support">
      <strong>{activity.viewer_display_name}</strong>
      <span>{activity.type_display_name}</span>
      <span>{synchra.assembleParts(activity.message_parts)}</span>
    </main>
  )
}

synchra.render(<LatestSupport />)
function RecentSupport() {
  const activities = synchra.useActivities({
    limit: 10,
    activityGroups: ["subscription", "donation"],
  })

  return (
    <main>
      {activities.map((activity) => (
        <div key={activity.id}>
          <strong>{activity.viewer_display_name}</strong>
          <span>{activity.type_display_name}</span>
        </div>
      ))}
    </main>
  )
}

synchra.render(<RecentSupport />)

See Activity data for activity fields and provider-independent filtering with activity and contribution groups.

function DeathCounter() {
  const deaths = synchra.useKvValue<number>("counter:deaths")

  return (
    <main>
      <strong>{deaths.value ?? 0}</strong>
    </main>
  )
}

synchra.render(<DeathCounter />)
async function convertActivityAmount(activity: Activity) {
  const amount = await synchra.convertCurrency({
    amount: activity.count / 10 ** activity.count_decimal_place,
    from: activity.count_currency ?? "EUR",
    to: "USD",
  })
  return amount
}
interface WeatherResponse {
  current: {
    temperature_2m: number
  }
}

function Weather() {
  const weather = useQuery({
    queryKey: ["weather", "copenhagen"],
    queryFn: async () => {
      const response = await fetch(
        "https://api.open-meteo.com/v1/forecast?latitude=55.68&longitude=12.57&current=temperature_2m",
      )
      if (!response.ok) throw new Error("Unable to load weather")
      return response.json() as Promise<WeatherResponse>
    },
    refetchInterval: 60_000,
  })

  if (weather.isPending) return null
  if (weather.isError) return <main>Weather unavailable</main>

  return <main>{weather.data.current.temperature_2m} C</main>
}

synchra.render(<Weather />)

index.html is the project entry. It selects the widget module and styles:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link rel="stylesheet" href="./styles.css" />
  </head>
  <body>
    <div id="synchra-widget-root"></div>
    <script type="module" src="./widget.tsx"></script>
  </body>
</html>

Add external scripts or stylesheets to index.html with normal absolute URLs. Use relative imports for your own components and helpers.

import { LatestMessage } from "./latest-message"

synchra.render(<LatestMessage />)

Available packages:

  • react
  • @tanstack/react-query
  • zustand

See Custom fonts for loading and applying a custom font.

Use callbacks when code outside a React component needs new events.

synchra.onActivity((activity) => {
  playAlert(activity)
})

Normal React hook rules apply: call hooks at the top level of a React component, not inside conditions or callbacks. React hooks, useQuery, useMutation and Zustand’s create are available directly.

const useCounter = create<{ count: number; increment(): void }>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
}))
APIBehavior
synchra.render(node)Renders the widget’s root React element.
synchra.modepreview in the editor and live in the browser source.
synchra.settingsValues declared by the settings schema.
synchra.useChatMessages({ limit?, types? })Recent messages. Defaults to message types.
synchra.useLatestChatMessage({ types? })Latest message. Defaults to message types.
synchra.useActivities({ limit?, types?, activityGroups?, contributionGroups? })Recent activities, optionally filtered by type or group.
synchra.useLatestActivity({ types?, activityGroups?, contributionGroups? })Latest matching activity.
synchra.useKvValue(key)Returns a channel KV value and its updates.
synchra.kv.get({ key })Reads a channel KV key.
synchra.kv.set({ key, value, ttl? })Writes a JSON value with an optional TTL in seconds.
synchra.kv.delete({ key })Deletes a channel KV value.
synchra.kv.inc({ key, amount?, ttl? })Atomically increments an integer value.
synchra.convertCurrency({ amount, from, to })Converts an amount using the current currency rates.
synchra.assembleParts(parts)Renders message parts, including emotes and links.
synchra.providerLogo(provider)Renders a provider logo. Its size follows font-size.
synchra.partsToText(parts)Converts chat or activity message parts to readable text.

Open settings.schema.json in the Explorer to define the controls shown under Settings.

{
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "canvas_scale": {
      "type": "number",
      "title": "Canvas scale",
      "default": 1,
      "minimum": 0.1,
      "maximum": 10,
      "multipleOf": 0.1
    },
    "heading": {
      "type": "string",
      "title": "Heading",
      "default": "Latest chat"
    },
    "accent_color": {
      "type": "string",
      "format": "color",
      "title": "Accent color",
      "default": "#74c0fc"
    },
    "show_message": {
      "type": "boolean",
      "title": "Show message",
      "default": true
    },
    "layout": {
      "type": "string",
      "title": "Layout",
      "enum": ["compact", "large"],
      "enumItemLabels": ["Compact", "Large"],
      "default": "compact"
    }
  }
}

Read the values from synchra.settings.

function CustomWidget() {
  return (
    <main style={{ color: synchra.settings.accent_color }}>
      <strong>{synchra.settings.heading}</strong>
      {synchra.settings.show_message && <span>Widget content</span>}
    </main>
  )
}

Settings are also CSS custom properties. For example, accent_color becomes --accent-color.

main {
  color: var(--accent-color);
}

New widgets include canvas_scale. It affects the browser source, not the editor preview, and can be removed from the schema.

Supported types: string, number, integer, and boolean.

  • title, description, default: labels and defaults
  • enum, enumItemLabels: select options
  • minimum, maximum, multipleOf: number constraints
  • format: color, textarea, or slider

Use synchra.mode for controls that should appear in the editor preview but not in OBS.

function CustomWidget() {
  const [showControls, setShowControls] = useState(false)

  return (
    <main>
      {synchra.mode === "preview" && (
        <button onClick={() => setShowControls((open) => !open)}>
          Controls
        </button>
      )}
      {showControls && <div>Preview controls</div>}
    </main>
  )
}

synchra.render(<CustomWidget />)