Appearance
Introduction
What is gomawt?
gomawt is a library of framework-agnostic Web Components that render a sportsbook UI — event cards, market cards, sport bars, competition lists, and more. Each widget ships as its own NPM package under the @gomagaming/* scope. Consumers install only the widgets they need and embed them with a single ESM import plus a plain JavaScript config object — no Vue knowledge required, no build-step changes, no framework lock-in.
The repo is structured as a pnpm workspaces monorepo with one package per widget.
html
<goma-events-horizontal id="ev"></goma-events-horizontal>
<script type="module">
import '@gomagaming/events-horizontal'
document.querySelector('#ev').config = {
apiBaseUrl: 'https://api.example.com',
locale: 'en',
socketUrl: 'wss://socket.example.com',
socketRealm: 'example',
ucsOperatorId: '4313',
}
</script>The same widget drops into React, Vue, Angular, or Svelte with no adapter library — because native custom elements are part of the browser, not the framework.
Who is this for?
- Host-app developers who want to embed a full-featured sportsbook in minutes without rebuilding one from scratch.
- Operators who need a single UI library that works across marketing sites, apps, and partner white-labels.
- Contributors who want to extend the library with new widgets or re-skin existing ones without touching consumer code.
How it works
Every gomawt widget is a real custom element that, under the hood, runs an isolated Vue 3 application inside a Shadow DOM.
The anatomy of one instance
<goma-events-horizontal> HTMLElement subclass
#shadow-root (open)
adoptedStyleSheets Tailwind + DaisyUI + theme CSS
<div> mount point
Vue app (createApp)
Pinia (createPinia) isolated store tree
VueRouter (createMemoryHistory) no URL side effects
VueI18n locale from config/attribute
App.vue widget wrapper
Inner component tree rendered into the shadow rootFive things happen when the element is connected to the DOM:
attachShadow({ mode: 'open' })creates a private DOM subtree.createApp()spins up a fresh Vue app inside that shadow root.- Isolated Pinia, Vue Router (memory history), and vue-i18n are installed on that app.
- All CSS (Tailwind, DaisyUI, theme tokens) is injected via
adoptedStyleSheets, falling back to a<style>tag in older browsers. - A
reactive()object bridges the HTMLElement property API to the Vue tree.
The reactive bridge
The element class holds one reactive state object that Vue watches. Element property setters mutate it, and Vue re-renders automatically:
js
// Inside the element class
this._state = reactive({ events: [], locale: 'en', theme: {} })
// The app is given access
app.provide('gomaState', this._state)
// JS property setters mutate the reactive state
set events(val) { this._state.events = val }Components inside the widget read that state via inject('gomaState'), and host code drives the widget simply by setting properties:
js
el.events = [{ id: '123', /* ... */ }]Talking back to the host
Widgets dispatch CustomEvents with composed: true so the events cross the Shadow DOM boundary and bubble up to the host app:
js
this.dispatchEvent(new CustomEvent('goma:outcome-select', {
bubbles: true,
composed: true,
detail: { bettingOfferId, outcomeName, odds }
}))The host listens with standard DOM APIs:
js
el.addEventListener('goma:outcome-select', (e) => {
addToBetslip(e.detail)
})Standalone vs hosted mode
Every widget supports two runtime modes, auto-selected at mount time:
| Mode | Trigger | What happens |
|---|---|---|
| Standalone | Element has no parent goma orchestrator. | Own Shadow DOM, own Vue app, own Pinia, own router, own API container. Suitable for drop-in usage on any page. |
| Hosted | Element is a descendant of another goma element that already has a Vue app. | No new Shadow DOM, no new Vue app. Renders as a VNode inside the parent's Vue context, sharing Pinia/i18n/router. The parent's shadow root provides CSS isolation. |
Hosted mode is forward-looking: it lets a future orchestrator widget compose multiple child widgets — sharing one Pinia, one i18n, one router — without each child standing up its own Shadow DOM. Today only <goma-events-horizontal> ships, so it always runs in standalone mode.
Runtime configuration
All configuration is runtime, not build-time. There is no import.meta.env, no per-consumer build. The config property replaces what env vars would normally carry:
js
el.config = {
apiBaseUrl: 'https://...',
socketUrl: 'wss://...',
socketRealm: '...',
ucsOperatorId: '...',
locale: 'en',
theme: { /* CSS variable overrides */ },
}The config is validated on assignment (unknown keys log a warning), applied to the reactive state, and provided into the Vue tree via inject('sportsbookConfig').
Key architectural decisions
| Decision | Why |
|---|---|
createApp() + Shadow DOM, not defineCustomElement() | Our widgets are full SPAs with routers and store trees. Vue's defineCustomElement() is designed for isolated leaf components and can't host a router or sub-widgets cleanly. |
createMemoryHistory() for the router | Widgets must never touch the host page's URL. Memory history keeps navigation internal. |
Vue, Pinia, vue-router, vue-i18n, @vueuse/core declared as peerDependencies | One copy across all installed widgets — bundlers dedup. Consumers install the framework deps once at the app level. |
CSS via adoptedStyleSheets | Keeps styles inside the Shadow DOM. Never leaks into document.head. |
reactive() bridge between element and Vue | Clean separation. Element properties drive reactive state; Vue watches trigger re-renders. |
Custom events with composed: true | Events cross the shadow boundary to reach host listeners. |
Separate createPinia() per instance | Multiple widgets on the same page don't collide. The host app's Pinia is unaffected. |
| Runtime config replaces env vars | Consumers configure the widget at runtime — there is no per-consumer build step. |
What to read next
- Theming — Canonical CSS-variable token reference for every widget.
- Widget reference — API docs for every widget shipped by gomawt.