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, giveaways, 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 Giveaway() {
  const { phase, winner, snapshot, entry_count, recent_entries } =
    synchra.useGiveaway({ recentCount: 5 })

  if (winner) {
    return (
      <main className={phase === "exiting" ? "fade-out" : undefined}>
        {phase === "drawing"
          ? "Picking a winner..."
          : `Winner: ${winner.giveaway.winner_display_name}`}
      </main>
    )
  }
  if (phase !== "active") return null

  return (
    <main>
      <strong>{snapshot?.is_running ? snapshot.trigger : "Entries closed"}</strong>
      <span>{entry_count} entries</span>
      {recent_entries.map((entry) => (
        <span key={entry.id}>{entry.display_name}</span>
      ))}
    </main>
  )
}

synchra.render(<Giveaway />)

The hook shares its lifecycle with the built-in Giveaway widget: draw for four seconds, show the winner for 30 seconds, then refresh the newest open giveaway. No timers or event subscriptions are needed in custom code. The built-in widget also waits for its configured exit animation before refreshing.

Configure that behavior through options:

const giveaway = synchra.useGiveaway({
  drawDurationMs: 0,
  winnerDurationMs: 8_000,
  nextGiveawayDelayMs: 8_000,
  recentCount: 5,
})
OptionDefaultBehavior
giveawayIdnullFollow the newest open giveaway, even when its entry command is unavailable or its entry deadline has passed. An explicit ID stays pinned to that giveaway, including when it is closed.
recentCount10Number of recent entries, limited to 0-10.
drawDurationMs4000Time in the drawing phase. Set to 0 to reveal immediately.
winnerDurationMs30000Time to show the revealed winner.
exitDurationMs0Time in the exiting phase before removing the winner. Use it for a CSS exit animation.
nextGiveawayDelayMsSum of the three durationsDelay measured from the draw event before refreshing selection. Set to 0 to fetch immediately in the background. Set longer than the presentation to add a blank interval afterward.

An explicit ID refreshes that same giveaway after the delay. It never advances to another ID. Redraws restart the timing; changing the requested ID clears the previous presentation. Durations are nonnegative milliseconds; non-finite values use the defaults.

Without an explicit ID, live draw events from any giveaway on the channel show a winner, including closed giveaways that are no longer selected or were already closed when the widget loaded. With an explicit ID, only draws for that giveaway are shown. Duplicate delivery does not restart the presentation.

FieldMeaning
phaseidle, active, drawing, winner, exiting, or waiting. active means the giveaway is open and remains on screen, including while awaiting a winner after entries close. Only advertise entry when snapshot.is_running is true. waiting is the blank interval when the next-giveaway delay exceeds the presentation.
winnerCurrent presentation, or null. Includes giveaway, finalized entryCount, drawing, and exiting. It remains stable if a background refresh selects another giveaway.
snapshotServer snapshot for the presented selection, or null when unavailable. snapshot.is_running indicates entry availability, not widget visibility; it accounts for command schedules and stream restrictions. trigger and end_at describe its linked entry command, if present. end_at closes entries, not the giveaway display.
giveawaySelected giveaway, reconciled from snapshots and live updates. During the next-giveaway delay it stays on the drawn giveaway. Explicit ID selection can return a closed giveaway and its saved winner without replaying an animation.
entry_count, recent_entriesLive entries for the selected giveaway, held during the next-giveaway delay. A draw uses the server’s finalized count. Use these instead of the corresponding snapshot fields.
last_eventMost recent observed giveaway event. It may refer to the previously selected giveaway, so check last_event.data.id when matching it to a selection. It persists until another event arrives.
last_event.drawPresent only on a completed draw, including redraws of the same winner. draw.entry_count is the finalized count used for that draw. Ordinary edits and closing a giveaway do not include it.

Both widget types keep displaying and listening to the selected open giveaway after its countdown ends, so a winner can be drawn later. Entry availability never causes selection to fall back to an older giveaway. Explicitly closing or deleting a giveaway removes it from latest selection; opening a newer giveaway selects that one. Refreshes from live events and reconnects are deferred during nextGiveawayDelayMs, which starts at the draw event, not the entry deadline. Remounting refreshes current state; missed draw events are not replayed. For custom presentation, last_event remains available, but most widgets only need phase and winner.

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.useGiveaway(options?)Shared giveaway lifecycle, configurable timing, winner and live entries.
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 />)