Skip to content
Notion Arena

Building

Authoring a widget

A widget is one self-contained HTML file. No framework, no build step, no dependencies. That constraint is what makes both delivery channels possible from a single artifact, and it means four contracts have to be implemented identically in every file, or the catalog stops feeling like one product.

The four non-negotiables

RuleWhyEnforced by
Zero external requestsThe sandbox has no network. A CDN link is a blank block, and a webfont is a layout shift on every load.Inline everything: CSS in <style>, JS in <script>, images as data: URIs.
Read config from two sourcesThe /embed path has a URL; the downloaded path does not. One file must serve both.The bk-config loader, in the same order, in every widget.
Implement theme and accentA dashboard is re-skinned as a whole. Six widgets with six hardcoded palettes cannot be.<code>data-theme</code> on <code>&lt;html&gt;</code> plus <code>--accent</code>.
Namespace persisted stateTwo widgets on one page must not collide, and a schema change must not corrupt stored data.<code>notion-arena.&lt;slug&gt;.v1</code>

The file skeleton

Copy this and fill in the middle. Everything outside <!-- widget --> is boilerplate that should be identical across the catalog.

public/widgets/my-widget.htmlhtml
<!doctype html>
<html lang="en" data-theme="auto">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>My Widget</title>
<style>
  :root {
    --accent: #2383e2;
    --bg: #ffffff; --fg: #37352f; --muted: #787774;
    --line: rgba(55,53,47,.14); --raised: #f7f7f5;
  }
  html[data-theme="dark"] {
    --bg: #191919; --fg: #e6e6e4; --muted: #9b9a97;
    --line: rgba(255,255,255,.14); --raised: #202020;
  }
  @media (prefers-color-scheme: dark) {
    html[data-theme="auto"] {
      --bg: #191919; --fg: #e6e6e4; --muted: #9b9a97;
      --line: rgba(255,255,255,.14); --raised: #202020;
    }
  }
  @media (prefers-reduced-motion: reduce) {
    *, *::before, *::after { animation: none !important; transition: none !important; }
  }
  * { box-sizing: border-box; }
  html, body { height: 100%; margin: 0; }
  body { overflow: hidden; background: var(--bg); color: var(--fg);
         font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI",
               Helvetica, Arial, sans-serif; }
  .wrap { height: 100%; display: flex; flex-direction: column; padding: 14px; gap: 10px; }
</style>
</head>
<body>
<div class="wrap">
  <!-- widget -->
</div>

<script>
(function () {
  "use strict";

  /* ---- 1. configuration ------------------------------------- */
  var baked = {};
  try {
    baked = JSON.parse(
      (document.getElementById("bk-config") || {}).textContent || "{}"
    );
  } catch (e) { baked = {}; }

  var qs = new URLSearchParams(location.search);
  var cfg = Object.assign({}, baked);
  qs.forEach(function (v, k) { cfg[k] = v; });

  var num  = function (k, d) { var n = parseFloat(cfg[k]); return isFinite(n) ? n : d; };
  var bool = function (k, d) {
    return cfg[k] === undefined ? d : ["true", "1"].indexOf(String(cfg[k])) > -1;
  };
  var str  = function (k, d) { return cfg[k] === undefined || cfg[k] === "" ? d : String(cfg[k]); };

  document.documentElement.setAttribute("data-theme", str("theme", "auto"));
  document.documentElement.style.setProperty("--accent", str("accent", "#2383e2"));

  /* ---- 2. state --------------------------------------------- */
  var KEY = "notion-arena.my-widget.v1";
  var load = function () {
    try { return JSON.parse(localStorage.getItem(KEY) || "null") || {}; }
    catch (e) { return {}; }
  };
  var save = function (s) {
    try { localStorage.setItem(KEY, JSON.stringify(s)); } catch (e) { /* private mode */ }
  };

  /* ---- 3. widget -------------------------------------------- */
})();
</script>
</body>
</html>
Why the IIFE and var
Notion renders blocks in WebViews of varying age, and an HTML block gives you no error surface: a syntax error is a blank rectangle. The defensive style is deliberate: wrap storage access in try/catch (Safari private mode throws on setItem), and never let one bad parameter throw during initialisation.

Sizing and layout

A Notion block can be resized by dragging its edge to anything between roughly 100px and 900px tall and a full page column wide. Your widget has to survive all of it.

  • body { overflow: hidden } and .wrap { height: 100% }. A scrollbar inside a Notion block is a bad experience: the page already scrolls.
  • Declare height, minHeight and maxHeight in the catalog entry. The configurator uses them to bound the resize slider, and the card preview uses height.
  • Prefer fluid units and clamp() over breakpoints. There is no reliable viewport width to design against, because the same file renders in a full-width page, a narrow column and a mobile screen.
  • Test at 240px wide and at 120px tall. If it is unusable there, the catalog entry's minHeight should say so honestly.

Export, and the clipboard caveat

A sandboxed block cannot send anything anywhere. If a widget produces something worth keeping (a whiteboard drawing, an org chart, a calculator result) it has to hand it back through the clipboard or the console.

the export pattern used by whiteboard, org-chart, roi-calculator, quote-cardjs
async function copy(payload, label) {
  try {
    await navigator.clipboard.writeText(payload);
    flash("Copied " + label);
  } catch (err) {
    // Clipboard writes need a user gesture and are blocked in some WebViews.
    // Always leave the payload in the console as a fallback.
    console.log("[notion-arena] " + label + "\n" + payload);
    flash("Copy blocked. Payload logged to the console");
  }
}
  • Offer the format the destination actually wants: JSON for re-importing, CSV for a Notion database import, SVG for a cover image.
  • A Download button using a blob: URL and an <a download> works in the desktop app and in browsers, and fails silently in some mobile WebViews. Pair it with the copy path.
  • Never rely on the clipboard alone. Every export in this catalog also logs, and says so in the toast.

Adding it to the catalog

One file, one entry. Nothing else needs to change: the embed route, the download route, the registry manifest, the JSON Schema endpoint, the sitemap and the catalog UI all read from the same array.

lib/catalog.tsts
{
  slug: "my-widget",
  name: "My Widget",
  kind: "widget",
  emoji: "🧩",
  category: "visuals",
  tier: "free",
  plan: "Free",
  badge: "new",
  version: "1.0.0",
  updatedAt: "2026-10-11",
  tagline: "One line that says what it is for.",
  description:
    "Two or three sentences: what it shows, what it remembers, and the thing it cannot do.",
  tags: ["visual", "canvas"],
  features: [
    "Does the thing",
    "Remembers your last session per browser",
    "Exports SVG",
  ],
  useCases: [
    "Top of a project dashboard",
    "Inside a weekly review page",
  ],
  capabilities: {
    interactive: true,
    offlineSafe: true,
    persistsLocally: true,
    writesToNotion: false,   // no widget may ever set this true
    needsNetwork: false,      // false for widgets: the file is self-contained
  },
  delivery: ["embed", "html-block", "download"],
  file: "/widgets/my-widget.html",
  height: 260,
  minHeight: 160,
  maxHeight: 620,
  params: [
    { key: "label",  type: "string",  label: "Label",  default: "My Widget" },
    { key: "count",  type: "number",  label: "Count",  default: 10, min: 1, max: 100, step: 1 },
    { key: "compact",type: "boolean", label: "Compact",default: false },
    { key: "mode",   type: "select",  label: "Mode",   default: "a",
      options: [{ value: "a", label: "A" }, { value: "b", label: "B" }] },
    { key: "accent", type: "color",   label: "Accent", default: "#2383e2" },
    { key: "theme",  type: "select",  label: "Theme",  default: "auto",
      options: [{ value: "auto", label: "Match Notion" },
                { value: "light", label: "Light" },
                { value: "dark", label: "Dark" }] },
  ],
}
params is a whitelist, not documentation
buildConfig() drops any key that is not declared here before it reaches the file. So an undeclared parameter is not merely undocumented: it is silently unreachable through the embed URL. If a widget reads cfg.something, it must appear in params.

Pre-merge checklist

No external requests
grep the file for http://, https://, //cdn and @import. Zero hits.
Renders with no parameters
Load /widgets/my-widget.html directly. Defaults must produce something sensible.
Theme works both ways
theme=light and theme=dark, plus auto under both OS preferences.
Accent recolors coherently
Try #e03e3e and #0f7b6c. Nothing should stay hardcoded blue.
Survives 120px tall
No overflow, no clipped controls, no scrollbars.
Survives 240px wide
Labels truncate or wrap rather than pushing layout out.
State persists and recovers
Reload keeps it. Corrupt localStorage manually; it must not throw.
Reduced motion respected
prefers-reduced-motion: reduce stops all animation.
Live timers tick on their own
A countdown or clock repaints from inside its interval, and reads what is left from Date.now() against a fixed end timestamp. A counter that only decrements drifts, and a UI that only repaints on click looks frozen: Notion throttles a background iframe.
Escape-safe config
A label containing </script> and a quote must not break the page.
Export has a console fallback
Block the clipboard and confirm the payload is logged.
Catalog entry matches source
Every param the file reads is declared; every declared param is read.
Schema endpoint updated
GET /r/schema.json includes the new widget automatically. Verify it appears.

Testing without Notion

You do not need a Notion workspace to develop a widget. Three URLs cover almost everything:

URLWhat it exercises
/widgets/my-widget.htmlThe raw file. No config injected, no CSP. Fastest iteration loop, and the closest thing to what an HTML block receives.
/embed/my-widget?count=5&theme=darkThe hosted path: parameter coercion, the whitelist, the CSP header, and framing.
/api/widget/my-widget?count=5The download path: config baked into the bk-config tag, served with a Content-Disposition attachment header.

Diffing the two paths

Download the configured file and open it directly. It must render identically to the embed URL with the same parameters. If it does not, the widget is reading something from location that the sandbox will not have, usually location.search or location.href.

# same config, two channels, byte-comparable output
curl -s "localhost:3000/api/widget/my-widget?count=5&theme=dark" > baked.html
open baked.html   # must match /embed/my-widget?count=5&theme=dark