Skip to main content

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:

PropTypePurpose
configRecord<string, unknown> | undefinedPer-instance saved config (falls back to sample data).
widgetIdstring | undefinedThe 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:

FieldRequiredPurpose
typeStable key persisted on the view (e.g. "my-widget").
displayNameLabel in the picker and default title.
descriptionPicker subtitle.
categoryPicker tab the widget appears under (a WidgetCategory).
iconLucide icon shown next to the widget in the picker.
defaultSizeInitial size in grid units when added from the picker.
defaultConfigSeed config applied to a fresh instance.
datalessHide the Data section; open the drawer on Appearance.
hideActionsHide the Actions section.
componentThe 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:

  1. Create website/docs/widgets/my-widget.md with frontmatter (id, title, and the next sidebar_position). Follow the shape of the existing widget guides: At a glance, Configuration, Config panel, Cross-widget actions, and Notable behavior.
  2. Add the page id to the Widgets category in website/sidebars.ts. The sidebar is defined explicitly, so a new doc has no link until it is listed here.
  3. 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.ts with a Zod schema and inferred type
  • sample-data.ts fallback config
  • Component accepting WidgetRenderProps
  • Folder index.ts exports, re-exported from src/widgets/index.ts
  • Entry in WIDGET_DEFINITIONS
  • Optional config panel registered in config.tsx
  • Colocated test
  • Widget guide in website/docs/widgets/ and listed in sidebars.ts
  • Rows added to the catalog and widget-registry reference tables