Skip to content
Back to skills

Building Product Empty States

ASecurity

Guide for adding a product setup empty state — the skippable first-run screen a product scene shows until real data arrives, built on the shared ProductEmptyState component. Use when adding an empty state or first-run/setup screen to a product scene, declaring `emptyState` on a `SceneExport`, writing a product setup-status detection logic, building an animated example-data preview widget, or deciding between the scene-level `ProductEmptyState` gate and an inline `ProductIntroduction` panel. C...

  • 40,048 stars
  • 0 votes
  • 0 copies
  • 4 views
  • Added September 1, 2026
developmenttypescriptgoshellreactnodeapifrontendbackend

Works with

  • terminal
  • cli
  • api
  • mcp

Security analysis

A100/100

Scanned October 1, 2026

npx -y skills add PostHog/posthog --skill building-product-empty-states --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Building Product Empty States?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Building Product Empty States
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/posthog-building-product-empty-states-posthog/badge)](https://www.skillsdirectory.com/skills/posthog-building-product-empty-states-posthog)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: building-product-empty-states
description: Guide for adding a product setup empty state — the skippable first-run screen a product scene shows until real data arrives, built on the shared ProductEmptyState component. Use when adding an empty state or first-run/setup screen to a product scene, declaring `emptyState` on a `SceneExport`, writing a product setup-status detection logic, building an animated example-data preview widget, or deciding between the scene-level `ProductEmptyState` gate and an inline `ProductIntroduction` panel. Covers the `productSetupStatusLogic` single-layer contract, real-data detection rules, local-only skip semantics, wizard commands, and design tokens.
---

# Building product empty states

Before a user has set a product up, its scene should show a setup empty state: the product pitch and install command on the left, an animated preview of the product filled with realistic example data on the right. The shared component lives in `frontend/src/lib/components/ProductEmptyState/`; MCP analytics (`products/mcp_analytics/frontend/emptyState/`) is the reference adoption.

This system is for product landing scenes. `ProductIntroduction` stays the inline panel for surfaces the gate cannot cover; see "Scene gate or inline panel?" at the end before choosing.

## How it works

1. A scene declares `emptyState` on its `SceneExport`. The app shell (`frontend/src/scenes/App.tsx`) wraps the scene in `ProductEmptyStateGate` — the scene component itself contains **no** empty-state branching.
2. The gate mounts the product's **detection logic**, which pushes a normalized status into `productSetupStatusLogic({ productKey })` — the app-wide single read point for "is product X set up?".
3. The gate renders the setup empty state for `needs-setup` / `waiting-for-data`, and the scene untouched for `has-data`. While `loading` it shows the standard scene-level spinner — the one shared loading treatment. **Never add a product-specific loading fallback.**
4. Statuses are **preloaded at app boot**: `productSetupPreloadLogic` (mounted in `App.tsx`) answers every manifest-declared probe (each product's `setupProbe`, aggregated into `productSetupProbes`) with one combined event-count query on idle, so by the time a user opens the scene the status is usually already known and the spinner never shows. The product's in-scene detection stays the fresher source of truth.
5. Users can **skip** by default. Skip is local-only (localStorage, keyed team + product, never backend-persisted); detection keeps polling, and a slim "Set up" banner stays visible until data lands. Creation-first products whose gated scene is just an empty list can set `skippable: false` on the config to drop the escape hatch — the primary action is the only next step anyway.

## Adoption steps

### 1. Write (or extend) the detection logic

The status must come from a **real signal**: a data-existence query (HogQL count / exists API), the product's opt-in flag, or an entity count for creation-first products. Never a dismissal flag — `has_completed_onboarding_for` is routing metadata, not evidence of data.

Default: one call to `createSetupDetectionLogic` (`lib/components/ProductEmptyState/setupDetectionLogic.ts`). Supply a `detect` function resolving the product's status; the factory owns the shared contract - detect on mount, optionally poll until data lands (stopping for good on `has-data`, pausing on hidden tabs), fail open on errors, and wait out bootstrap before the first check:

```ts
export const logsSetupLogic = createSetupDetectionLogic({
  productKey: ProductKey.LOGS,
  path: ['products', 'logs', 'frontend', 'emptyState', 'logsSetupLogic'],
  detect: async () => ((await api.logs.hasLogs()) ? 'has-data' : 'needs-setup'),
  // Only for products where data arrives from outside (SDK events); entity-count
  // products omit it - the gate remounts the logic on every scene entry.
  pollIntervalMs: 20000,
})
```

`detect` composes freely: retry inside it with `retryWithBackoff`, return `unknown` for "cannot tell" (e.g. no access), return `waiting-for-data` from an opt-in flag + count check. When the query is a fresh event count, use `refresh: 'force_blocking'` - a cached pre-ingestion `[0,0]` would otherwise stick. `recheckActionTypes` re-detects immediately when app state changes elsewhere (a team-setting opt-in). `cacheHasData` remembers a has-data answer in localStorage so returning users skip the spinner and the query - use it for products with no boot-time probe. Add `revalidateCachedHasData` when the data can go away (entities users delete, recordings that expire, a lookback window): the cached answer still opens the gate at once, detection runs once in the background, and a `needs-setup` or `waiting-for-data` answer clears the cache so the next visit shows the empty state. It never replaces the mounted scene, so an open modal or a half-created entity survives. Plain `cacheHasData` is for ingested data that stays. Recheck actions stop re-detecting once the status is `has-data`, since their job is to flip the gate open after the first entity is created in place.

Products whose detection drives more than the gate (extra selectors, staged dashboards) keep a bespoke logic instead - template: `products/mcp_analytics/frontend/mcpAnalyticsOnboardingLogic.ts`. It must push the status from a listener and handle failure the same way the factory does:

```ts
connect(() => ({
    actions: [productSetupStatusLogic({ productKey: ProductKey.MY_PRODUCT }), ['setDetectedStatus']],
    values: [productSetupStatusLogic({ productKey: ProductKey.MY_PRODUCT }), ['status as setupStatus']],
})),
listeners(({ actions, values }) => ({
    loadSignalsSuccess: () => actions.setDetectedStatus(values.hasData ? 'has-data' : 'needs-setup'),
    loadSignalsFailure: () => {
        // Never strand the gate on its spinner: if nothing has answered yet, fail
        // open to the real scene. Don't downgrade an existing answer on a poll blip.
        if (values.setupStatus === 'loading') {
            actions.setDetectedStatus('unknown')
        }
    },
})),
```

Statuses: `loading` (not yet known - the gate holds a spinner, never flashes the empty dashboard), `unknown` (detection failed with no earlier answer - the gate fails open to the scene), `needs-setup`, `waiting-for-data` (optional middle state: instrumented but no traffic yet), `has-data`. Binary products simply never emit `waiting-for-data`. **Your detection logic must handle its failure path** - a query that fails forever must not leave the status `loading`. Statuses are stamped with the team they were detected for, so project switches automatically reset to `loading`.

### 2. Create the config

`products/<product>/frontend/emptyState/<product>EmptyState.tsx` exports a `SceneProductEmptyState` (see `lib/components/ProductEmptyState/types.ts` for every field). Reference: `products/mcp_analytics/frontend/emptyState/mcpAnalyticsEmptyState.tsx`. Notes:

- **Accent**: use the product's `--color-product-<name>-light`/`-dark` token (`frontend/src/styles/base.scss`). If your product has none, add one there (get the color from design) rather than hardcoding a hex.
- **Wizard vs primary action**: SDK-installed products set `wizard: { slug }` (the slug must exist in `@posthog/wizard`); creation-first products (flags, surveys) set `primaryAction` instead. Self-hosted degrades automatically: no cloud → the terminal hides and the manual path is promoted. If the create action needs hooks (e.g. it opens PostHog AI via `useMaxTool`, like user research's "New topic"), provide a `PrimaryAction` component instead of `primaryAction` - it renders in the same slot and takes precedence.
- **Permissions and selectors on the primary action**: set `primaryAction.accessControl` to the same resource type and level the gated scene's own create button uses. Without it a viewer gets an enabled button and only learns they can't create when the form fails to save. Set `primaryAction.dataAttr` to the attr that scene button carries, so an end-to-end spec keeps one selector whether it lands on the scene or the empty state. A `PrimaryAction` component wraps its own `AccessControlAction`.
- **`featureFlag`**: set it when the scene is already flag-gated (so the scene's own gate keeps handling flag-off) or to roll the empty state out gradually.
- **Scene modules that serve more than one surface**: `scenes` narrows where the gate applies, and omitting it gates everything the module serves. A plain scene id covers that whole scene (web analytics gates only `Scene.WebAnalyticsWebVitals`). When one scene id serves several tabs, pass `{ scene, tabs }` and list every value of the `tab` route param you gate, including `undefined` for the URL with no tab segment - `products/workflows/frontend/emptyState/workflowsEmptyState.tsx` gates its workflow list while channels, opt-outs, suppression, and reputation stay reachable with no workflows yet. Gating the scene instead would take those tabs down with it.
- **Hedgehog**: a `pngHoggie(...)`-wrapped module — import only inside the product chunk (eager-graph guard: `frontend/bin/check-eager-graph.mjs`). Never hardcode image URLs (e.g. Cloudinary) — `@posthog/brand` assets only.
- **`text` is keyed by mode**: provide the `needs-setup` base; add a `waiting-for-data` entry only if your product has that middle state (missing fields fall back to the base). Sentence case, benefit-first, no AI tells (see "User-facing copy" in `CLAUDE.md`).
- **Key `wizard` by mode when the install command stops applying**: a product whose `waiting-for-data` means "events are flowing, a scheduled job hasn't run yet" has nothing left to install, so pass `wizard: { 'needs-setup': { slug } }` and the terminal, the manual link, and the hint leave the waiting screen (clusters). Leave it flat when re-running setup still makes sense there (MCP analytics: "Instrumenting another server?").
- **Key `primaryAction` by mode when it only fits one**: a one-click opt-in ("Enable session recording") is done once the status is `waiting-for-data`, and clicking it again re-sends the same team update. Pass `primaryAction: { 'needs-setup': { ... } }` and the button, along with the `hint` that introduces it, leaves the waiting screen. A single flat action still covers both modes - keep that when it reads correctly either way (support's "Open support settings").
- **Product header**: the gate keeps the product header (name, description, icon) above the empty state automatically, sourced from the scene's `SceneConfig` in your product manifest — make sure your manifest's scene entry has `name`, `description`, and `iconType` set.

### 3. Build the signature preview

`Preview` is the right-hand widget: the product's most recognizable UI, populated with **static, realistic fake data** (label it "example data"). References: `products/mcp_analytics/frontend/emptyState/MCPToolCallPreview.tsx`, `products/feature_flags/frontend/emptyState/FeatureFlagPreview.tsx`, `products/experiments/frontend/emptyState/ExperimentPreview.tsx`.

This is not a flat mock - the bar is "fun and involved", and the references set it:

- **Layer 2-3 small cards** that tell one story together (a list + the mini app it drives + a stat card with a chart), not a single panel of rows.
- **One real interaction with a visible payoff.** Drive it with a hidden checkbox/radio and `:checked ~` styles - clicking a flag row flips a mini app's UI and steps a conversion chart up at a "Released" marker; picking a variant highlights its interval and re-skins the app. No React state, no JS timers (`setInterval` is banned; CSS keyframes only).
- **Ambient motion so it feels alive at rest:** a trace segment cycling along a sparkline, a pulsing "Running" dot - subtle and continuous. Do **not** auto-toggle the interactive state on a loop - a UI that flips itself reads as broken, not alive; the user flips it, ambient motion does the rest.
- **No layout shift on state change:** stack on/off variants in one grid cell and crossfade opacity; never swap `display` or animate heights for state text.
- **Never paint text in the raw product accent.** Those tokens are sidebar icon tints; on the light surface most land under the 4.5:1 AA floor as text. Use `@include preview-accent-text(var(--<x>-preview-accent))` for every `color:` declaration and keep the raw accent for fills, borders, and strokes.
- **Crossfade with the `preview-swap-hidden` / `preview-swap-visible` mixins** (`lib/components/ProductEmptyState/_previewMixins.scss`), never bare `opacity`. Opacity alone leaves the hidden half in the accessibility tree and the tab order, so a screen reader announces both states at once and a keyboard user can land on an invisible button. The mixins add `visibility` on a delay, which keeps the fade and costs no layout shift.
- The hidden input stays keyboard-focusable, so the row it labels needs a `:focus-visible` outline (WCAG 2.4.7).
- The preview must **never scroll**; guard all animation with `prefers-reduced-motion` and an `inStorybook()`-driven static class so visual-regression snapshots stay stable.
- **It has to hold up from ~340px wide to the full scene.** The empty state keys its layout off its own width (a container query, not the viewport, because the sidebar and side panel eat into it): a wide scene puts the preview in a 40% column beside the copy, and a narrow one stacks it full width underneath. So every row wraps or truncates rather than clipping — `productEmptyStateStory(..., { containerWidth })` pins a narrow width for a snapshot.
- Honor the `mode` prop: `waiting-for-data` should read as "listening" (e.g. a pinned spinner row).

### 4. Declare it on the scene

```ts
export const scene: SceneExport = {
  component: MyScene,
  logic: mySceneLogic,
  productKey: ProductKey.MY_PRODUCT,
  emptyState: myProductEmptyState,
}
```

Then **delete** the scene's bespoke empty/loading branches (including any custom loading component) — the gate owns them now. This is strictly an **in-product** surface: do not modify the app-wide onboarding flow (`frontend/src/scenes/onboarding/`), and if the product currently redirects never-set-up users into that flow, remove the redirect — the empty state now covers first-visit setup right in the scene (reference: `mcpAnalyticsSceneLogic.ts`, which kept only its landing-tab logic).

### 4b. Register a boot-time probe

Declare a `setupProbe` in your product manifest (`products/<name>/manifest.tsx`) - the `productKey`, the event names that prove your product has data (and optionally the "instrumented but no traffic" events), and the `featureFlag` to gate on, mirroring your detection logic's semantics. `build-products.mjs` aggregates every manifest's `setupProbe` into `productSetupProbes` (regenerate with `pnpm build:products`), and `productSetupPreloadLogic` answers them all at boot with one batched event-definitions request (Postgres, cheap). This is what lets the app resolve your status before the user ever opens the scene; your in-scene detection stays the fresher source of truth. Set `staleAfterDays` when your detection logic uses a staleness window, so a project that stopped sending long ago reads as needing setup at boot too. Use string literals for event names - the probe is cloned into the eager generated `products.tsx`, so it must not import from your product chunk. The `ProductSetupProbe` shape and the definitions-to-status mapping live in `lib/components/ProductEmptyState/setupProbes.ts`. Products whose detection isn't event-based (exists APIs, entity counts) skip this; their status resolves on first scene visit.

### 5. Test the status mapping

Extend the detection logic's existing jest file with a parameterized push-through case: mount with mocked signals, assert `productSetupStatusLogic({ productKey }).values.status`. Reference: `products/mcp_analytics/frontend/mcpAnalyticsOnboardingLogic.test.ts`. Run `/writing-tests` first; don't re-test the shared gate or skip mechanics (covered in `productSetupStatusLogic.test.ts`).

### 6. Add storybook coverage

Add one story per mode to `lib/components/ProductEmptyState/ProductEmptyState.stories.tsx` with `productEmptyStateStory(myProductEmptyState, mode)` (from `storybookHelpers.ts`) - it renders your real config and gives you visual-regression snapshots for free. Default mocks answer queries and product intents so a bare call renders cleanly; pass `mocks` to drive your status indicator into a specific state (see the MCP stories).

## Scene gate or inline panel?

- The whole scene is empty because the product is not set up, or has no entities yet → this system. "Product not installed" is data-existence detection; "no entities yet" is entity-count detection with a `primaryAction` create CTA.
- One part of an otherwise working surface is empty → `ProductIntroduction`, rendered inline where the list would be. Typical cases: a tab or sub-list inside an adopted product (workflow channels, message templates), a dashboard widget tile or notebook node, a section of a settings page, an activity log, a flag-off gate, or a state that is not about setup (no ingestion warnings, an empty chat history).
- Never both on one scene for the same emptiness. When a scene adopts this system, delete the `ProductIntroduction` it rendered for the whole-scene case; keep or add one only for a per-tab or mixed case the gate does not see (alerts keeps a compact table message per kind, pulse keeps a per-focus message).
- The SetupPrompt family (error_tracking, logs, tracing, metrics, ai_observability) already has detection logics; when one of those scenes adopts, step 1 is just the `connect` + push. Their dashboard widget tiles keep `SetupPrompt`, because tiles are not scenes.
- `has_seen_product_intro_for` dismissals belong to neither: this system uses a local skip, and `ProductIntroduction` no longer reads the flag.

## QA checklist

Add `?empty_state=1` to the scene URL to pull up the setup screen on a project that already has data - it overrides detection and a local skip, and `?empty_state=waiting-for-data` gives you the other mode. Check the list below through that param rather than emptying a project.

- Dark mode, reduced motion (`prefers-reduced-motion`), self-hosted (no wizard terminal).
- Loading never flashes the real scene or the empty dashboard.
- Skip → scene renders immediately, persists across reload, "Set up" banner shows, onboarding redirect suppressed.
- Non-adopting scenes unaffected (the gate is a strict no-op without `emptyState`).
- `pnpm --filter=@posthog/frontend typescript:check`, storybook snapshots stable.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…