Writing UI components
How UI code is structured: which file a component lives in, when duplication becomes a shared
component, what a rename must sweep, and what every component renders before its data resolves.
Applies to everything under frontend/src/ and products/*/frontend/.
Each section leads with its gate question. If you can answer the gate honestly, the details
below it usually follow on their own.
Use this skill when
- Creating a new component, scene, view, hook, or frontend module
- Splitting or restructuring an existing component file, or moving one between folders
- Extracting a repeated shape into a shared/generic component, or promoting one toward
lib/
- Renaming a frontend symbol, file, or feature vocabulary
- Adding loading/empty/error handling to a view
- Reviewing a PR that does any of the above
Companion rules (do not duplicate)
This skill owns structure. These own their own territory — link to them, don't restate them:
Survey precedent before you build
Gate: which existing scene or component are you modeling this on — and does that model
itself follow these rules?
Brand consistency comes from imitation, not invention. Before building any new UI, read how
2–3 comparable scenes or components are implemented — component choice, density, layout, file
structure, logic wiring, data-attr naming — and name the precedent you're following. A new
surface that matches its compliant neighbors is on-brand by construction.
But filter precedent through the conventions. The codebase carries legacy that predates
these rules — clickable divs, hand-rolled tables, new-style-banned LemonMenus, whole-scene
ProductIntroduction panels where the scene gate belongs, re-export shims, slop styling. An existing violation is
history, not license: this skill and frontend/src/AGENTS.md outrank precedent. When the
nearest example violates the rules, follow the rules — and if the violation is cheap to fix,
convert it while you're there (references/anti-patterns.md).
Telling good precedent from bad:
- Prefer reference implementations named in skills — e.g. MCP analytics for
empty states, the migrated scenes listed in
scene-menu-bar — and recently-touched code (
git log) over
untouched corners.
- Prefer current primitives: quill menus/comboboxes over
LemonMenu/Radix menus,
the ProductEmptyState gate over a whole-scene ProductIntroduction, generated *Api types
over handwritten ones.
- When they conflict, the priority order is conventions > compliant precedent > invention.
Inventing a new pattern when a compliant precedent exists is itself a reuse violation.
Code organization
Gate: can a reader find this code by name, from the folder tree alone?
- One component per file. A file exports the component its name promises — nothing else. A
private sub-piece may stay only when it is unexported, has a single consumer in the same file,
is small (a few lines of markup, not a second real component), and is inseparable from the
parent (mode-variants of the same export). The moment it's exported, it moves to its own file.
Named exports only, never
default (handbook rule).
- One concern per file. When a file accumulates a second concern, split it: pure decision
functions come out of logics and components into a sibling module and get tested directly
(see writing-tests — extract, don't escalate); a dialog opened by
a button is not part of the button. Copy stays inline where reviewers see it in rendering
context — extract only long-form copy blocks or copy shared across components.
- Folders mirror the feature. A flow is a shell plus a
steps/ folder; reusable pieces in
components/; shared helpers in utils.ts (or the layer's existing helpers.ts). The tree
should read like a table of contents. Files are named after their main export
(DashboardMenu.tsx, dashboardLogic.ts); no index.ts, styles.css, or other generic names.
- Every symbol has exactly one home. No re-export shims creating a second import path, no
export * from, no new barrel files. When you extract or move a module, point every consumer
at the new home in the same PR and delete the old path — imports are mechanical; sweep them.
The only tolerated shim is inside a multi-PR migration that crosses team ownership boundaries,
marked // TODO(<issue>): delete after <migration> and removed in the final PR of the series.
The abstraction ladder
Gate: does the call site read as content, not markup?
That's the litmus for a good extraction: callers pass data and slots; the generic owns the
scaffolding. Climb the ladder one rung at a time:
- Reuse before you create — frontend/src/AGENTS.md Rule 1.
The design system is the brand: Lemon/quill components carry PostHog's tokens, density, and
interaction patterns, so building from them is what keeps a scene looking like PostHog.
Hand-rolling markup that an existing component already is counts as duplication and a
branding leak.
- Duplicate before you abstract. Extract a shared component when the same shape appears
three times, or twice within one feature with a third clearly coming. Below that
threshold, duplication is cheaper than the wrong abstraction.
- The generic owns scaffolding, not variants. If the second call site needs a boolean prop
to switch off half the generic's behavior (
hideIcon, noBorder, variant="other"), it is
not the same shape — inline the call sites again or split into two components.
- Keep new generics feature-local. They live next to the feature that uses them until a
second feature needs them; promotion is one
git mv away. The path is: feature folder →
frontend/src/lib/components/ (app-shared) → lemon-ui / quill (design system — needs design
review). Products never import from each other: a second consumer in another product forces
promotion to a shared layer, not a cross-product import.
- Don't abstract single-consumer, state-coordinated components. A wrapper with one caller
is indirection, not reuse — delete the layer.
Naming and vocabulary
Gate: does the name promise exactly what the thing does — and does it promise it everywhere?
One vocabulary per concept. When a feature is renamed, sweep the code symbols completely —
components, logics, files, folders, props, test names. git grep the old name before calling
the rename done; a codename surviving alongside the real name costs every future reader a
translation step.
Names don't over-promise. A hook named for "any wizard run" must not answer only for
self-driving; a use<X> that only works under one scene is named for that scene. If the scope
is narrower than the name, rename one of them.
Wire strings are frozen. The rename sweep stops at anything the outside world can see:
| Frozen string |
Who depends on it |
Event names (posthog.capture('…')) |
dashboards, insights, cohorts, alerts |
| Event/person property names and values |
the same, plus breakdowns and filters |
| Feature flag keys |
rollout state lives server-side; a renamed key is a new flag |
data-attr values |
autocapture-built dashboards, Playwright selectors, QA tooling |
localStorage / sessionStorage keys |
users' persisted state silently resets |
| URL paths and search params |
bookmarks, docs links, urlToAction handlers |
Pin them where they're defined with a comment stating the constraint, e.g.
// pinned: analytics event name — renaming breaks dashboards. If a wire string genuinely
must change, that's a migration (emit both, backfill, deprecate), not a rename.
Resolution states
Gate: what does this component render before its data has answered?
Loading, empty, and error are three different screens. "Empty" is a verdict about resolved
data; rendering it from unresolved data shows every user a flash of the wrong screen — or worse,
acts on it.
// don't — the empty state renders during the first fetch
{
items.length === 0 ? <EmptyState /> : <ItemsList items={items} />
}
// do — unresolved data is its own branch, checked first
{
itemsLoading ? <Spinner /> : items.length === 0 ? <EmptyState /> : <ItemsList items={items} />
}
- Branch in resolution order: loading → error → empty → content.
- Model "not yet known" explicitly in the logic —
null (a loader's natural default) or an
explicit 'unknown', never false/[] doubling as "nobody asked yet". See
state-decision.md for
the logic side.
- Scene-level first-run ("product not set up") belongs to the
ProductEmptyState gate, not bespoke branching.
- Any submit that fires a network request disables the trigger and shows a loading state while
in flight (root
CLAUDE.md rule) — reset in both success and error paths.
Visual discipline
- Design tokens, not hardcoded values. Colors come from tokens (
--color-*, product accents
in frontend/src/styles/base.scss) — a hardcoded hex is invisible to dark mode. Spacing comes
from the Tailwind scale; an arbitrary value (w-[347px]) needs a comment explaining the
constraint, or it should be a scale value.
- Custom components stay on brand. When the design system genuinely lacks what you need,
build the new component from the system's primitives and tokens (colors, spacing, radii,
typography) and match the density and tone of the surrounding scene — PostHog's product UI is
dense, flat, and utilitarian. Do not fill the gap with the generic AI-generated look ("AI
slop"): purple/blue gradients and neon glows, gradient text, glassmorphism and blurred orbs,
oversized radii and decorative shadows, icon-tile-above-heading card grids, pill badges
floating over headings, and motion that isn't tied to a state change. The test: every styling
choice is traceable to a token or an existing PostHog pattern — if it isn't, it's a tell. Full
catalog: references/anti-patterns.md.
- Tailwind utilities over inline styles; components over repeated class strings. A class
string copy-pasted across three files is a component in disguise. SCSS is the fallback for
what Tailwind can't express, namespaced BEM-style under the component's class (handbook rule).
- Interactive elements are real elements. A real
<button>/<a> — which is what
LemonButton and quill triggers render — never onClick on a <div> or a Card. Real elements
give keyboard focus, Enter/Space activation, and autocapture for free. If the design-system
component isn't semantically right, extend it; don't drop to a clickable div. Don't remove
focus outlines; give motion a motion-reduce: variant.
data-attr on meaningful interactions. New buttons and key interactive elements get a
kebab-case data-attr (match the surrounding scene's pattern) — it's how autocapture
dashboards and Playwright find them. Once shipped it's frozen (see the table above).
- New presentational components ship with a story (handbook rule) — visual-regression
coverage is free once the story exists. Flag-gated components use the
featureFlags story
parameter (setting-feature-flags-in-storybook).
Anti-patterns
references/anti-patterns.md is the convert-on-sight catalog —
before/after for the rules above. Read it when reviewing UI diffs.
Before you open the PR
1---2name: writing-ui-components3description: Structure and abstraction rules for PostHog UI code — any React component or frontend file under `frontend/src/` or `products/*/frontend/`. Use ALWAYS before creating, moving, splitting, or restructuring a component or frontend file, extracting a shared/generic component, promoting a component to `lib/`, renaming a frontend symbol or feature, or reviewing a diff that does any of these. Covers file and folder organization (one component per file, one home per symbol, no re-export shims or barrels), when duplication becomes a component and when a generic is premature, rename sweeps and the frozen-strings contract (event names, properties, flag keys, `data-attr` values are API), UI resolution states (loading, empty, and error are three different screens), and visual discipline (design tokens, on-brand custom components with no AI slop, real interactive elements, reduced motion, Storybook). Component choice (Lemon vs quill) lives in `frontend/src/AGENTS.md` Rule 1; state management in `/writing-kea-logics`.4---5
6# Writing UI components
7
8How UI code is structured: which file a component lives in, when duplication becomes a shared
9component, what a rename must sweep, and what every component renders before its data resolves.
10Applies to everything under `frontend/src/` and `products/*/frontend/`.
11
12Each section leads with its gate question. If you can answer the gate honestly, the details
13below it usually follow on their own.
14
15## Use this skill when
16
17- Creating a new component, scene, view, hook, or frontend module
18- Splitting or restructuring an existing component file, or moving one between folders
19- Extracting a repeated shape into a shared/generic component, or promoting one toward `lib/`
20- Renaming a frontend symbol, file, or feature vocabulary
21- Adding loading/empty/error handling to a view
22- Reviewing a PR that does any of the above
23
24## Companion rules (do not duplicate)
25
26This skill owns _structure_. These own their own territory — link to them, don't restate them:
27
28- [frontend/src/AGENTS.md](../../../frontend/src/AGENTS.md) — **Rule 1: reuse before you create**
29 (the Lemon/quill lookup table) and Rule 2 (generated API types). Both apply before anything here.
30- [writing-kea-logics](../writing-kea-logics/SKILL.md) — business logic lives in a logic, not in
31 React; state container choice. [using-kea-disposables](../using-kea-disposables/SKILL.md) for
32 anything that needs cleanup.
33- [writing-user-facing-copy](../writing-user-facing-copy/SKILL.md) — every visible string.
34- [writing-code-comments](../writing-code-comments/SKILL.md) — every comment, including the
35 pinned-string comments below.
36- [building-product-empty-states](../building-product-empty-states/SKILL.md) — scene-level
37 first-run empty states (the `ProductEmptyState` gate).
38- [scene-menu-bar](../scene-menu-bar/SKILL.md) — scene action surfaces.
39- [setting-feature-flags-in-storybook](../setting-feature-flags-in-storybook/SKILL.md) — stories
40 for flag-gated components.
41
42## Survey precedent before you build
43
44> **Gate: which existing scene or component are you modeling this on — and does that model
45> itself follow these rules?**
46
47Brand consistency comes from imitation, not invention. Before building any new UI, read how
482–3 comparable scenes or components are implemented — component choice, density, layout, file
49structure, logic wiring, `data-attr` naming — and name the precedent you're following. A new
50surface that matches its compliant neighbors is on-brand by construction.
51
52But **filter precedent through the conventions**. The codebase carries legacy that predates
53these rules — clickable divs, hand-rolled tables, new-style-banned `LemonMenu`s, whole-scene
54`ProductIntroduction` panels where the scene gate belongs, re-export shims, slop styling. An existing violation is
55history, not license: this skill and `frontend/src/AGENTS.md` outrank precedent. When the
56nearest example violates the rules, follow the rules — and if the violation is cheap to fix,
57convert it while you're there ([references/anti-patterns.md](references/anti-patterns.md)).
58
59Telling good precedent from bad:
60
61- **Prefer reference implementations named in skills** — e.g. MCP analytics for
62 [empty states](../building-product-empty-states/SKILL.md), the migrated scenes listed in
63 [scene-menu-bar](../scene-menu-bar/SKILL.md) — and recently-touched code (`git log`) over
64 untouched corners.
65- **Prefer current primitives**: quill menus/comboboxes over `LemonMenu`/Radix menus,
66 the `ProductEmptyState` gate over a whole-scene `ProductIntroduction`, generated `*Api` types
67 over handwritten ones.
68- When they conflict, the priority order is **conventions > compliant precedent > invention**.
69 Inventing a new pattern when a compliant precedent exists is itself a reuse violation.
70
71## Code organization
72
73> **Gate: can a reader find this code by name, from the folder tree alone?**
74
75- **One component per file.** A file exports the component its name promises — nothing else. A
76 private sub-piece may stay only when it is unexported, has a single consumer in the same file,
77 is small (a few lines of markup, not a second real component), and is inseparable from the
78 parent (mode-variants of the same export). The moment it's exported, it moves to its own file.
79 Named exports only, never `default` (handbook rule).
80- **One concern per file.** When a file accumulates a second concern, split it: pure decision
81 functions come out of logics and components into a sibling module and get tested directly
82 (see [writing-tests](../writing-tests/SKILL.md) — extract, don't escalate); a dialog opened by
83 a button is not part of the button. Copy stays inline where reviewers see it in rendering
84 context — extract only long-form copy blocks or copy shared across components.
85- **Folders mirror the feature.** A flow is a shell plus a `steps/` folder; reusable pieces in
86 `components/`; shared helpers in `utils.ts` (or the layer's existing `helpers.ts`). The tree
87 should read like a table of contents. Files are named after their main export
88 (`DashboardMenu.tsx`, `dashboardLogic.ts`); no `index.ts`, `styles.css`, or other generic names.
89- **Every symbol has exactly one home.** No re-export shims creating a second import path, no
90 `export * from`, no new barrel files. When you extract or move a module, point every consumer
91 at the new home in the same PR and delete the old path — imports are mechanical; sweep them.
92 The only tolerated shim is inside a multi-PR migration that crosses team ownership boundaries,
93 marked `// TODO(<issue>): delete after <migration>` and removed in the final PR of the series.
94
95## The abstraction ladder
96
97> **Gate: does the call site read as content, not markup?**
98
99That's the litmus for a good extraction: callers pass data and slots; the generic owns the
100scaffolding. Climb the ladder one rung at a time:
101
1021. **Reuse before you create** — [frontend/src/AGENTS.md Rule 1](../../../frontend/src/AGENTS.md).
103 The design system is the brand: Lemon/quill components carry PostHog's tokens, density, and
104 interaction patterns, so building from them is what keeps a scene looking like PostHog.
105 Hand-rolling markup that an existing component already is counts as duplication _and_ a
106 branding leak.
1072. **Duplicate before you abstract.** Extract a shared component when the same shape appears
108 **three times**, or **twice within one feature with a third clearly coming**. Below that
109 threshold, duplication is cheaper than the wrong abstraction.
1103. **The generic owns scaffolding, not variants.** If the second call site needs a boolean prop
111 to switch off half the generic's behavior (`hideIcon`, `noBorder`, `variant="other"`), it is
112 not the same shape — inline the call sites again or split into two components.
1134. **Keep new generics feature-local.** They live next to the feature that uses them until a
114 second _feature_ needs them; promotion is one `git mv` away. The path is: feature folder →
115 `frontend/src/lib/components/` (app-shared) → lemon-ui / quill (design system — needs design
116 review). Products never import from each other: a second consumer in another product forces
117 promotion to a shared layer, not a cross-product import.
1185. **Don't abstract single-consumer, state-coordinated components.** A wrapper with one caller
119 is indirection, not reuse — delete the layer.
120
121## Naming and vocabulary
122
123> **Gate: does the name promise exactly what the thing does — and does it promise it everywhere?**
124
125- **One vocabulary per concept.** When a feature is renamed, sweep the code symbols completely —
126 components, logics, files, folders, props, test names. `git grep` the old name before calling
127 the rename done; a codename surviving alongside the real name costs every future reader a
128 translation step.
129- **Names don't over-promise.** A hook named for "any wizard run" must not answer only for
130 self-driving; a `use<X>` that only works under one scene is named for that scene. If the scope
131 is narrower than the name, rename one of them.
132- **Wire strings are frozen.** The rename sweep stops at anything the outside world can see:
133
134 | Frozen string | Who depends on it |
135 | -------------------------------------- | -------------------------------------------------------------- |
136 | Event names (`posthog.capture('…')`) | dashboards, insights, cohorts, alerts |
137 | Event/person property names and values | the same, plus breakdowns and filters |
138 | Feature flag keys | rollout state lives server-side; a renamed key is a new flag |
139 | `data-attr` values | autocapture-built dashboards, Playwright selectors, QA tooling |
140 | `localStorage` / `sessionStorage` keys | users' persisted state silently resets |
141 | URL paths and search params | bookmarks, docs links, `urlToAction` handlers |
142
143 Pin them where they're defined with a comment stating the constraint, e.g.
144 `// pinned: analytics event name — renaming breaks dashboards`. If a wire string genuinely
145 must change, that's a migration (emit both, backfill, deprecate), not a rename.
146
147## Resolution states
148
149> **Gate: what does this component render before its data has answered?**
150
151**Loading, empty, and error are three different screens.** "Empty" is a verdict about resolved
152data; rendering it from unresolved data shows every user a flash of the wrong screen — or worse,
153acts on it.
154
155```tsx
156// don't — the empty state renders during the first fetch
157{
158 items.length === 0 ? <EmptyState /> : <ItemsList items={items} />
159}
160
161// do — unresolved data is its own branch, checked first
162{
163 itemsLoading ? <Spinner /> : items.length === 0 ? <EmptyState /> : <ItemsList items={items} />
164}
165```
166
167- Branch in resolution order: loading → error → empty → content.
168- Model "not yet known" explicitly in the logic — `null` (a loader's natural default) or an
169 explicit `'unknown'`, never `false`/`[]` doubling as "nobody asked yet". See
170 [state-decision.md](../writing-kea-logics/references/state-decision.md#unknown-is-a-state) for
171 the logic side.
172- Scene-level first-run ("product not set up") belongs to the
173 [ProductEmptyState gate](../building-product-empty-states/SKILL.md), not bespoke branching.
174- Any submit that fires a network request disables the trigger and shows a loading state while
175 in flight (root `CLAUDE.md` rule) — reset in both success and error paths.
176
177## Visual discipline
178
179- **Design tokens, not hardcoded values.** Colors come from tokens (`--color-*`, product accents
180 in `frontend/src/styles/base.scss`) — a hardcoded hex is invisible to dark mode. Spacing comes
181 from the Tailwind scale; an arbitrary value (`w-[347px]`) needs a comment explaining the
182 constraint, or it should be a scale value.
183- **Custom components stay on brand.** When the design system genuinely lacks what you need,
184 build the new component from the system's primitives and tokens (colors, spacing, radii,
185 typography) and match the density and tone of the surrounding scene — PostHog's product UI is
186 dense, flat, and utilitarian. Do not fill the gap with the generic AI-generated look ("AI
187 slop"): purple/blue gradients and neon glows, gradient text, glassmorphism and blurred orbs,
188 oversized radii and decorative shadows, icon-tile-above-heading card grids, pill badges
189 floating over headings, and motion that isn't tied to a state change. The test: every styling
190 choice is traceable to a token or an existing PostHog pattern — if it isn't, it's a tell. Full
191 catalog: [references/anti-patterns.md](references/anti-patterns.md#the-ai-slop-component).
192- **Tailwind utilities over inline styles; components over repeated class strings.** A class
193 string copy-pasted across three files is a component in disguise. SCSS is the fallback for
194 what Tailwind can't express, namespaced BEM-style under the component's class (handbook rule).
195- **Interactive elements are real elements.** A real `<button>`/`<a>` — which is what
196 `LemonButton` and quill triggers render — never `onClick` on a `<div>` or a Card. Real elements
197 give keyboard focus, Enter/Space activation, and autocapture for free. If the design-system
198 component isn't semantically right, extend it; don't drop to a clickable div. Don't remove
199 focus outlines; give motion a `motion-reduce:` variant.
200- **`data-attr` on meaningful interactions.** New buttons and key interactive elements get a
201 kebab-case `data-attr` (match the surrounding scene's pattern) — it's how autocapture
202 dashboards and Playwright find them. Once shipped it's frozen (see the table above).
203- **New presentational components ship with a story** (handbook rule) — visual-regression
204 coverage is free once the story exists. Flag-gated components use the `featureFlags` story
205 parameter ([setting-feature-flags-in-storybook](../setting-feature-flags-in-storybook/SKILL.md)).
206
207## Anti-patterns
208
209[references/anti-patterns.md](references/anti-patterns.md) is the convert-on-sight catalog —
210before/after for the rules above. Read it when reviewing UI diffs.
211
212## Before you open the PR
213
214- [ ] New UI is modeled on named, compliant precedent — violating neighbors were not copied
215- [ ] Every new file exports what its name promises — one component each, named exports
216- [ ] No new re-export shims or barrels; moved symbols' consumers all updated, old paths deleted
217- [ ] New generics: call sites read as content; no variant booleans; feature-local unless a
218 second feature exists today
219- [ ] Loading, empty, and error all reachable and distinct; empty never renders from unresolved
220 data
221- [ ] No hardcoded colors; arbitrary Tailwind values justified or replaced
222- [ ] Renames swept (`git grep` the old name returns nothing); wire strings untouched and pinned
223- [ ] New presentational component has a story
224- [ ] Typecheck/lint cadence per [frontend/src/AGENTS.md](../../../frontend/src/AGENTS.md) —
225 full check once at the end, `pnpm --filter=@posthog/frontend fix` before finishing