Skip to content

Building Components ​

Components are presentational. They render UI and manage their own interaction state (hover, focus, open/closed), but they don't fetch data or contain business logic. That responsibility lives in state stores (data/state/) and the coordinator.

Component structure ​

Each component lives in a kebab-case folder under ui/components/:

timer/
├── index.tsx              # Component (named export)
├── component.ftl          # Localization resource (en-US baseline)
├── component.test.tsx     # Vitest + testing-library tests
├── component.story.tsx    # Storybook story
├── style.module.css       # Scoped styles
└── hooks/                 # Optional: colocated hooks for derived display values

A minimal component looks like this:

typescript
import style from "./style.module.css"

export function Header() {
  return (
    <div className={style.base} data-testid="header">
      {/* ... */}
    </div>
  )
}

Key patterns:

  • Named exports, export function Timer(), not export default
  • data-testid on every component, used by tests
  • CSS module import first, style import is always the first line
  • No business logic, if you're reaching for fetch or complex conditional logic, it probably belongs in data/state/

The _base/ directory holds shared decorators that multiple components use.

State access ​

Components consume state through Zustand hooks with selectors:

typescript
import { useTimer } from "@data/state/timer"

export function TimerDisplay() {
  const status = useTimer((s) => s.data.status)
  const start = useTimer((s) => s.actions.start)

  return <button onClick={start}>{status}</button>
}

What makes this work well:

  • Selectors keep re-renders tight. useTimer((s) => s.data.status) re-renders only when status changes. useTimer() re-renders on every store update. That's the difference.
  • Narrow selectors for data and actions. Pick what you need, leave the rest.
  • Package aliases for imports. @data/state/timer reads better and won't break when things move around, unlike ../../../data/state/timer.

For more on how stores work: State management.

CSS Modules ​

Every component gets a style.module.css. CSS Modules scope class names to the component at build time, so two components can both define .base without colliding. Each gets a unique hash in the output. You import the module and reference classes as properties:

typescript
import style from "./style.module.css"

export function Timer() {
  return <div className={style.base}>...</div>
}

The module boundary is the isolation mechanism. Styles in one component's module cannot leak into another's. No global namespace, no BEM prefixing. The tooling handles it.

Nesting ​

Native CSS nesting keeps related styles together under your class name instead of scattering them across the file. Use & for pseudo-states and pseudo-elements, and nest child classes directly:

css
.base {
  display: grid;
  container-type: inline-size;
  gap: var(--space-medium);

  &:hover {
    box-shadow: var(--box-shadow-card-hover);
  }

  .inner {
    padding: var(--space-medium);
  }
}

Nesting reflects the DOM structure. This keeps the relationship explicit and avoids flat lists of disconnected selectors.

Not everything needs to live under .base. Define additional top-level classes when a part of the component has its own layout concerns or doesn't share a container relationship with .base. .base gets you started with a reasonable root container, not a cage.

Key patterns ​

  • Container queries for responsive behavior, components respond to their own size, not the viewport
  • CSS variables for design tokens, var(--space-medium), not 16px
  • .base as the root class name, consistent entry point for every component's styles

Global styles and design tokens live in ui/styles/.

Localization ​

Each component that renders user-visible text gets a component.ftl file in the same directory. This is the en-US baseline, the only locale authored in this repo. Components reference message IDs via data-l10n-id attributes — the ESLint plugin (no-missing-message) validates every reference against the colocated FTL at edit time.

Static strings ​

The simplest case: a message with no variables.

ftl
renderer-info-title = Intentionally Boring Renderer
tsx
<h1 data-l10n-id="renderer-info-title" />

Variable interpolation ​

When a message includes dynamic content, document each variable with a # Variables: block above the message. Pass values at runtime via data-l10n-args as a JSON object.

ftl
# Variables:
#   $hash (String) - Content hash of the current renderer entry artifact
renderer-info-hash = Hash: { $hash }
tsx
<li
  data-l10n-id="renderer-info-hash"
  data-l10n-args={JSON.stringify({ hash })}
/>

Selectors ​

Use selectors when a message has variants driven by state. This keeps all display strings in FTL and out of component logic. Boolean props are passed as strings since Fluent variant keys are string-matched — String(prop) is the convention.

ftl
# Variables:
#   $updating (String) - "true" when a renderer update is pending, "false" otherwise
renderer-info-renderer-section =
    { $updating ->
        [true] Renderer — will update
       *[false] Renderer — cached
    }
tsx
<header
  data-l10n-id="renderer-info-renderer-section"
  data-l10n-args={JSON.stringify({ updating: String(renderUpdate) })}
/>

The * prefix marks the default variant — use it on the most common or least surprising state.

At build time, all component.ftl files are aggregated into a single baseline artifact for the snapshot. Non-baseline translations are produced externally. For the full pipeline: Localization.

Storybook ​

Each component has a component.story.tsx for visual development and documentation:

typescript
import { Meta, StoryObj } from "@storybook/react-vite"
import { Timer } from "."

const meta: Meta<typeof Timer> = {
  title: "Timer / Overview",
  component: Timer,
}

export default meta

export const Overview: StoryObj<typeof Timer> = {
  render: () => <Timer />,
}

Story titles use a flat two-level format: "Group / Name". No deeper nesting. The named export matches the sidebar leaf.

typescript
// correct — two levels, export matches leaf
const meta = { title: "Dev Panel / Sources" }
export const Sources: StoryObj<...> = { ... }

// avoid — three levels create nested folders in the sidebar
const meta = { title: "Dev / Panel / Sources" }

State variants via controls ​

Prefer one story with controls over multiple named exports for different states. Add custom boolean args via a ComponentPropsAndCustomArgs type and derive props in render:

typescript
type ComponentPropsAndCustomArgs = {
  withData: boolean
} & React.ComponentProps<typeof MyComponent>

const meta: Meta<ComponentPropsAndCustomArgs> = {
  title: "Section / MyComponent",
  component: MyComponent,
}
export default meta

export const MyComponent: StoryObj<ComponentPropsAndCustomArgs> = {
  render: (args) => {
    const withData = args.withData ? { data: mockData } : {}
    return <MyComponent {...args} {...withData} />
  },
  args: { withData: false },
  argTypes: {
    data: { table: { disable: true } },
  },
}

Run Storybook with:

bash
pnpm storybook

Stories serve double duty: they're development sandboxes (see your component in isolation) and living documentation (other developers see what it looks like and how it behaves).

Quick orientation

  • Business logic lives in data/, not ui/
  • Narrow selectors keep components fast
  • Every component has a test, a story, and a .ftl file alongside it
  • Code conventions — naming, imports, TypeScript patterns
  • Localization — FTL pipeline, identity participation, two-channel delivery
  • State management — commit vs set, selector patterns
  • Testing — test file structure and trust-in-layers model
  • Tooling — scaffolding new features with pnpm gen