Skip to content

Walkthrough: the S&P 500 widget

widgets/sp500 is a good model for a data widget: it declares one host, fetches, computes, draws an SVG chart, animates only when it’s visible and shows freshness. Read the full source in the repository; this page explains the parts.

widgets/sp500/
widget.json manifest, one host, three options
index.html the card markup
sp500.css styles (tokens, trend colors)
sp500.js fetch, compute, render, hover
chart.js pure chart math: samples, denoise, scale, smooth path, nearest
preview.png the Browse screenshot
icon.png the tab icon
{
"id": "sp500", "name": "S&P 500", "version": "1.1.1",
"width": 340, "height": 196,
"permissions": { "hosts": ["query1.finance.yahoo.com"] },
"options": {
"symbol": { "label": "Symbol", "type": "text", "default": "^GSPC", "hint": "Any Yahoo Finance symbol, e.g. ^IXIC, ^DJI, AAPL" },
"label": { "label": "Display name", "type": "text", "default": "" },
"range": { "label": "Chart range", "type": "select", "default": "1d",
"choices": [{ "value": "1d", "label": "1 day" }, { "value": "5d", "label": "5 days" }] }
}
}

One host is all it needs. Any other request would be blocked. Three options give you a different index per copy: add a second copy with symbol set to ^IXIC and you have the Nasdaq next to it.

function url({ symbol, range }) {
return `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}?range=${range}&interval=${RANGES[range].interval}`;
}
const json = await umbra.fetchJson(url(o));

1d uses 5-minute samples, 5d uses 15-minute samples. The response is real JSON: regularMarketPrice, chartPreviousClose, the closes and timestamps, and the trading period for the open/closed dot. null gaps are dropped. A failure calls umbra.status?.failed(err), so the status dot dims the numbers, and the widget only prints an error message when it has no data to show.

const change = d.price - d.prev;
const pct = d.prev !== 0 ? (change / d.prev) * 100 : 0;
const trend = change > 0 ? "up" : change < 0 ? "down" : "flat";

The sign picks the color (green, red or grey through --up, --down, --flat) and toggles trend-up / trend-down classes, so a Windows contrast theme gets an arrow instead of relying on color.

The pure math lives in chart.js, so it’s easy to reason about:

  1. Denoise: a 3-point moving average on interior samples. The endpoints stay exact, so the live dot sits on the real last price.
  2. Scale: the y-range always includes the previous close, so the dashed reference line stays inside the chart.
  3. Smooth curve: a Catmull-Rom spline converted to cubic Béziers, with control points C1 = P1 + (P2 - P0)/6 and C2 = P2 - (P3 - P1)/6. It passes through every sample with no corners.

The chart is SVG sized from the element (clientWidth/clientHeight) and redrawn by a ResizeObserver, so it stays crisp when the user resizes the widget.

umbra.tween(shownPrice, d.price, 700, (v) => (el.price.textContent = umbra.fmt(v)));

The price counts to its new value with ease-out-cubic (1 - (1 - t)^3), and the line draws in via stroke-dashoffset. Both are skipped when the window is covered or animations are off: the code checks document.visibilityState and data-motion, and umbra.tween jumps to the end value by itself.

A binary-search-style Chart.nearest finds the sample closest to the pointer for the crosshair and tooltip. The time is formatted with umbra.locale and umbra.hour12, and the number with umbra.fmt, so a German Windows shows 7.744,06.

new ResizeObserver(() => current && drawChart()).observe(el.chart);
umbra.onSettings(() => load());
umbra.every(load);

umbra.every(load) runs now and every refresh interval; onSettings reloads when an option changes. Double-clicking the card calls umbra.openUrl to open the symbol on Yahoo Finance.