Today Canvas Kit

Widget guidelines

How to build a widget that behaves well across every Today Canvas Kit host — what the host owns, what the widget owns, and the boundary in between.

Where your widget runs

The same widget bundle is mounted in multiple hosts through one of four transports — see the render-mode contract for the taxonomy:

  • Web workbench (apps/host-playground in this repo, plus production web Today pages). React renders your widget into a per-instance Shadow DOM root inside the host's tree.
  • Native webview (macOS / iOS hosts). A WKWebView loads the same bundle through the same WidgetLoader. Same JS engine surface, same React, same WidgetCtx.
  • Preview shell (tck-preview). Identical contract; the iframe is your widget's runtime, just like in production.
  • Embed routes (today-platform-web). Production browsers load widget.mjs from a same-origin BFF that unpacks .tckb server-side.

Implication: your widget cannot assume which host it runs in. No DOM reach into window.parent. No userAgent sniffing. No web-only APIs (use the hooks).

Lifecycle: the widget is stateless

Treat the widget like a pure projection of the host-managed doc store. Assume your component can be mounted, unmounted, or re-mounted at any moment — the user navigates away, the host migrates state, the widget crashes and reloads, a different render mode kicks in. The host guarantees:

  • Your manifest is loaded once per session.
  • A fresh WidgetCtx is wired up per mount.
  • The doc store survives mount/unmount cycles; your local React state does not.

Implication:

  • All persistent state lives in the doc store. Read it with useFieldState / useDoc. Never store anything you want to survive a remount in useState.
  • No localStorage, sessionStorage, IndexedDB, cookies, or BroadcastChannel. They're inaccessible in sandboxed iframes, partitioned by webview, and leak between widget instances. Persistence flows through the hooks only.
  • No module-level side effects. Don't kick off fetch at import time. Don't register global listeners outside React. Don't write to window.*. The module may be imported multiple times in a session.
  • Clean up every effect. Timers, observers, abort controllers — all cleaned up in the useEffect return. The user may navigate away mid-fetch; your widget must not leak.

Sizing: the host decides

You declare layout through layout. For grid widgets, list the fixed sizes the widget renders well at; the helper maps them to the resolved sizes / defaultSize manifest fields. The host picks one size (from user preference, defaults, or the canvas's responsive math) and gives you the resulting box.

You never set width or height yourself. Your outer container is always size-full so it fills whatever the host gave you, plus p-4 (16px) as the standard content inset:

// ❌ widget decides its own dimensions
<div style={{ width: 336, height: 160 }}>...</div>

// ❌ hugs to content
<div className='w-fit h-fit'>...</div>

// ✅ size-full + p-4 is the canonical outer shape
<div className='size-full p-4 ...'>...</div>

The p-4 inset is the standard breathing room between the host's tile chrome and your content. Use it everywhere by default. If your widget needs an edge-to-edge effect (a full-bleed image, a gradient that fills the corner, a list that scrolls under the chrome), drop the p-4 on the outer wrapper and put it on the inner content block instead — keep the inset on the main content area, just shifted inward.

// ✅ standard: padded outer
<div className='size-full p-4'>{content}</div>

// ✅ bleed effect: outer is flush, inner content keeps its inset
<div className='relative size-full'>
  <Background className='absolute inset-0' />
  <div className='relative p-4'>{content}</div>
</div>

Size variants

sizes: ['1x1', '2x1', '2x2', '4x2', '4x4', 'fill-auto']

Two flavors:

  • Fixed grid (1x1, 2x1, 1x2, 2x2, 4x2, 4x4) — host reserves an exact pixel slot from its cell grid. Both width and height are deterministic. Use these for tile-style widgets that fit a grid cell.
  • Fill-auto (fill-auto) — host gives you whatever width it has (a column in the canvas, a section header in the feed, full screen on mobile), and reads your height back via ResizeObserver. Your outer container is still size-full. The widget never sees, decides, or assumes its own width — you design for content that reflows across any reasonable column width.

For a grid widget, the host picks one size from the resolved sizes array based on:

  1. The user's saved preference for this instance, if any.
  2. The resolved defaultSize produced from layout.defaultSize.
  3. The canvas's available space (the host clamps a 4x2 tile to 2x2 on a 2-column breakpoint, etc.).

Reading the chosen size at runtime

const size = useCurrentWidgetSize() // re-renders on resize

Branch on size when your layout genuinely differs (e.g., hide the description in 1x1, show it in 2x1). Don't measure your own container — the size hook is the source of truth.

Layout: layout declares the authoring intent

New source should declare layout through defineManifest(...).layout:

  • Grid{ type: 'grid', supportedSizes, defaultSize } maps to fixed CxR sizes (1x14x4) and renders on the host's grid canvas (<TodayWidgetCanvas>) — sized to a grid cell, draggable, reorderable, resizable.
  • Auto-height'auto-height' or { type: 'auto-height' } maps to fill-auto and renders in the host's feed stack (<TodayFeedStack>) — a linear column, full width, height intrinsic to content, driven by the agent / page descriptor rather than user reordering.

The resolved manifest still contains sizes and defaultSize; defineManifest derives those fields from layout before bundling. The host partitions by isAutoHeightSize(size): fill-auto instances stack, fixed sizes go to the grid.

type describes the authoring profile and presentation intent:

  • type: 'widget' (default) — the interactive, grid-snapped tile.
  • type: 'feed' — marks a read-only snapshot card. Feed source must currently pair it with layout: 'auto-height'; that resolves to fill-auto and lands the card in the feed stack.

New Feed source is stateless: the bundler rejects doc-context SDK imports such as useFieldState, usePersistState, and useWidgetCtx in Feed mode. Historical bundles that used those APIs remain a runtime compatibility case; hosts must keep rendering them instead of crashing.

// Grid widget — fixed sizes place it on the canvas
export const manifest = defineManifest({
  type: 'widget',
  layout: {
    type: 'grid',
    supportedSizes: ['1x1', '2x1', '2x2'],
    defaultSize: '1x1',
  },
  // ...
})

// Feed card — auto-height is required today
export const manifest = defineManifest({
  type: 'feed',
  layout: 'auto-height',
  // ...
  summary: 'The card has one clear point and enough context for hosts or agents to index it.',
})

Author a feed card as presentation-only: don't reach for the doc store. The hooks (useFieldState / useDoc) are present — there's no type gate — but a snapshot shouldn't persist state, and the feed surface mounts cards read-only so writes wouldn't stick there anyway. Use feed cards for static greetings, agent-driven banners, or read-only summaries.

Chrome: the host owns it

The host paints the tile chrome around your widget — the rounded surface, border, shadow, padding. Don't add your own.

// ❌ doubled corner (your radius + host's radius)
<div className='size-full rounded-2xl bg-white'>...</div>

// ❌ extra border / shadow on top of the host's
<div className='size-full border shadow-lg'>...</div>

// ✅ flat outer; the host clips you to its tile radius
<div className='size-full bg-white'>...</div>

If you need a rounded sub-element (an inner accent, a button, a card-inside-a-card), put the border-radius on that inner element. Just not on the root.

Background

Default transparent. The host owns the surface. Your widget renders inside a host-provided frosted-translucent frame — a soft border, a compound shadow, and a blurred semi-transparent background, per the active theme. Your content sits on top of that frame; no opaque background of your own.

The contract (full spec: visual container contract):

  • The host renders the canonical TCK shell frame around your mount; you render inside it.
  • Your root element paints transparent by default.
  • Need a colour accent? Layer a subtle tint on top of the host frame — alpha ≤ ~15%, theme-aware. Above that and the host frame disappears under your fill, defeating the contract.
// ✅ low-alpha tint — host frame remains the surface
<article className='size-full bg-gradient-to-br from-sky-500/10 via-transparent to-amber-500/8 p-4'>

</article>
// ❌ opaque self-drawn card — hides the host frame; reads as pasted-on
//    against the stage. Forbidden unless your manifest declares
//    `chrome: 'opaque'` (see Trivial widgets below).
<article className='size-full bg-white dark:bg-zinc-950 p-4'>…</article>

Trivial widgets — manifest.chrome: 'opaque'

A demo / hello-tier widget that legitimately wants to own its full surface declares it explicitly:

export const manifest: WidgetManifest = {
  id: 'com.example.hello',
  // …
  chrome: 'opaque', // host omits its frame for this mount
}

The host detects chrome: 'opaque' at mount time and skips the shell frame. The widget renders directly into the slot and may paint whatever surface it wants. This is the documented escape hatch — opaque self-drawn surfaces are explicit, not accidental. An audit flags root-level bg-* with alpha > 15% (or no-alpha) on widgets without this opt-out.

Theme: the host controls it

The host decides the theme. Your widget reads it; never decides it.

Two equivalent ways to consume:

// 1. JS: subscribe to changes
const theme = useTheme() // 'light' | 'dark'

// 2. CSS: Tailwind `dark:` variant
<div className='bg-white dark:bg-zinc-950'>...</div>

How it works: the host hangs data-theme="dark" or data-theme="light" on the document root (your iframe document if you're sandboxed, the host's <html> if you're embedded). The useTheme() hook listens for changes. Tailwind's dark: variant matches [data-theme='dark'].

Don't reach for prefers-color-scheme

// ❌ Wrong — bypasses the host's theme
const isDark = matchMedia('(prefers-color-scheme: dark)').matches

// ❌ Wrong — same problem
<style>@media (prefers-color-scheme: dark) { ... }</style>

The host may render in light mode even when the OS is in dark mode (think a kiosk, a light-themed Today page on a dark-mode laptop, the user manually toggled). prefers-color-scheme is the OS preference, not the host preference. Always go through useTheme() or dark:.

Don't cache theme

// ❌ Wrong — theme can flip mid-session
const [theme] = useState(() => readTheme())

useTheme() is reactive — call it directly. Re-renders are cheap; the host already coalesces them.

Pointer affordance: let the host decide

For touch-oriented adjustments, use Tailwind's host-controlled tck-coarse: variant instead of reading the device media query directly:

<button className='text-[12px] tck-coarse:text-[14px]'>Open</button>

The host writes data-tck-pointer="fine" or data-tck-pointer="coarse" and the widget mount mirrors it into the shadow host. Preview can force either value, so a component should not hard-code @media (pointer: coarse) for style decisions the host may need to simulate.

State and persistence

import {
  useFieldState,
  useDoc,
  useTheme,
  useCurrentWidgetSize,
  type WidgetManifestInput,
} from '@todayai-labs/tck'
  • useFieldState(path, fallback) — read+write a JSON Pointer slice of the doc. Each setX(value) issues an RFC-6902 JSON Patch the host persists. Prefer this when you can — it patches narrowly.
  • useDoc() — read the whole doc, when you genuinely need the full document.
  • useCurrentWidgetSize() — current WidgetSize literal.
  • useTheme() — current theme.

Persistence path: doc-store-only. Nothing else survives across mounts:

// ❌ Forbidden — sandbox-partitioned, inaccessible in native webview
localStorage.setItem('foo', 'bar')
sessionStorage.setItem('foo', 'bar')
indexedDB.open('...')
document.cookie = '...'

// ❌ Forbidden — won't survive a reload
const [x, setX] = useState(initial)

// ✅ Use the doc store
const [x, setX] = useFieldState('/x', initial)

If you legitimately need a key/value side store (e.g., a draft buffer for a multi-step form, a cache of a remote response), declare permissions: ['storage.local'] and use the storage hooks the host provides — they route to a per-widget partition the host manages. Direct localStorage access is never correct.

Styling

Tailwind utility-first. The host's Tailwind build scans your widget source, so any class you reference in JSX shows up in the emitted CSS automatically.

Use stable utility names, not dynamic class strings. Tailwind cannot scan strings constructed at runtime — only classes that appear literally in your source make it into the bundle:

// ❌ Tailwind won't see `bg-${color}-500`, so it won't emit any of the colors
<div className={`bg-${color}-500`}>...</div>

// ✅ Map to literal class names
const COLOR_CLASS = { red: 'bg-red-500', green: 'bg-green-500' } as const
<div className={COLOR_CLASS[color]}>...</div>

Inline style={{ ... }} is fine for computed values — a translate from drag state, an interpolated angle, a brand color the user picked.

Responsive layout: container queries, not viewport queries

Your widget runs at whatever size the host gives you — from a 128×128 1×1 tile on a 1920px monitor to a full-bleed expansion on a 320px phone. The viewport doesn't tell you anything useful about the box you're rendering into.

Use Tailwind v4's container variants @sm: @md: @lg: @xl: @2xl: — not the viewport variants sm: md: lg: xl: 2xl:. The host's <WidgetMount> declares container-type: inline-size on every widget's root container, so @<size>: resolves against your widget's allotted inline-size. Viewport variants compile to @media queries — those would read the document viewport and produce semantically wrong layouts inside small slots on big screens.

// ❌ viewport variants — read the host's window size, not your widget
<div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3'>

// ✅ container variants — read your widget's actual size
<div className='grid grid-cols-1 @sm:grid-cols-2 @md:grid-cols-3'>

Same restriction applies to viewport units (100vh, 50vw, 100dvh, ...) — they read the document viewport. Use 100% to fill the slot, or container-query units (100cqi / 100cqw) for inline-size relative sizing. The bundler rejects @media viewport queries and viewport units at compile time with an error message naming the suggested replacement.

rem is fine to write. The bundler rewrites every Nrem literal so it resolves through the host's --tck-rem token instead of the document's <html> font-size. Your widget's intrinsic scale is decoupled from whatever the host page happens to set — and a host that wants a uniform "compact mode" can dial every widget at once by overriding --tck-rem.

Full rules are in the Widget CSS contract. The five-rule summary:

  1. No @media viewport queries (use @container).
  2. No viewport units (use 100% / cqi / cqw).
  3. No root-font-metric units other than rem (use explicit lengths).
  4. Nrem is rewritten to a host-controlled token (silent — write rem as usual).
  5. Widget stacking and font-size are isolated by the shadow reset (host-side, no per-widget work).

Layout: declare display on wrappers

A wrapper that holds children (a row of icons, a card with a header and body, anything with padding around inline content) should declare display: flex or display: grid explicitly. Don't fall back to the browser-default block.

Why: when an inline-level child (<span>, <Badge>, <svg>, etc.) sits inside a default-block wrapper, the wrapper enters inline formatting mode and the parent's strut leaks line-height leading into the box. The visible padding is no longer (padding + font-size) — it's (padding + line-height). You'll wonder why Figma says 28px and Chrome renders 33px.

// ❌ default-block wrapper, line-height leaks
<div className='border-b px-4 py-2'>
  <Eyebrow>Preview</Eyebrow>
</div>

// ✅ flex wrapper, padding matches the math
<div className='flex items-center border-b px-4 py-2'>
  <Eyebrow>Preview</Eyebrow>
</div>

This applies even with a single inline child. The fix is on the wrapper, never on the inline primitive.

Nested corner geometry

When a widget renders a rounded child element flush against the inside corner of a rounded container — an icon button against a card edge, a status pill in a card corner, an inner card-within-a-card — the child's corner radius must follow:

r_inner = max(0, r_outer − inset_gap)

where inset_gap is the padding (or margin) between the container's inner edge and the child. The max(0, …) floor keeps the math sane: when padding ≥ outer radius, the inner element goes square (rounded-none), not a negative radius the browser clamps to zero anyway.

Concrete cases:

  • rounded-3xl (24px) container with p-4 (16px) padding → child uses rounded-lg (8px) — concentric.
  • rounded-2xl (16px) container with p-4 (16px) padding → child uses rounded-none (0px). Square is correct. Don't reach for rounded-lg to "soften it" — that breaks the concentric read.
  • rounded-3xl (24px) container with p-2 (8px) padding → child uses rounded-2xl (16px) — still concentric.

Why it matters — the visual difference:

✓ concentric (r_inner = 8)         ✗ drifting (r_inner = r_outer = 24)
┌─────────────────────────┐         ┌─────────────────────────┐
│ ╭───────────────────╮   │         │  ╱─────────────────╲    │
│ │                   │   │         │ ╱                   ╲   │
│ │      child        │   │         │ │       child        │  │
│ │                   │   │         │ ╲                   ╱   │
│ ╰───────────────────╯   │         │  ╲─────────────────╱    │
└─────────────────────────┘         └─────────────────────────┘
                                       inner curves bulge
                                       past the parallel

When inner and outer curves are concentric, the corner reads as a single nested form — the eye traces continuous parallels. When they're not (typical bug: copy-pasting the outer's rounded-3xl onto the child without subtracting the padding), the inner curves bulge away from the outer curves and the relationship reads as "two unrelated rounded shapes that happen to sit close together," not "child nested inside parent."

Special case — single-side padding: if padding is asymmetric (e.g., pl-4 pr-2 py-3), use the adjacent edge's padding for each corner: bottom-left corner uses pl-4 + pb-3 whichever applies to the corner. In practice this is rare; the standard recipe p-4 against rounded-3xl covers most widget needs.

Async work

Any work that outlives a render needs cleanup. The user can navigate away, the host can unmount you, the iframe can be torn down. Patterns:

// ✅ fetch with abort + mounted check
useEffect(() => {
  const ctrl = new AbortController()
  let live = true
  void fetch(url, { signal: ctrl.signal })
    .then((r) => r.json())
    .then((data) => {
      if (!live) return
      setData(data)
    })
  return () => {
    live = false
    ctrl.abort()
  }
}, [url])

// ✅ timers / observers / event listeners — cleaned up in the return
useEffect(() => {
  const id = setInterval(tick, 1000)
  return () => {
    clearInterval(id)
  }
}, [])

Manifest

Every widget exports a manifest. New source should use defineManifest(...) with an explicit type. The bundler resolves defaults and bakes the populated manifest into the .tckb:

export const manifest = defineManifest({
  type: 'widget',
  layout: {
    type: 'grid',
    supportedSizes: ['1x1', '2x1', '2x2'], // every size your widget renders well at
    defaultSize: '1x1',
  },
  id: 'com.example.counter', // reverse-DNS, stable across versions
  name: 'Counter', // display name shown in pickers
  version: '0.1.0', // semver of *this widget bundle*
  state: {
    version: 1, // bump on any default shape change (add a migration!)
    default: { count: 0 }, // must be JSON-serializable
  },
  permissions: ['storage.local'], // optional — see Permissions below
})

Feed-card variant

A stateless feed card writes layout: 'auto-height' and omits defaultState. defineManifest resolves that to sizes: ['fill-auto'], defaultSize: 'fill-auto', and defaultState: {} before the bundle is written:

export const manifest = defineManifest({
  type: 'feed',
  layout: 'auto-height',
  // ...
})

Schema migrations

If you change the shape of state.default, bump state.version and ship a migrations array alongside your default export. The helper maps those fields to the resolved schemaVersion / defaultState wire fields. The host runs migrations forward when it restores a persisted instance whose stored schema is behind the current one. Migration failures clear the doc back to defaultState.

export const migrations = [
  {
    to: 2,
    run(doc: unknown) {
      const old = doc as { count: number }
      return { count: old.count, lastTickedAt: 0 } // shape v2
    },
  },
]

Permissions

permissions: ['agent.read', 'agent.write', 'storage.local']
  • agent.read — receive agent-published messages on the bus.
  • agent.write — publish messages to the agent.
  • storage.local — request a per-widget key/value partition (managed by the host, available via storage hooks). Use this instead of localStorage.

Widgets that don't request a permission get a no-op channel: ctx.agent.publish becomes a sentinel, ctx.agent.subscribe never fires. Fail soft, not loud.

Cross-host considerations

  • No DOM reach beyond your root. Don't document.querySelector for anything you didn't render. Your widget may be inside an iframe (sandboxed, opaque origin) where the surrounding DOM is unreachable anyway.
  • No window.parent, no window.top. In sandboxed mode these are partitioned; in embedded mode they exist but reaching into them is a layering violation.
  • No network without thought. The host may enforce CSP that blocks arbitrary fetches. Network calls should be feature-gated and resilient to denial.
  • Native webview quirks. Some browser APIs (Notification, Permission, certain Service Worker hooks) behave differently or are absent. If you depend on a Web-only API, feature-detect and degrade.

Quick checklist

Before publishing:

  • Outer container is size-full (no fixed width / height).
  • No border-radius / border / box-shadow on the outer container.
  • Background is intentional — transparent or theme-aware, no leftover defaults.
  • All declared layout.supportedSizes actually look reasonable when the host renders them.
  • Theme branches via useTheme() or Tailwind dark: — never prefers-color-scheme.
  • Responsive layout uses @container / @<size>: variants, not @media / viewport variants. No viewport units (vh / vw / dvh / ...).
  • No localStorage / sessionStorage / IndexedDB / cookies anywhere.
  • No window.parent, no module-level side effects, no DOM queries outside your render tree.
  • Every useEffect cleans up (timers cleared, observers disconnected, fetches aborted).
  • Async work checks a mounted flag before setState.
  • state.default is JSON-serializable; state.version reflects the current shape.
  • If you bumped state.version, you exported a migrations array forward from the previous version.
  • Layout wrappers declare flex / grid (the rule above), not browser-default block.
  • Tailwind classes are literal in source — no bg-${color}-500 runtime strings.

Why these rules exist

The host owns layout, chrome, theme, persistence, lifecycle. The widget owns content and behavior. When those boundaries blur, you get widgets that work in one host and break in another — wrong corner radius in webview, theme that doesn't propagate in dark mode, state that vanishes on reload, padding that disagrees with the design spec. The rules above are the boundary; staying behind them is what makes a widget portable across every TCK host.

On this page