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.
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.
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.tsowns the public type contractcomponent-name.tsxowns runtime behaviorcomponent-name.module.scssowns local stylingREADME.mdowns usage documentationcomponent-name.stories.tsxowns visual examplesdoc.mdxowns Storybook documentation composition- tests own behavior guarantees
index.tsowns 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.
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.
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:
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.
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.
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.
.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.
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.
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.
# 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.
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.
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:
windowdocument- 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.
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:
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:
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.