# Calm Component Structure

A simple component folder convention for predictable UI, local documentation, tests, Storybook examples, and AI-friendly maintenance.

Published: 2026-05-28
Tags: vyriy, react, component, storybook
Source: https://vyriy.dev/blog/calm-component-structure/

---

# Calm Component Structure

A UI component should be easy to understand before it becomes easy to reuse.

In many projects, components start small and clear. Then they slowly collect hidden responsibilities:

- styles live somewhere else
- types are shared from a distant file
- tests are missing or hard to find
- Storybook examples are incomplete
- usage is only understandable after reading implementation code
- documentation is separated from the actual component
- AI tools have to guess intent from source code alone

This creates noise.

A component becomes harder to change not because the JSX is complex, but because the component boundary is unclear.

Vyriy prefers a calmer structure.

A component should be a small local contract.

It should contain everything needed to understand, use, test, document, and preview that component without jumping across the codebase.

## The component as a local contract

A calm component is not just a `.tsx` file.

It is a small documented unit with one clear responsibility.

```txt
component-name/
  README.md
  types.ts
  index.ts
  component-name.tsx
  component-name.test.tsx
  index.test.ts
  component-name.module.scss
  component-name.stories.tsx
  doc.mdx
```

Each file has one job.

```txt
README.md                    # human and AI-readable usage guide
types.ts                     # public component contract
index.ts                     # public re-export surface only
component-name.tsx           # runtime implementation
component-name.test.tsx      # behavior tests
index.test.ts                # public API export test
component-name.module.scss   # local styles
component-name.stories.tsx   # Storybook examples and playground
doc.mdx                      # Storybook documentation page
```

This structure makes the component folder self-contained.

A developer should be able to open the folder and answer:

- what does this component do?
- how do I use it?
- what props does it accept?
- how does it look?
- how is it tested?
- what is part of the public API?
- what should not be changed accidentally?

That is the main idea.

## Why this is calm

Calm architecture is not about making code look impressive.

It is about reducing surprise.

For UI components, surprise usually comes from unclear ownership.

A calm component has explicit boundaries:

- `types.ts` owns the public type contract
- `component-name.tsx` owns runtime behavior
- `component-name.module.scss` owns local styling
- `README.md` owns usage documentation
- `component-name.stories.tsx` owns visual examples
- `doc.mdx` owns Storybook documentation composition
- tests own behavior guarantees
- `index.ts` owns only the public re-export surface

Nothing is magical.

Nothing is hidden.

Nothing needs to be discovered by searching the entire repository.

## Example: `card`

The `card` component is only an example. Real components should use their real names consistently.

```txt
card/
  README.md
  types.ts
  index.ts
  card.tsx
  card.test.tsx
  index.test.ts
  card.module.scss
  card.stories.tsx
  doc.mdx
```

## `types.ts`

`types.ts` describes the public contract of the component.

```ts
import type { ComponentProps, FC, ReactNode } from 'react';

export type CardProps = {
  title?: ReactNode;
  subtitle?: ReactNode;
  children?: ReactNode;
  variant?: 'default' | 'muted' | 'highlighted';
} & ComponentProps<'div'>;

export type CardType = FC<CardProps>;
```

This keeps the implementation file focused.

The component contract can be read without reading JSX.

For real components, prefer explicit type names:

```ts
export type ProfileHeaderProps = {};
export type ProfileHeaderType = FC<ProfileHeaderProps>;
```

Avoid generic names like `ComponentType` inside component folders unless there is a strong reason.

## `index.ts`

`index.ts` is the public surface of the component folder.

It should only re-export.

```ts
export * from './card.js';
export type * from './types.js';
```

No implementation.

No side effects.

No hidden logic.

This keeps imports predictable and makes public API tests simple.

## `card.tsx`

The runtime implementation belongs in the component file.

```tsx
import { cn } from '@vyriy/cn';

import type { CardType } from './types.js';

import styles from './card.module.scss';

export const Card: CardType = ({ title, subtitle, children, variant = 'default', className, ...props }) => {
  return (
    <div className={cn(styles.card, styles[`card--${variant}`], className)} {...props}>
      {(title || subtitle) && (
        <header className={styles.header}>
          {title && <h2 className={styles.title}>{title}</h2>}
          {subtitle && <p className={styles.subtitle}>{subtitle}</p>}
        </header>
      )}

      {children && <div className={styles.body}>{children}</div>}
    </div>
  );
};
```

The component remains small and predictable.

It does not know about routing, fetching, application state, global layout, or deployment environment.

It owns one responsibility: rendering a reusable card surface.

## `card.module.scss`

Styles stay local to the component.

```scss
.card {
  border: 1px solid var(--profile-card-border-color, #e5e7eb);
  border-radius: var(--profile-card-radius, 1rem);
  background: var(--profile-card-background, #ffffff);
  color: var(--profile-card-color, #111827);
  box-shadow: var(--profile-card-shadow, 0 10px 30px rgb(15 23 42 / 8%));
  padding: var(--profile-card-space, 1rem);
}

.card--default {
  background: var(--profile-card-background, #ffffff);
}

.card--muted {
  background: var(--profile-card-muted-background, #f9fafb);
}

.card--highlighted {
  border-color: var(--profile-card-highlight-border-color, #93c5fd);
  background: var(--profile-card-highlight-background, #eff6ff);
}

.header {
  display: grid;
  gap: 0.25rem;
  margin-block-end: 0.75rem;
}

.title {
  margin: 0;
  font-size: 1rem;
  font-weight: 650;
  line-height: 1.3;
}

.subtitle {
  margin: 0;
  color: var(--profile-card-muted-color, #6b7280);
  font-size: 0.875rem;
  line-height: 1.4;
}

.body {
  display: grid;
  gap: 0.75rem;
}
```

The component may use CSS variables for customization, but it should not require a global reset to work correctly.

A calm component protects itself with reasonable local styles.

## `card.test.tsx`

The component test verifies behavior.

```tsx
import { render, screen } from '@testing-library/react';

import { Card } from './card.js';

describe('Card', () => {
  it('renders title', () => {
    render(<Card title="Profile" />);

    expect(screen.getByRole('heading', { name: 'Profile' })).toBeInTheDocument();
  });

  it('renders subtitle', () => {
    render(<Card title="Profile" subtitle="Frontend engineer" />);

    expect(screen.getByText('Frontend engineer')).toBeInTheDocument();
  });

  it('renders children', () => {
    render(
      <Card title="Profile">
        <span>Content</span>
      </Card>,
    );

    expect(screen.getByText('Content')).toBeInTheDocument();
  });

  it('passes div props to root element', () => {
    render(<Card title="Profile" data-testid="card-root" />);

    expect(screen.getByTestId('card-root')).toBeInTheDocument();
  });
});
```

The test is not a snapshot of implementation details.

It checks what users of the component actually rely on.

## `index.test.ts`

The public export test protects the component boundary.

```ts
import * as publicApi from './index.js';

describe('card public API', () => {
  it('exports Card', () => {
    expect(publicApi.Card).toBeDefined();
  });
});
```

This looks small, but it catches accidental API breakage.

For packages with many components, these tests make refactoring safer.

## `README.md`

The README is part of the component contract.

It is not optional documentation that becomes outdated later.

It should explain how to use the component without opening the implementation.

````md
# Card

`Card` is a small layout primitive used by profile-card components.

It provides:

- a bordered surface
- optional title
- optional subtitle
- content slot
- visual variants

## Usage

```tsx
import { Card } from './card.js';

export const Example = () => (
  <Card title="Profile" subtitle="Calm architecture">
    Content
  </Card>
);
```

## Props

```ts
export type CardProps = {
  title?: ReactNode;
  subtitle?: ReactNode;
  children?: ReactNode;
  variant?: 'default' | 'muted' | 'highlighted';
} & ComponentProps<'div'>;
```

## Notes

- SSR/SSG-safe
- no browser-only APIs
- root element accepts regular `div` props
- styles are local to the component
````

This is useful for humans.

It is also useful for AI tools.

Codex, ChatGPT, Continue, or any other assistant can read the README first and understand intent before editing code.

That reduces accidental rewrites.

## `card.stories.tsx`

Storybook stories provide visual examples and a playground.

```tsx
import type { Meta, StoryObj } from '@storybook/react';

import { Card } from './card.js';

const meta = {
  title: 'Components/Card',
  component: Card,
  args: {
    title: 'Profile',
    subtitle: 'Calm reusable UI primitive',
    children: 'Card content',
  },
} satisfies Meta<typeof Card>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {};

export const Muted: Story = {
  args: {
    variant: 'muted',
  },
};

export const Highlighted: Story = {
  args: {
    variant: 'highlighted',
  },
};
```

A story is not just a demo.

It is a living usage example.

It shows how the component is expected to be used.

## `doc.mdx`

`doc.mdx` connects the local README with Storybook examples.

```mdx
import { Meta, Markdown, Canvas } from '@storybook/addon-docs/blocks';
import ReadMe from './README.md?raw';

import * as CardStories from './card.stories';

<Meta of={CardStories} />

<Markdown>{ReadMe}</Markdown>

## Examples

<Canvas of={CardStories.Default} />
<Canvas of={CardStories.Muted} />
<Canvas of={CardStories.Highlighted} />
```

This keeps documentation close to the component.

The README becomes reusable:

- visible in the repository
- visible in Storybook
- readable by AI tools
- reviewable in pull requests

## Why README and Storybook both matter

README and Storybook solve different problems.

README explains the contract.

Storybook shows the contract.

Together, they reduce the need to inspect source code.

A developer can read the README to understand the purpose and props.

A designer or reviewer can open Storybook to see the component in action.

An AI assistant can read the README before touching implementation.

That is a calm workflow.

## SSR and SSG friendliness

A calm component should be safe to render on the server.

Avoid using browser-only APIs during render:

- `window`
- `document`
- layout measurements
- random IDs generated during render
- current date/time generated during render
- environment-specific behavior

The same props should produce the same markup.

This makes the component compatible with:

- SSR
- SSG
- prerendering
- documentation builds
- tests
- micro frontend rendering
- serverless environments

Predictable rendering is easier to deploy and easier to debug.

## Accessibility by default

Calm structure also helps accessibility.

The component folder should make accessibility decisions visible.

Examples:

- links should be real `<a>` elements
- navigation groups should use `<nav>`
- metadata should use `<dl>`
- tag collections should use `<ul>` and `<li>`
- images should have useful `alt`
- focus styles should be visible
- icon-only links should have readable labels

These rules can be documented in `README.md`, demonstrated in Storybook, and protected with tests.

Accessibility should not be a hidden implementation detail.

## Public API at package level

At package level, components can be re-exported from a root `index.ts`.

```txt
src/
  components/
    avatar/
    badge/
    button-link/
    card/
    icon-link/
    profile-card/
    profile-details/
    profile-header/
    profile-links/
    profile-meta/
    profile-tags/
  index.ts
  index.test.ts
```

Example:

```ts
export * from './components/avatar/index.js';
export * from './components/badge/index.js';
export * from './components/button-link/index.js';
export * from './components/card/index.js';
export * from './components/icon-link/index.js';
export * from './components/profile-card/index.js';
export * from './components/profile-details/index.js';
export * from './components/profile-header/index.js';
export * from './components/profile-links/index.js';
export * from './components/profile-meta/index.js';
export * from './components/profile-tags/index.js';
```

And the root public API test:

```ts
import * as publicApi from './index.js';

describe('public API', () => {
  it('exports public components', () => {
    expect(publicApi.Avatar).toBeDefined();
    expect(publicApi.Badge).toBeDefined();
    expect(publicApi.ButtonLink).toBeDefined();
    expect(publicApi.Card).toBeDefined();
    expect(publicApi.IconLink).toBeDefined();
    expect(publicApi.ProfileCard).toBeDefined();
    expect(publicApi.ProfileDetails).toBeDefined();
    expect(publicApi.ProfileHeader).toBeDefined();
    expect(publicApi.ProfileLinks).toBeDefined();
    expect(publicApi.ProfileMeta).toBeDefined();
    expect(publicApi.ProfileTags).toBeDefined();
  });
});
```

The package API stays explicit.

Consumers import from stable boundaries, not from random internal files.

## Why this helps AI-assisted development

AI tools work better when the codebase explains itself.

A calm component folder gives the assistant multiple layers of context:

- README explains intent
- types explain the API
- stories show usage examples
- tests show expected behavior
- styles show visual boundaries
- implementation shows runtime behavior

This is much better than asking an AI tool to infer everything from a single `.tsx` file.

The structure helps the assistant make smaller, safer changes.

It also helps human reviewers understand those changes.

## The trade-off

This structure creates more files.

That is intentional.

The goal is not to minimize file count.

The goal is to minimize confusion.

A single large component file may look simpler at first, but it often mixes too many responsibilities:

- public types
- implementation
- styling assumptions
- examples
- documentation
- test expectations
- API boundaries

A calm component splits those responsibilities into small files with clear jobs.

More files.

Less ambiguity.

## When to use this structure

This structure is useful for reusable components, design system primitives, shared UI packages, and micro frontend building blocks.

It is especially useful when components are consumed by multiple projects or generated by a CLI preset.

For tiny one-off local components, this may be too much.

But for a reusable package, this structure gives the component a stable boundary from the beginning.

## Final shape

A calm component is:

- small
- typed
- documented
- tested
- previewable
- locally styled
- SSR/SSG-safe
- easy to understand without reading implementation first

That is the core idea.

The component is not just code.

It is a contract.
