> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tappify.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Add a widget

> Render a card in a named slot on a host page, following that page's filters.

A widget is a card that renders in a named slot on one of Tappify's own pages. It must be
readable at 320 pixels, because that is the narrowest slot.

```bash theme={null}
tappify extension add widget --name Funnel --page analytics --slot kpi-row --size 2x1 --expandable
```

Leave the flags off and the command asks in this order: name, host page, slot, size,
expandable.

## The manifest entry

```json theme={null}
{
  "contributes": {
    "widgets": [
      {
        "id": "funnel",
        "title": "Funnel",
        "entry": "src/widgets/funnel.tsx",
        "page": "analytics",
        "slot": "kpi-row",
        "size": "2x1",
        "expandable": true
      }
    ]
  }
}
```

The command writes this entry. You can hand-edit `tappify.extension.json` instead — the
`$schema` line gives your editor completion and validation, and
[`tappify extension doctor`](/extensions/test/doctor-checks) checks the result.

| Field        | Means                                                                                               |
| ------------ | --------------------------------------------------------------------------------------------------- |
| `page`       | Which host page the widget renders on. See [Pages and slots](/extensions/reference/pages-and-slots) |
| `slot`       | Which anchor on that page. A slot belongs to one page; the command only offers the ones that fit    |
| `size`       | `1x1`, `2x1` or `2x2` cells in the slot's grid                                                      |
| `expandable` | Whether owners can open the same component in the expand panel                                      |

## The component

The component is the file's default export and receives `TapWidgetProps`.

```tsx theme={null}
import {
  TapCard,
  TapEmptyState,
  TapSkeleton,
  TapStat,
  useTap,
  useTapFilters,
  useTapQuery,
  type TapWidgetProps,
} from "@tappify/extension-sdk";
import "@tappify/extension-sdk/styles.css";

export default function Funnel(_props: TapWidgetProps) {
  const tap = useTap();
  const filters = useTapFilters();
  const downloads = useTapQuery({
    kind: "series",
    metric: "downloads",
    range: filters.range,
  });

  if (downloads.isLoading) {
    return (
      <TapCard title="Funnel">
        <TapSkeleton lines={2} />
      </TapCard>
    );
  }

  const points = downloads.data?.points ?? [];

  return (
    <TapCard title="Funnel">
      {points.length === 0 ? (
        <TapEmptyState
          title="No downloads in this range"
          description="Widen the date picker to see numbers here."
        />
      ) : (
        <TapStat
          label="Downloads"
          value={tap.format.number(
            points.reduce((sum, point) => sum + point.value, 0),
          )}
        />
      )}
    </TapCard>
  );
}
```

A `series` query needs `analytics:read`; every query kind names its own scope. The command
adds `ui:render`, which every UI contribution requires, and the data scope is one you
declare. See [Scopes](/extensions/reference/scopes).

## What the host renders

The host mounts your component in a shadow root inside the slot and draws the frame around
it: your vendor tile — 16 pixels, your initials — in the card's header, and one of four
states.

| State         | What the owner sees                                                                                                                                             |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Loading       | A host skeleton until your remote resolves                                                                                                                      |
| Failed        | `Funnel Lab didn't load — remoteEntry.js timed out after 8s. The rest of Tappify is unaffected.` with Retry and Report to vendor                                |
| Missing scope | `Funnel Lab needs revenue:read on Photo Editor — the current release asks for a scope you haven't granted on this project.` with Review scopes and Keep current |
| Paused        | `Funnel Lab is paused. Resume it in Settings → Extensions to see it here.`                                                                                      |

Report to vendor sends the error, the stack, the page and your release checksum to your
[Runtime page](/extensions/publish/reporting-and-quality).

With `expandable: true`, owners can open the same component in a 640-pixel panel. It
re-mounts with `tap.ui.size` set to `panel` and `tap.context` carrying what the surface was
showing. The panel's footer is host-drawn: Ask Tappify about this, Pin as widget, and
Open in your product, which is the only exit.

<CardGroup cols={2}>
  <Card title="Follow the page's filters" icon="sliders" href="/extensions/build/follow-the-filters">
    Why `filters.range` is all the code the number needs to follow the date picker.
  </Card>

  <Card title="Style with host tokens" icon="palette" href="/extensions/build/style-with-host-tokens">
    The variables that make the card match the page around it.
  </Card>

  <Card title="The dev server" icon="play" href="/extensions/test/dev-server">
    See the widget while you edit it.
  </Card>
</CardGroup>
