Sub-skill of storybook. Owns the *.stories.tsx file format itself: where it lives (next to the component, never in a parallel tree), what it exports (CSF 3 Meta default + named StoryObjs), how args / argTypes shape the controls panel, and how the story ID becomes the URL Playwright targets. Pillar 1 — Storybook-first is enforced here: a component file does not exist without a sibling story file, and the story is the assertion target a Playwright spec drives (see playwright-story-tests).
When to invoke
- Adding a new component under
apps/web/app/components/<name>/and authoring its<name>.stories.tsx. - Renaming or moving a component — the story moves with it (co-location).
- Choosing the right
Meta/StoryObjshape, default args, controls, parameters. - Naming a story so the resulting story ID is stable and Playwright-targetable.
- Diagnosing "my new story doesn't appear in Storybook" (likely a glob mismatch — see
storybook-config).
Owns
Authoring *.stories.tsx co-located with the component, CSF 3 Meta/StoryObj, args/argTypes, and the rule "no component without a story".
Defers to
storybook(parent) — version pin and routing.storybook-config— for the discovery glob, framework setup, and global decorators that wrap every story.storybook-play-functions— for theplayfunction inside a story (the in-iframe interaction surface).playwright-story-tests— for the external Playwright spec that mounts the story by URL and asserts on it. Story-ID contract: the story file's path + named export collapses into the slug Playwright targets.react(router) — for the component patterns (Compiler purity, React 19 primitives) used inside the component the story is for.zod— for thez.objectschema that types the component props (per Pillar 2). The story'sargsshape comes fromz.infer<typeof PropsSchema>.tanstack-router-routing+storybook-config— when a story needs route context (rare; pure components should not).biome—*.stories.tsxis linted by Biome like any other TSX. Story files are not exempt frombun run check.
Dean-stack rules
- Pillar 1 — Storybook-first is enforced here: every component file has a sibling
*.stories.tsx, and the story is the assertion target a Playwright spec drives (seeplaywright-story-tests). Story files are part of the test surface, not decoration. - Pillar 2 (Zod-first types) means: the component's props are a
z.objectschema (seezod); the story'sargsare typed asz.infer<typeof PropsSchema>. Hand-written prop types in a story file are forbidden. - Pillar 4 (CLI-gate-first) means: a broken story (missing default export, malformed
Meta, type mismatch) failstsgo --noEmitand the Playwright story-tests stage ofbun run check. - CSF 3 (
Meta<typeof Component>+StoryObj<typeof meta>) is the canonical format. Don't mix CSF Factories in the same project — pick one. - Co-location is non-negotiable:
Button.tsxships withButton.stories.tsxin the same directory. Never put stories in__stories__/, never underapp/routes/**. - Type imports come from
@storybook/react-vite(the framework path), never@storybook/react(renderer path).
Patterns
Co-located component layout
apps/web/app/components/button/
├── index.tsx # the component
├── schema.ts # z.object props schema (see `zod`)
├── styles.css # optional co-located CSS (see `tailwind`)
└── index.stories.tsx # the story file
The index.tsx + index.stories.tsx pair is the unit of construction. Tests (Playwright story tests, plus optional bun test for any pure helper) live beside them.
Minimal CSF 3 story file
// apps/web/app/components/button/index.stories.tsx
// pinned: storybook ^10.x, @storybook/react-vite ^10.x
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Button } from "./index";
const meta = {
// The title controls the sidebar grouping AND the story ID slug Playwright targets.
title: "Components/Button",
component: Button,
// `autodocs` for pure UI components; omit for route-level / page-level.
tags: ["autodocs"],
// Centered layout matches the global parameter; reaffirm here for clarity.
parameters: { layout: "centered" },
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
// Each named export is a story. The slug is `kebab-cased(title) + "--" + kebab-cased(name)`.
// For the example above + `Primary`, the iframe URL is:
// http://localhost:6006/iframe.html?id=components-button--primary
export const Primary: Story = {
args: { label: "Click me", variant: "primary" },
};
export const Secondary: Story = {
args: { label: "Click me", variant: "secondary" },
};
export const Disabled: Story = {
args: { label: "Click me", variant: "primary", disabled: true },
};
The default export is Meta<typeof Component>. Each named export is a StoryObj<typeof meta>. The story ID Playwright consumes is deterministic: kebab-case the title, join with --, kebab-case the export name. Pin the title and the export name — changing them changes URLs and breaks Playwright story tests.
Props schema → args typing
// schema.ts
import * as z from "zod"; // see `zod`
export const ButtonProps = z.object({
label: z.string().min(1),
variant: z.enum(["primary", "secondary"]).default("primary"),
disabled: z.boolean().default(false),
onClick: z.function().args().returns(z.void()).optional(),
});
// index.tsx
import type { z } from "zod";
import type { ButtonProps as ButtonPropsSchema } from "./schema";
import { defineComponent } from "@/lib/define-component"; // see `zod`
type ButtonProps = z.infer<typeof ButtonPropsSchema>;
export const Button = defineComponent(ButtonPropsSchema, (p: ButtonProps) => /* ... */);
// index.stories.tsx (excerpt)
// `args` is typed as `Partial<ButtonProps>` automatically via `Meta<typeof Button>`.
// No hand-written prop type anywhere in the story file.
The component's prop type comes from z.infer<typeof ButtonProps> (see zod and the Pillar 2 rule). The story's args are inferred from Meta<typeof Button> — never declare args: ButtonProps by hand.
argTypes — control configuration
const meta = {
title: "Components/Button",
component: Button,
argTypes: {
variant: {
control: { type: "radio" },
options: ["primary", "secondary"],
},
onClick: { action: "clicked" }, // shows in the Actions panel without needing a play function
disabled: { control: "boolean" },
},
} satisfies Meta<typeof Button>;
argTypes shapes the controls panel. Use control: { type: "radio" } for finite enums (mirrors the Zod enum); use action: "name" to log calls in the Actions panel without writing a play function. argTypes is also the place to add descriptions that flow into autodocs.
Story IDs as the Playwright contract
Story file: apps/web/app/components/button/index.stories.tsx
meta.title: "Components/Button"
Named export: Primary
→ Story ID: components-button--primary
→ Iframe URL: http://localhost:6006/iframe.html?id=components-button--primary
Playwright story tests target this URL exactly (see playwright-story-tests). When a story is renamed, its URL changes and any Playwright spec referencing it must update. Treat meta.title and named-export names as part of the test contract.
Composition via args + render
export const InMenu: Story = {
render: (args) => (
<nav>
<Button {...args} label="One" />
<Button {...args} label="Two" />
</nav>
),
args: { variant: "secondary" },
};
Use render only when the story needs more than the default single-component render. Keep render bodies small — large compositions belong in their own component (with their own story).
Decorators per story
export const InsideCard: Story = {
decorators: [
(Story) => <div className="rounded-card bg-white p-4 shadow-md"><Story /></div>,
],
args: { label: "Inside a card" },
};
Per-story decorators stack on top of the global decorators from .storybook/preview.tsx (see storybook-config). Use them sparingly — most framing should live in the component itself.
Pillar 1 enforcement check
Before merging a new component, run through this checklist:
index.tsxexists.index.stories.tsxexists in the same directory.- The default export is a
Meta<typeof Component>. - At least one named
StoryObj<typeof meta>is exported. - The story renders in
bun run storybookwithout console errors. - A Playwright story test (see
playwright-story-tests) targets the resulting story URL.
If 1 is true but any of 2–6 is false, the change is incomplete — Pillar 1 is not satisfied.
Anti-patterns
- Don't add a component without a sibling
*.stories.tsx— Pillar 1 violation. The story is the construction surface; build it first. - Don't write the story for a component that doesn't have a Playwright story test — every story is exercised. See
playwright-story-testsand ASK FIRST per the Pillar 4 rule before writing the test (seeplaywright-conventions). - Don't put stories in
__stories__/,apps/web/stories/, or any parallel tree — co-locate. The discovery glob (seestorybook-config) walksapp/components/**. - Don't import
Meta/StoryObjfrom@storybook/react— use@storybook/react-vite. The renderer-only path is wrong here. - Don't hand-write prop types in the story file —
Meta<typeof Component>infers them. Hand-written types drift from the schema (Pillar 2 violation). - Don't author
*.stories.mdx— keep the story-glob to.ts/.tsx. MDX is fine for one-off documentation pages but not the default. - Don't use CSF 2 (
storiesOf(...)) or pre-CSF formats — Storybook 10 is CSF 3. ThestoriesOfAPI is removed. - Don't change
meta.titleor a named export name without checking Playwright story tests — both are part of the URL contract. - Don't make a story for a route-level component as the primary coverage — build the inner pieces first. A route-level story exists only when the route itself is the unit of design.
Triggers on
story, stories.tsx, CSF 3, Meta, StoryObj, args, argTypes, story file, co-located story