Skip to content

Embeddable widgets

Widgets that keep their styles to themselves.

Each widget renders into its own shadow root, so the page's loud CSS below does not reach it. The rating and the color picker tell the page about changes with custom events, and the countdown shows slotted content when it ends. Slotted content, such as the rating's question, still belongs to the page, so it keeps the page's pink.

Preview

Open the page on its own

Code

Save it as an .html file and open it in a browser. It loads Signy and Iris CSS from the CDN, so there is nothing to install or build.

Show the full page
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="color-scheme" content="light dark">
    <title>Embeddable widgets - Signy example</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/iriscss@1/dist/iris.min.css">
    <style>
      /* The host page's own loud CSS. It does not reach inside the widgets. */
      .host-page button {
        border: 3px dotted light-dark(#b0005c, #ff7ab6);
        font-family: "Comic Sans MS", cursive;
        text-transform: uppercase;
      }

      .host-page span {
        color: light-dark(#b0005c, #ff7ab6);
      }
    </style>
    <script src="https://cdn.jsdelivr.net/npm/signy@1/dist/signy.global.min.js"></script>
  </head>
  <body>
    <main class="container host-page">
      <h1>A page with three widgets</h1>
      <p>This page styles every <code>button</code> and <code>span</code> in pink: <button>like this</button> <span>and this</span>.</p>

      <div class="grid">
        <article class="card">
          <header><strong>Star rating</strong></header>
          <div id="rating"><span slot="label">How was your coffee?</span></div>
          <p><output id="rating-output">No rating yet.</output></p>
        </article>
        <article class="card">
          <header><strong>Countdown</strong></header>
          <div id="countdown"><strong slot="done">🚀 Launched!</strong></div>
        </article>
        <article class="card">
          <header><strong>Color picker</strong></header>
          <div id="color"></div>
          <p><output id="color-output">Pick a color.</output></p>
        </article>
      </div>
    </main>

    <script>
      const { Component, Signal, Computed, application, html, Show } = Signy;

      const BASE = `
        :host { display: block; font-family: system-ui, sans-serif; }
        button { font: inherit; border-radius: 6px; cursor: pointer; }
      `;

      class Rating extends Component {
        shadow = true;
        styles = BASE + `
          ::slotted(*) { display: block; margin-bottom: 0.25rem; font-weight: 600; }
          .star { border: 0; padding: 0 0.1rem; font-size: 1.75rem; color: #f59e0b; background: none; }
        `;
        stars = new Signal(0);

        rate(stars) {
          this.stars.value = stars;
          this.emit("rate", { stars });
        }

        render() {
          return html`
            <div>
              <slot name="label"></slot>
              ${[1, 2, 3, 4, 5].map((n) => html`
                <button class="star" aria-label=${`${n} stars`} aria-pressed=${() => String(n === this.stars.value)}
                  onClick=${() => this.rate(n)}>${() => (n <= this.stars.value ? "★" : "☆")}</button>
              `)}
            </div>
          `;
        }
      }

      // Shows the host's slot="done" content when the time runs out
      class Countdown extends Component {
        shadow = true;
        styles = BASE + `
          .time { font-size: 2.5rem; font-variant-numeric: tabular-nums; margin: 0 0 0.5rem; }
          button { padding: 0.4rem 0.9rem; border: 1px solid #7c3aed; color: #fff; background: #7c3aed; }
        `;
        left = new Signal(5);
        running = new Signal(false);
        done = new Computed(() => this.left.value === 0);

        start() {
          if (this.running.value) return;
          this.left.value = 5;
          this.running.value = true;
          const timer = setInterval(() => {
            this.left.set((s) => s - 1);
            if (this.left.peek() === 0) {
              clearInterval(timer);
              this.running.value = false;
            }
          }, 1000);
          this.onUnmount(() => clearInterval(timer));
        }

        render() {
          return html`
            <p class="time" role="timer">00:0${() => this.left.value}</p>
            ${Show({ when: () => this.done.value, children: html`<slot name="done"></slot>` })}
            <p><button disabled=${() => this.running.value} onClick=${() => this.start()}>Start</button></p>
          `;
        }
      }

      class ColorPicker extends Component {
        shadow = true;
        styles = BASE + `
          .swatches { display: flex; gap: 0.5rem; flex-wrap: wrap; }
          .swatch { width: 2.25rem; height: 2.25rem; border: 2px solid transparent; }
          .swatch[aria-pressed="true"] { border-color: currentColor; }
        `;
        colors = ["#ec4899", "#8b5cf6", "#0ea5e9", "#10b981", "#f59e0b"];
        picked = new Signal(null);

        pick(color) {
          this.picked.value = color;
          this.emit("pick", { color });
        }

        render() {
          return html`
            <div class="swatches">
              ${this.colors.map((color) => html`
                <button class="swatch" style=${`background: ${color}`} aria-label=${color}
                  aria-pressed=${() => String(this.picked.value === color)} onClick=${() => this.pick(color)}></button>
              `)}
            </div>
          `;
        }
      }

      application(Rating).mount("#rating");
      application(Countdown).mount("#countdown");
      application(ColorPicker).mount("#color");

      // The page listens to the widgets' custom events like any other event
      document.querySelector("#rating").addEventListener("rate", (event) => {
        document.querySelector("#rating-output").textContent = `You gave ${event.detail.stars} stars.`;
      });
      document.querySelector("#color").addEventListener("pick", (event) => {
        const output = document.querySelector("#color-output");
        output.textContent = `You picked ${event.detail.color}.`;
        output.style.color = event.detail.color;
      });
    </script>
  </body>
</html>

What it uses