Patterns · Widget & embed
Patterns

Widget & embed

An organiser embeds their rating on their own site. Two rules: nothing of ours leaks onto the host, nothing of the host's leaks onto us. This is a scoping problem, not a density — though a widget is compact by definition, because an embed is a guest, not a page.

Three widgets

One score, one list, one carousel. They share the same shell, the same tokens and the same isolation; they differ only in how much of the feedback they show.

WidgetShowsTypical placementHeight
scoreThe aggregate, the stars and the review countFooter, sidebar, next to a registration button~120px
listThe aggregate plus the most recent reviews, stackedA dedicated "what athletes said" blockGrows with count
carouselThe aggregate plus reviews, one at a timeInside a landing page where vertical space is scarce~220px

score

Maratona de Lisboa
4.7
★★★★★
412 ATHLETE REVIEWSSPORTING SCOUTER

list

The default when an organiser wants the words, not just the number. Reviews are newest first, never curated by score — a widget that hides the low ratings is an advertisement, and athletes stop trusting the badge.

Maratona de Lisboa
412 ATHLETE REVIEWS
4.7
★★★★★
★★★★★ 5.0
14 MAR 2026

Water every 5k and the finish was calm even in the last wave. Best organised race I have run in Lisbon.

Ana R. · Half marathon
★★★☆☆ 3.0
14 MAR 2026

Great course, but the bag drop queue took 40 minutes and there was no signage for it.

Miguel S. · Marathon

carousel

Same reviews, one at a time, when the host page cannot spare the height. It is the only widget with state, which makes it the only one with real accessibility obligations.

Maratona de Lisboa
4.7
★★★★★

Carousel rules

  • No autoplay. Not pausable autoplay, not "slow" autoplay — none. A testimonial that moves while it is being read is a testimonial nobody finishes, and WCAG 2.2.2 puts the burden on us the moment it moves.
  • Real <button>s at 44px, each with its own aria-label. Not arrows made of divs, not swipe-only.
  • Wraps at both ends, so the controls never disable and the count never lies. The position line is the feedback: "2 of 412".
  • One aria-live="polite" region, on the counter. The review text is not announced on change — the counter is, and the reader moves to the text. Announcing both says everything twice.
  • The slide is not a link. A whole clickable card inside someone else's page is a dark pattern; the "read all" link at the foot is the way out.
  • Height is fixed to the tallest slide, computed once. A carousel that resizes per slide reflows the host page under the reader's cursor.

Rules for all three

  • Newest first, never score-sorted, never filtered by rating. The count and the aggregate must reconcile with what the widget shows.
  • Truncate at four lines with a "read all" link. Never a "…more" that expands and reflows the host page.
  • Stars are gold at every tier and always sit beside the number — the same rule as everywhere else.
  • Every widget carries the Sporting Scouter mark and links back. An unattributed rating is a claim; an attributed one is evidence.

Two mechanisms, identical declarations

:host

The primary mechanism. A shadow root carries the token block, so the host's cascade cannot reach in and ours cannot reach out.

.ss-widget

The light-DOM fallback for hosts where a shadow root cannot be attached. Re-declares the same tokens plus the defensive resets a shadow root would have given free.

Both selectors carry the same declarations

Change one, change the other — the same class of defect as the theme.css / tokens.css drift that shipped 1.6:1 input borders. Two mirrors is two chances to forget.

What the widget block does

  • Re-anchors typography. The host's font-size and line-height are unknown and frequently hostile, so the widget states --font-sans, --text-sm, 1.55 and text-align: left itself.
  • States its own ground. surface-raised plus --shadow-widget — the one place elevation is load-bearing.
  • Contains layout and paint. contain: layout paint style and isolation: isolate, so a host's * { transition: all } or stray grid cannot reflow us.
  • Never links out with a colour surprise. Links inside a widget use --color-primary, underlined, so they read as ours on any ground.

Embedding

One snippet, one script tag, any number of widgets on the page.

<div
  data-ss-widget="carousel"      <!-- score | list | carousel -->
  data-ss-event="maratona-lisboa"
  data-ss-count="6"              <!-- list and carousel only -->
></div>

<script src="https://widgets.sportingscouter.com/v1/embed.js" async></script>

The script attaches a shadow root per element, falls back to .ss-widget when it cannot, and renders nothing at all if the event has no reviews — an empty widget on someone else's site is worse than no widget.

No framework, ever

A widget runs inside a page we do not control, next to a stack we cannot predict — WordPress, Squarespace, Wix, a hand-written HTML page from 2014. The embed is plain HTML, plain CSS and vanilla JavaScript. It is the one part of the system that shares no build with the apps.

ConstraintRule
DependenciesZero. No React, no Preact-in-disguise, no polyfill bundle, no CSS-in-JS.
OutputOne ES2019 IIFE plus one CSS string, both inside embed.js. Nothing is fetched to render the shell.
Budget12 KB gzipped for script, styles and mark together. A rating badge that costs more than the host's hero image will be removed.
NetworkOne GET for the event JSON. Nothing else — no fonts, no analytics, no beacon.
StorageNone. No cookies, no localStorage. A guest does not write to someone else's origin.
TypeSystem stack. The widget never loads DM Sans — a webfont request on a host page is a cost we have no right to impose.
FailureSilent. A failed fetch removes the container; it never leaves a spinner or an error on an organiser's homepage.
The one place the system's type rule bends

Everywhere else the answer is DM Sans. In an embed it is system-ui: the widget still owns its size, weight, line-height and colour, so it reads as ours — but it inherits the host's rendering rather than adding a font request to their critical path.

What widgets.sportingscouter.com serves

PathContentsCache
/v1/embed.jsThe loader, the three templates and the CSS stringimmutable, versioned by path
/v1/logo.svgThe mark used in every attribution lockupimmutable
/v1/e/<slug>.jsonAggregate, category scores and the most recent reviews5 minutes at the edge

/v1/ is in the path, not a query parameter: a breaking change ships as /v2/ and every existing embed keeps working forever. We cannot ask thousands of organisers to edit a script tag.

The three files

1 — HTML

Rendered by the script, but written as if by hand: semantic elements, real buttons, no div pretending to be a control. This is the carousel, in full.

<section class="ssw" aria-roledescription="carousel"
         aria-label="Athlete reviews for Maratona de Lisboa">
  <header class="ssw-head">
    <span class="ssw-name">Maratona de Lisboa</span>
    <span class="ssw-score">4.7</span>
    <span class="ssw-stars" aria-hidden="true">★★★★★</span>
  </header>

  <article class="ssw-slide">
    <p class="ssw-quote">Water every 5k and the finish was calm…</p>
    <p class="ssw-by">Ana R. · Half marathon</p>
  </article>

  <footer class="ssw-foot">
    <button type="button" class="ssw-btn" data-dir="-1" aria-label="Previous review">‹</button>
    <button type="button" class="ssw-btn" data-dir="1"  aria-label="Next review">›</button>
    <p class="ssw-count" aria-live="polite">1 of 3</p>
    <a class="ssw-mark" href="https://sportingscouter.com/e/maratona-lisboa">
      <img src="https://widgets.sportingscouter.com/v1/logo.svg" alt="" width="11" height="15">
      Sporting Scouter
    </a>
  </footer>
</section>

2 — CSS

One string, injected into the shadow root. Only the tokens the widget actually uses — the full set would be most of a stylesheet for a 120px badge. Class names are ssw- prefixed so the light-DOM fallback cannot collide with a host's classes.

:host, .ss-widget {
  /* only what the widget uses — literal values, no build step */
  --ssw-surface: #ffffff;  --ssw-text: #333333;   --ssw-muted: #64748b;
  --ssw-border: #e2e8f0;   --ssw-star: #f59e0b;   --ssw-link: #0077b3;
  --ssw-excellent: #1f7a4d;

  font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
  font-size: 14px; line-height: 1.55; color: var(--ssw-text);
  text-align: left;
  background: var(--ssw-surface);
  border-radius: 4px;
  box-shadow: 0 0 0 1px rgb(51 51 51 / .08), 0 2px 8px rgb(51 51 51 / .08);
  contain: layout paint style;
  isolation: isolate;
}
.ssw-btn {
  min-width: 44px; min-height: 44px;      /* the touch floor holds here too */
  border: 1px solid #8595a9; border-radius: 4px;
  background: none; color: inherit; cursor: pointer;
}
.ssw-btn:focus-visible { outline: 2px solid var(--ssw-link); outline-offset: 2px; }
.ssw-mark { display: inline-flex; align-items: center; gap: 6px;
            color: var(--ssw-muted); text-decoration: none;
            font-size: 10px; letter-spacing: .06em; text-transform: uppercase; }
Why literals here and tokens everywhere else

The embed cannot @import the design system — there is no build and no shared cascade inside a stranger's page. So the hexes are written out, and keeping them in step with theme.css is a release-checklist item, not something the build can guarantee. Six values. If that list grows past a dozen, the widget is doing too much.

3 — JavaScript

Vanilla, roughly 150 lines. The shape of the loader, with the parts that matter:

(function () {
  var API = 'https://widgets.sportingscouter.com/v1';

  function mount(el) {
    var kind = el.getAttribute('data-ss-widget') || 'score';
    var slug = el.getAttribute('data-ss-event');
    if (!slug) return;

    fetch(API + '/e/' + encodeURIComponent(slug) + '.json')
      .then(function (r) { return r.ok ? r.json() : Promise.reject(); })
      .then(function (data) {
        if (!data.count) return el.remove();          // nothing to show: leave no trace

        // Shadow root first; light DOM only where it is refused.
        var root;
        try { root = el.attachShadow({ mode: 'open' }); }
        catch (e) { el.classList.add('ss-widget'); root = el; }

        var style = document.createElement('style');
        style.textContent = CSS;                       // the string above
        root.appendChild(style);
        root.appendChild(render(kind, data, el));      // returns a DocumentFragment
      })
      .catch(function () { el.remove(); });            // silent failure, always
  }

  function boot() { document.querySelectorAll('[data-ss-widget]').forEach(mount); }
  document.readyState === 'loading'
    ? document.addEventListener('DOMContentLoaded', boot)
    : boot();
})();
  • No autoplay timer anywhere in the file. The carousel advances on click and on nothing else.
  • All text goes in with textContent, never innerHTML. Review bodies are user-written and land on someone else's domain.
  • Idempotent. mount() marks the element, so a host that injects the script twice gets one widget.
  • Height is measured once after the first render and pinned, so advancing a slide never reflows the host page.
Sporting Scouter Design System
Every token on this site is generated from theme.css.