Creating a widget
This guide walks through adding a new widget type so it appears in the picker and can be placed, configured, and persisted on a view.
Widgets are schema-driven: a Zod schema validates config, sample data provides sensible fallbacks, a React component renders, and an entry in the registry ties it all together.
Anatomy of a widget folder
Each widget lives under
src/widgets/widgets/
in its own folder. A typical folder (modeled on world-clock) contains:
src/widgets/widgets/my-widget/
MyWidget.tsx # the render component (takes WidgetRenderProps)
ConfigPanel.tsx # optional type-specific config panel
schema.ts # Zod schema + inferred config type
sample-data.ts # default/fallback config
index.ts # barrel exports
*.test.ts(x) # colocated tests
Step 1 — Define the schema and sample data
Create schema.ts with a Zod schema and export the inferred type:
// src/widgets/widgets/my-widget/schema.ts
import { z } from "zod";
export const MyWidgetData = z.object({
label: z.string(),
refreshSeconds: z.number().int().positive().default(30),
});
export type MyWidgetData = z.infer<typeof MyWidgetData>;
Then sample-data.ts provides the fallback used when no config is set:
// src/widgets/widgets/my-widget/sample-data.ts
import type { MyWidgetData } from "./schema";
export const sampleMyWidget: MyWidgetData = {
label: "Hello",
refreshSeconds: 30,
};
Step 2 — Build the component
The component receives WidgetRenderProps from the canvas:
// src/widgets/widgets/my-widget/MyWidget.tsx
import * as React from "react";
import type { WidgetRenderProps } from "@/widgets/registry";
import { sampleMyWidget } from "./sample-data";
import type { MyWidgetData } from "./schema";
export function MyWidget({ config }: WidgetRenderProps) {
// Merge saved config over the sample defaults.
const data: MyWidgetData =
config && Object.keys(config).length > 0
? { ...sampleMyWidget, ...(config as Partial<MyWidgetData>) }
: sampleMyWidget;
return <div className="p-4 text-2xl">{data.label}</div>;
}
WidgetRenderProps provides:
| Prop | Type | Purpose |
|---|---|---|
config | Record<string, unknown> | undefined | Per-instance saved config (falls back to sample data). |
widgetId | string | undefined | The instance id, used to route cross-widget actions. |
Step 3 — Export from the folder barrel
// src/widgets/widgets/my-widget/index.ts
export { MyWidget } from "./MyWidget";
export { sampleMyWidget } from "./sample-data";
export type { MyWidgetData } from "./schema";
// export {MyWidgetConfigPanel} from './ConfigPanel'; // if you add one
Then re-export it from the widgets barrel src/widgets/index.ts:
export * from "./widgets/my-widget";
Step 4 — Register the widget
Add a WidgetDefinition to WIDGET_DEFINITIONS in
src/widgets/registry.tsx:
import { Sparkles } from 'lucide-react';
{
type: 'my-widget',
displayName: 'My Widget',
description: 'A short description shown in the picker.',
category: 'Data & Charts', // which picker tab it appears under
icon: Sparkles, // lucide icon shown in the picker
defaultSize: {w: 3, h: 4},
// dataless: true, // hides the drawer's Data section
// hideActions: true, // hides the drawer's Actions section
// defaultConfig: {}, // seed config for a fresh instance
component: MyWidget,
},
The WidgetDefinition fields:
| Field | Required | Purpose |
|---|---|---|
type | ✓ | Stable key persisted on the view (e.g. "my-widget"). |
displayName | ✓ | Label in the picker and default title. |
description | Picker subtitle. | |
category | ✓ | Picker tab the widget appears under (a WidgetCategory). |
icon | ✓ | Lucide icon shown next to the widget in the picker. |
defaultSize | ✓ | Initial size in grid units when added from the picker. |
defaultConfig | Seed config applied to a fresh instance. | |
dataless | Hide the Data section; open the drawer on Appearance. | |
hideActions | Hide the Actions section. | |
component | ✓ | The React component to mount. |
After this step the widget is available from the picker.
Step 5 — Add a config panel (optional)
To give the widget a custom Data/Appearance UI in the drawer, build a
ConfigPanel.tsx and register it in WIDGET_CONFIG_PANELS in
src/widgets/config.tsx.
A panel receives:
interface WidgetConfigPanelProps {
widgetId: string;
config: Record<string, unknown> | undefined;
updateConfig: (patch: Record<string, unknown>) => void;
section: "data" | "appearance";
siblings: WidgetInstance[];
}
Without a panel, the widget still gets the shared title field and any applicable default sections.
Step 6 — Test it
Colocate a test next to the component and run the suite:
npm run test
See Testing for conventions.
Step 7 — Document it
A widget isn't done until it's documented. Add a guide and wire it into the docs site so it appears in the sidebar:
- Create
website/docs/widgets/my-widget.mdwith frontmatter (id,title, and the nextsidebar_position). Follow the shape of the existing widget guides: At a glance, Configuration, Config panel, Cross-widget actions, and Notable behavior. - Add the page id to the
Widgetscategory in website/sidebars.ts. The sidebar is defined explicitly, so a new doc has no link until it is listed here. - Add a row to the catalog table in website/docs/widgets/overview.md and to the registered-widgets table in website/docs/reference/widget-registry.md.
Checklist
-
schema.tswith a Zod schema and inferred type -
sample-data.tsfallback config - Component accepting
WidgetRenderProps - Folder
index.tsexports, re-exported fromsrc/widgets/index.ts - Entry in
WIDGET_DEFINITIONS - Optional config panel registered in
config.tsx - Colocated test
- Widget guide in
website/docs/widgets/and listed insidebars.ts - Rows added to the catalog and widget-registry reference tables