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-playgroundin 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
WKWebViewloads the same bundle through the sameWidgetLoader. Same JS engine surface, same React, sameWidgetCtx. - 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.mjsfrom a same-origin BFF that unpacks.tckbserver-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
WidgetCtxis 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 inuseState. - 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
fetchat import time. Don't register global listeners outside React. Don't write towindow.*. The module may be imported multiple times in a session. - Clean up every effect. Timers, observers, abort controllers — all cleaned up in the
useEffectreturn. The user may navigate away mid-fetch; your widget must not leak.
Sizing: the host decides
You declare which sizes your widget renders well at. The host picks one (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: 280, height: 132 }}>...</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 stillsize-full. The widget never sees, decides, or assumes its own width — you design for content that reflows across any reasonable column width.
The host picks one size from your sizes array based on:
- The user's saved preference for this instance, if any.
- Your
defaultSize. - The canvas's available space (the host clamps a
4x2tile to2x2on a 2-column breakpoint, etc.).
Reading the chosen size at runtime
const size = useCurrentWidgetSize() // re-renders on resizeBranch 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.
Card types: widget vs feed
Two card types share the exact same widget code shape; only the manifest differs.
cardType: 'widget'(default) — lives in the host's grid canvas. Sized to a grid cell. Draggable. Has a doc store. The user can reorder, resize, pin.cardType: 'feed'— lives in the host's feed stack. A linear vertical column of cards above the grid. Always full width. Height intrinsic to content. The user does not reorder feed cards directly — they're driven by the agent / page descriptor.
The grid canvas and the feed stack are separate regions in the host — feed cards never land in the grid, grid widgets never land in the feed.
// Grid widget
export const manifest: WidgetManifest = {
// ...
sizes: ['1x1', '2x1', '2x2'],
defaultSize: '1x1',
// cardType defaults to 'widget'
}
// Feed card
export const manifest: WidgetManifest = {
// ...
sizes: ['fill-auto'], // feed cards are always fill-auto
defaultSize: 'fill-auto',
cardType: 'feed',
}A feed card has no doc store — it's presentation-only. useFieldState / useDoc are no-ops; ctx.applyPatch warns and drops the call. 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
<FrostedFrame>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 <FrostedFrame> wrap. 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.
State and persistence
import {
useFieldState,
useDoc,
useTheme,
useCurrentWidgetSize,
type WidgetManifest,
} from '@todayai-labs/tck'useFieldState(path, fallback)— read+write a JSON Pointer slice of the doc. EachsetX(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()— currentWidgetSizeliteral.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.
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 withp-4(16px) padding → child usesrounded-lg(8px) — concentric.rounded-2xl(16px) container withp-4(16px) padding → child usesrounded-none(0px). Square is correct. Don't reach forrounded-lgto "soften it" — that breaks the concentric read.rounded-3xl(24px) container withp-2(8px) padding → child usesrounded-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 parallelWhen 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:
export const manifest: WidgetManifest = {
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*
schemaVersion: 1, // bump on any defaultState shape change (add a migration!)
sizes: ['1x1', '2x1', '2x2'], // every size your widget renders well at
defaultSize: '1x1',
defaultState: { count: 0 }, // must be JSON-serializable
description: '...', // shown in the inspector
permissions: ['storage.local'], // optional — see Permissions below
}Feed-card variant
export const manifest: WidgetManifest = {
// ...
sizes: ['fill-auto'],
defaultSize: 'fill-auto',
cardType: 'feed',
}Schema migrations
If you change the shape of defaultState, bump schemaVersion and ship a migrations array alongside your default export. 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 oflocalStorage.
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.querySelectorfor 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, nowindow.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 fixedwidth/height). - No
border-radius/border/box-shadowon the outer container. - Background is intentional — transparent or theme-aware, no leftover defaults.
- All declared
sizesactually look reasonable when the host renders them. - Theme branches via
useTheme()or Tailwinddark:— neverprefers-color-scheme. - No
localStorage/sessionStorage/IndexedDB/ cookies anywhere. - No
window.parent, no module-level side effects, no DOM queries outside your render tree. - Every
useEffectcleans up (timers cleared, observers disconnected, fetches aborted). - Async work checks a mounted flag before
setState. -
defaultStateis JSON-serializable;schemaVersionreflects the current shape. - If you bumped
schemaVersion, you exported amigrationsarray 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}-500runtime 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.
Live widget vs feed card
Two business-level widget styles share the same architecture, SDK, and ABI. The difference is convention, not capability.
Widget lifecycle (source → bundle → run)
How a widget moves from a .tck.tsx source file to a mounted React tree in a host — and where to put work that survives across this chain.