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