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 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 stillsize-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:
- The user's saved preference for this instance, if any.
- The resolved
defaultSizeproduced fromlayout.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.
Layout: layout declares the authoring intent
New source should declare layout through defineManifest(...).layout:
- Grid —
{ type: 'grid', supportedSizes, defaultSize }maps to fixedCxRsizes (1x1…4x4) 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 tofill-autoand 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 withlayout: 'auto-height'; that resolves tofill-autoand 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. 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.
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:
- No
@mediaviewport queries (use@container). - No viewport units (use
100%/cqi/cqw). - No root-font-metric units other than
rem(use explicit lengths). Nremis rewritten to a host-controlled token (silent — writeremas usual).- 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 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. 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 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
layout.supportedSizesactually look reasonable when the host renders them. - Theme branches via
useTheme()or Tailwinddark:— neverprefers-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
useEffectcleans up (timers cleared, observers disconnected, fetches aborted). - Async work checks a mounted flag before
setState. -
state.defaultis JSON-serializable;state.versionreflects the current shape. - If you bumped
state.version, 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.
Interactive 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.