Skip to content

GomaPulseFramework-agnostic sportsbook widgets

A sports feed UI distributed as Web Components. Embed it with one import and a config object.

Quick examples ​

26 widgets ship today. Each is its own NPM package; consumers install only what they need. Every widget shares the same peer-dependency set (vue, pinia, vue-router, vue-i18n, @vueuse/core), so a host that mounts more than one only ever loads one copy of the framework. This includes the two chat widgets, <goma-live-chat> and <goma-messenger>, which externalise vue, pinia and vue-i18n for their own code the same way every other widget does. Both need the full peer set regardless, because both reach @gomagaming/my-bets (their share-screen bet picker), which pulls in @gomagaming/core and @gomagaming/sports-domain and their vue-router / @vueuse/core peers, packages neither chat widget imports directly. The chat widgets do need a backend service of their own; start with Chat identity. The table below highlights the widgets with a worked example further down this page. See the full widget registry for all 26, including search, top-competitions, top-competitions-pills, competitions-list, multi-banners, missions-hub, mission-details, suggested-bet, filters-settings, my-bets, favorites, highlight-card, and pros-choice.

WidgetPackageReference
<goma-events-horizontal>@gomagaming/events-horizontalAPI docs
<goma-sports-navigation-horizontal>@gomagaming/sports-navigation-horizontalAPI docs
<goma-sports-navigation-dialog>@gomagaming/sports-navigation-dialogAPI docs
<goma-betslip-floating>@gomagaming/betslip-floatingAPI docs
<goma-betslip-sidebar>@gomagaming/betslip-sidebarAPI docs
<goma-game-details>@gomagaming/game-detailsAPI docs
<goma-region-filter>@gomagaming/region-filterAPI docs
<goma-competition-filter>@gomagaming/competition-filterAPI docs
<goma-time-filter>@gomagaming/time-filterAPI docs
<goma-hero-card>@gomagaming/hero-cardAPI docs
<goma-casino-games>@gomagaming/casino-gamesAPI docs
<goma-live-chat>@gomagaming/live-chatAPI docs
<goma-messenger>@gomagaming/messengerAPI docs

<goma-events-horizontal> ​

Swipeable row of event cards with live scores, odds buttons, and market navigation. Subscribe to a WAMP topic, or feed events straight in via the events property.

bash
pnpm add @gomagaming/events-horizontal vue pinia vue-router vue-i18n @vueuse/core
html
<goma-events-horizontal id="eh"></goma-events-horizontal>

<script type="module">
  import '@gomagaming/events-horizontal'

  const el = document.getElementById('eh')
  el.config = {
    socketUrl: 'wss://sportsapi.example.com/v2',
    socketRealm: 'www.example.com',
    apiBaseUrl: 'https://api.example.com',
    bettingApiBaseUrl: 'https://sports-api.example.com',
    ucsOperatorId: '<your-operator-id>',
    locale: 'en',
  }
  el.topic = 'custom-matches-aggregator/1/all/all/all/POPULAR/LIVE/10/5'

  el.addEventListener('goma:outcome-select', (e) => {
    console.log('Betslip add:', e.detail.outcomeName, '@', e.detail.odds)
  })
</script>

<goma-sports-navigation-horizontal> ​

Horizontal swipeable strip of sport tiles with a live-event count badge. Three data sources: host-fed (prop), one-shot WAMP RPC (rpc), or live subscription (subscribe).

bash
pnpm add @gomagaming/sports-navigation-horizontal vue pinia vue-router vue-i18n @vueuse/core
html
<goma-sports-navigation-horizontal id="nav"></goma-sports-navigation-horizontal>

<script type="module">
  import '@gomagaming/sports-navigation-horizontal'

  const el = document.getElementById('nav')
  el.config = { iconBaseUrl: '/icons', locale: 'en' }
  el.dataSource = 'prop'
  el.sports = [
    { id: '1', name: 'Football',   iconId: 1, numberOfLiveEvents: 12 },
    { id: '3', name: 'Tennis',     iconId: 3, numberOfLiveEvents: 64 },
    { id: '6', name: 'Baseball',   iconId: 6, numberOfLiveEvents: 0  },
  ]

  el.addEventListener('goma:sport-select', (e) => {
    console.log('Sport selected:', e.detail.sportName, e.detail.sportId)
  })
</script>

<goma-sports-navigation-dialog> ​

Sister widget with the same data sources and the same goma:sport-select event, rendered as a trigger pill that opens a modal grid (bottom-sheet on mobile). Drop-in interchangeable with the horizontal sibling wherever a single-selection picker is needed.

bash
pnpm add @gomagaming/sports-navigation-dialog vue pinia vue-router vue-i18n @vueuse/core
html
<goma-sports-navigation-dialog id="picker"></goma-sports-navigation-dialog>

<script type="module">
  import '@gomagaming/sports-navigation-dialog'

  const el = document.getElementById('picker')
  el.config = {
    iconBaseUrl: '/icons',
    locale: 'en',
    // The pill should sit on the host's surface, so keep the host transparent.
    theme: { backgroundPrimary: 'transparent' },
  }
  el.dataSource = 'subscribe'
  el.liveStatus = 'LIVE'

  el.addEventListener('goma:sport-select', (e) => {
    console.log('Sport selected:', e.detail.sportName)
  })
</script>

<goma-betslip-floating> ​

Floating-action-button betslip: fixed pill with a selection-count badge that opens a full-screen <dialog> containing the shared betslip panel. Reads selections from the cross-widget goma:betslip:v4 storage, so any <goma-events-horizontal> on the page (or another tab on the same origin) feeds it.

bash
pnpm add @gomagaming/betslip-floating vue pinia vue-router vue-i18n @vueuse/core
html
<goma-betslip-floating id="bf"></goma-betslip-floating>

<script type="module">
  import '@gomagaming/betslip-floating'

  const el = document.getElementById('bf')
  el.placeBetMode = 'event'

  el.addEventListener('goma:place-bet-request', async (e) => {
    await fetch('/my/internal/bets', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(e.detail),
    })
  })
  el.addEventListener('goma:bet-placed', (e) => console.log('receipt', e.detail))
</script>

<goma-betslip-sidebar> ​

Always-visible vertical betslip rendered inline as an <aside>. Same shared-storage selection sync and place-bet contract as the floating sibling. Use this one for desktop layouts with a permanent betslip column.

bash
pnpm add @gomagaming/betslip-sidebar vue pinia vue-router vue-i18n @vueuse/core
html
<div style="height: 600px; max-width: 380px;">
  <goma-betslip-sidebar id="bs"></goma-betslip-sidebar>
</div>

<script type="module">
  import '@gomagaming/betslip-sidebar'

  const el = document.getElementById('bs')
  el.placeBetMode = 'event'

  el.addEventListener('goma:place-bet-request', async (e) => {
    await fetch('/my/internal/bets', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(e.detail),
    })
  })
  el.addEventListener('goma:bet-placed', (e) => console.log('receipt', e.detail))
</script>

<goma-game-details> ​

Page-shaped widget rendering the full details for one sporting event: header (teams, score, clock), market-category tabs, and a scrollable list of collapsible market cards. Pair with <goma-events-horizontal> and card taps drive it via the existing goma:event-navigate event, with no glue code.

bash
pnpm add @gomagaming/game-details vue pinia vue-router vue-i18n @vueuse/core
html
<div style="height: 100vh; display: grid; grid-template-rows: auto 1fr;">
  <goma-events-horizontal id="evts"></goma-events-horizontal>
  <goma-game-details id="gd"></goma-game-details>
</div>

<script type="module">
  import '@gomagaming/events-horizontal'
  import '@gomagaming/game-details'

  const cfg = { apiBaseUrl: 'wss://sports-api.example.com', locale: 'en' }
  document.getElementById('evts').config = { ...cfg, topic: 'live/1' }
  document.getElementById('gd').config = cfg
  // Tapping a card emits goma:event-navigate; game-details patches
  // its eventId via the watcher. No glue code is needed.
</script>

<goma-region-filter> ​

Horizontal pill row of regions (countries) with a built-in All pill. Sources its list from a one-shot getLocations({ sportId }) RPC; pair with a sports picker (followSportPicker) to refetch on sport change. Pair downstream with <goma-events-horizontal followRegionPicker> to rewrite the topic's locationId segment.

bash
pnpm add @gomagaming/region-filter vue pinia vue-router vue-i18n @vueuse/core
html
<goma-sports-navigation-horizontal id="sports"></goma-sports-navigation-horizontal>
<goma-region-filter id="regions" follow-sport-picker></goma-region-filter>
<goma-events-horizontal id="ev" follow-sport-picker follow-region-picker></goma-events-horizontal>

<script type="module">
  import '@gomagaming/sports-navigation-horizontal'
  import '@gomagaming/region-filter'
  import '@gomagaming/events-horizontal'

  const config = { socketUrl: 'wss://…', socketRealm: '…', apiBaseUrl: 'https://…', ucsOperatorId: '<your-operator-id>' }
  for (const id of ['sports', 'regions', 'ev']) document.getElementById(id).config = config
</script>

<goma-competition-filter> ​

A generic host-fed horizontal filter pill bar, with no WAMP, no RPC and no follow* auto-wiring. The host supplies the pills (and two optional edge triggers pinned to the bar's left/right edges) via items; a tap emits goma:filter-select. Use it for any "nav pills" strip where the host already has the list of options in hand. See the widget reference for the full items / leftTrigger / rightTrigger shape.

bash
pnpm add @gomagaming/competition-filter vue pinia vue-router vue-i18n @vueuse/core
html
<goma-competition-filter id="bar"></goma-competition-filter>

<script type="module">
  import '@gomagaming/competition-filter'

  const bar = document.getElementById('bar')
  bar.items = [
    { id: 'popular', label: 'Popular' },
    { id: 'upcoming', label: 'Upcoming' },
    { id: 'top', label: 'Top Competitions' },
  ]
  bar.selectedId = 'top'

  bar.addEventListener('goma:filter-select', (e) => console.log(e.detail)) // { id, label, previousId }
</script>

<goma-time-filter> ​

Dual-thumb range slider for picking a (start, end) time window, with no WAMP transport and no sport/region/competition context. Both thumbs snap to host-supplied ticks ({ hours, label } pairs; a working default ships built in) and the widget emits the snapped pair as '{startHours}-{endHours}', or the literal 'all' sentinel when both thumbs sit at the extremes. Pair with <goma-events-horizontal follow-time-picker> to rewrite the topic's hoursInterval segment.

bash
pnpm add @gomagaming/time-filter vue pinia vue-router vue-i18n @vueuse/core
html
<goma-time-filter id="time"></goma-time-filter>
<goma-events-horizontal id="ev" follow-time-picker></goma-events-horizontal>

<script type="module">
  import '@gomagaming/time-filter'
  import '@gomagaming/events-horizontal'

  // el.ticks is optional: the built-in 8-tick default (Now / 1H / 3H / 6H
  // / 12H / 24H / 48H / Everything) applies if omitted.
  document.getElementById('time').addEventListener('goma:time-select', (e) => {
    console.log(e.detail) // { timeValue, timeLabel, previousTimeValue }
  })
</script>

<goma-hero-card> ​

Featured single-event card: fixed header, hero image, and a swiping panel of markets. In subscribe mode it opens a WAMP connection like every other widget above. But in prop mode (the default) it takes zero network configuration. Assign one plain object to hostEvent and the card expands it into the same store shape a live feed would have produced. This is the widget to reach for when the only data source is a CMS with no sports-API connection at all. See the host-fed data guide for the full GomaHostEvent contract.

bash
pnpm add @gomagaming/hero-card vue pinia vue-router vue-i18n @vueuse/core
html
<goma-hero-card id="hero"></goma-hero-card>

<script type="module">
  import '@gomagaming/hero-card'

  const el = document.getElementById('hero')
  el.dataSource = 'prop' // the default: no socketUrl, no REST call
  el.heroImage = 'https://cdn.example.com/arsenal-chelsea.jpg'
  el.hostEvent = {
    id: '900123456789012345',
    homeTeam: 'Arsenal',
    awayTeam: 'Chelsea',
    competition: 'Premier League',
    sportId: 1,
    venueId: 'GB',
    startTime: 1789000000000,
    isLive: false,
    markets: [
      {
        id: 'm-1x2',
        name: 'Match Result',
        bettingTypeId: 69,
        eventPartId: 3,
        outcomes: [
          { id: 'o-home', label: 'Arsenal', odds: 2.10, bettingOfferId: 'bo-home-1' },
          { id: 'o-draw', label: 'Draw',    odds: 3.40, bettingOfferId: 'bo-draw-1' },
          { id: 'o-away', label: 'Chelsea', odds: 3.20, bettingOfferId: 'bo-away-1' },
        ],
      },
    ],
  }

  el.addEventListener('goma:outcome-select', (e) => {
    console.log('Betslip add:', e.detail.outcomeName, '@', e.detail.odds)
  })
</script>

<goma-casino-games> ​

Compact horizontal rail of portrait game tiles. Purely presentational: no socket, no RPC, no config object required at all. Every tile comes from the host-fed games array; a tap emits goma:casino-game-select and the host owns navigation.

bash
pnpm add @gomagaming/casino-games vue pinia vue-router vue-i18n @vueuse/core
html
<goma-casino-games id="cg"></goma-casino-games>

<script type="module">
  import '@gomagaming/casino-games'

  const el = document.getElementById('cg')
  el.games = [
    { id: 'g1', name: 'Gates of Olympus', imageUrl: 'https://cdn.example.com/olympus.jpg', ctaUrl: 'https://cdn.example.com/play/olympus' },
    { id: 'g2', name: 'Sweet Bonanza',    imageUrl: 'https://cdn.example.com/bonanza.jpg' },
    { id: 'g3', name: 'Book of Dead',     imageUrl: 'https://cdn.example.com/bookofdead.jpg' },
  ]

  el.addEventListener('goma:casino-game-select', (e) => {
    window.location.href = e.detail.ctaUrl ?? `/casino/${e.detail.id}`
  })
</script>

<goma-live-chat> ​

Per-game live chat room: message stream, emoji reactions, moderation states, and opt-in bet-ticket sharing. Configure via HTML attributes or a config object property. Both write into the same reactive state and compose, so the one applied last wins per key and a partial config only touches the keys it names. Its own code now externalises vue, pinia, and vue-i18n like the widgets above; it needs vue-router and @vueuse/core too because its dist unconditionally imports @gomagaming/my-bets (a declared dependency, installed and registered for you) for the share-screen bet picker, which reaches @gomagaming/core and @gomagaming/sports-domain.

bash
pnpm add @gomagaming/live-chat vue pinia vue-router vue-i18n @vueuse/core
html
<goma-live-chat
  api-url="https://api.example.com/api"
  socket-url="/"
  auth-token="USER_JWT_TOKEN"
  socket-auth-token="SOCKET_AUTH_TOKEN"
  game-id="game_12345"
  locale="en"
></goma-live-chat>

<script type="module">
  import '@gomagaming/live-chat'

  document.querySelector('goma-live-chat').addEventListener('gc:tail-bet', (e) => {
    console.log('Tail this bet:', e.detail.selections)
  })
</script>

<goma-messenger> ​

Direct-message / social chat widget: friends, group chats, and bet-ticket sharing, as a self-positioning floating bubble or inline in a host container. Configure via HTML attributes or a config object property. Both write into the same reactive state and compose, so the one applied last wins per key and a partial config only touches the keys it names. Sister widget to <goma-live-chat>, with the same gc:* event namespace, the same @gomagaming/my-bets dependency and the same peer install.

bash
pnpm add @gomagaming/messenger vue pinia vue-router vue-i18n @vueuse/core
html
<goma-messenger
  api-url="https://api.example.com"
  socket-url="wss://ws.example.com"
  auth-token="USER_TOKEN"
  socket-auth-token="SOCKET_TOKEN"
  user-chat-id="user-123"
  locale="en"
></goma-messenger>

<script type="module">
  import '@gomagaming/messenger'

  document.querySelector('goma-messenger').addEventListener('gc:message-received', (e) => {
    console.log('New message:', e.detail)
  })
</script>

Same widget, any framework ​

Custom elements work the same way in every framework: set object-valued properties (config, topic, arrays) via JS, not attributes, and listen for goma:* events like any other DOM event. Vanilla is shown throughout this page; here's <goma-events-horizontal> mounted in React and Vue.

React 18 ​

jsx
import { useEffect, useRef } from 'react'
import '@gomagaming/events-horizontal'

function EventsFeed({ topic }) {
  const ref = useRef(null)
  useEffect(() => {
    const el = ref.current
    if (!el) return
    el.config = {
      socketUrl: 'wss://sportsapi.example.com/v2',
      socketRealm: 'www.example.com',
      apiBaseUrl: 'https://api.example.com',
      bettingApiBaseUrl: 'https://sports-api.example.com',
      ucsOperatorId: '<your-operator-id>',
      locale: 'en',
    }
    el.topic = topic
    const onSelect = (e) => console.log('Betslip add:', e.detail.outcomeName, '@', e.detail.odds)
    el.addEventListener('goma:outcome-select', onSelect)
    return () => el.removeEventListener('goma:outcome-select', onSelect)
  }, [topic])
  return <goma-events-horizontal ref={ref} />
}

Vue 3 ​

vue
<script setup>
import '@gomagaming/events-horizontal'
import { ref, watchEffect } from 'vue'

const props = defineProps(['topic'])
const emit = defineEmits(['select'])
const el = ref(null)

watchEffect(() => {
  if (!el.value) return
  el.value.config = {
    socketUrl: 'wss://sportsapi.example.com/v2',
    socketRealm: 'www.example.com',
    apiBaseUrl: 'https://api.example.com',
    bettingApiBaseUrl: 'https://sports-api.example.com',
    ucsOperatorId: '<your-operator-id>',
    locale: 'en',
  }
  el.value.topic = props.topic
})
</script>

<template>
  <goma-events-horizontal ref="el" @goma:outcome-select="emit('select', $event.detail)" />
</template>

A composed page: sport picker, time filter, feed, and betslip, wired with no glue code ​

This is the core idea GomaPulse is built around: drop several widgets on the same page and they find each other through goma:* DOM events and a shared localStorage envelope, not direct references. It's also the shape a real filtering page tends to take: a sport strip, a time window, an events feed, and a sidebar betslip, all pointed at the same config. (A GomaPulse home page usually skips the picker chain entirely: a hero carousel plus one events feed on a fixed topic (no follow* involved), with "See all" linking through to the filtered page below.)

Below, tapping a sport or dragging the time slider re-scopes the events feed's WAMP topic (followSportPicker + followTimePicker), and tapping an odds button on any event card writes into goma:betslip:v4, which the sidebar betslip's list picks up automatically. Full mechanism: Cross-widget wiring.

html
<goma-sports-navigation-horizontal id="sports"></goma-sports-navigation-horizontal>
<goma-time-filter id="time"></goma-time-filter>
<goma-events-horizontal id="ev" follow-sport-picker follow-time-picker></goma-events-horizontal>

<div style="height: 600px; max-width: 380px;">
  <goma-betslip-sidebar id="betslip"></goma-betslip-sidebar>
</div>

<script type="module">
  import '@gomagaming/sports-navigation-horizontal'
  import '@gomagaming/time-filter'
  import '@gomagaming/events-horizontal'
  import '@gomagaming/betslip-sidebar'

  // One config object drives every widget on the page. Bundlers dedup the
  // framework deps, so mounting four widgets never loads a second Vue copy.
  const config = {
    socketUrl: 'wss://sportsapi.example.com/v2',
    socketRealm: 'www.example.com',
    apiBaseUrl: 'https://api.example.com',
    bettingApiBaseUrl: 'https://sports-api.example.com',
    ucsOperatorId: '<your-operator-id>',
    locale: 'en',
  }

  const sports = document.getElementById('sports')
  // Set the data-shape flags before `config`: assigning `config` is what
  // fires the RPC, so the first fetch already reflects them. `rpc` (not
  // `subscribe`) is what actually returns both live and upcoming tiles here.
  // `subscribe` has no combined-status feed and silently falls back to LIVE.
  sports.dataSource = 'rpc'
  sports.liveStatus = 'BOTH'
  sports.config = config

  document.getElementById('time').config = config

  const events = document.getElementById('ev')
  events.topic = 'custom-matches-aggregator/1/all/all/all/POPULAR/BOTH/10/5'
  // followSportPicker + followTimePicker (both default true, set explicitly
  // above via the kebab-case attributes) rewrite the topic's sportId and
  // hoursInterval segments in place as the strip and slider are used.
  events.config = config

  document.getElementById('betslip').config = config
  // No listener wires the betslip to the events feed. An odds tap on any
  // card in `ev` writes straight into the shared goma:betslip:v4 storage,
  // and the sidebar betslip's list updates on its own.
</script>

follow* attributes cover this case cleanly because each picker's event maps to exactly one topic segment. A page that adds more inputs than that (a settings modal whose single goma:filters-apply event bundles sport and time and market and odds range together, or a deep link arriving from another page) has no matching follow* flag for the bundle. Past that point a real integration turns the feed's own follow* flags off and owns the topic centrally instead: one small window.addEventListener('goma:*', …) per input, folding every change into a single topic string it assigns to el.topic itself. See Cross-widget wiring for the full event catalogue.

For responsive layouts that swap between the horizontal picker and the dialog at a breakpoint, see Sports navigation: deployment scenarios.

Cross-cutting concerns ​

  • Theming: assign a theme object; keys are auto-prefixed with --goma-. Full token reference in the Theming guide.
  • Property order: assigning config is what opens the WAMP connection (or fires the first RPC), so set theme, dataSource, topic, and the follow* flags first and config last. Assign it out of order and the widget's first fetch won't reflect the flags you set a moment later.
  • Framework integration: every widget is a real custom element, so React (ref callback), Vue (@goma:event syntax), and Angular all interop without wrappers. Per-framework snippets live on each widget's reference page.
  • Events: every widget emits CustomEvents with composed: true. They cross the Shadow DOM boundary and bubble to the host, so window.addEventListener('goma:sport-select', …) works. Full catalogue in Cross-widget wiring.
  • Package layering: what @gomagaming/core and @gomagaming/sports-domain give every widget, and what a widget package supplies itself: see Architecture.
  • Shared state: the localStorage-backed envelopes (goma:betslip:v4, goma:favorites:v1, goma:session:v1, goma:filters-settings:v1) that let widgets on the same page cooperate without direct references: see Shared state.
  • Host-fed data: feeding a widget host-owned event data with no socket connection at all: see Host-fed data.