Atomic design
Atomic design (Brad Frost) builds interfaces from small parts up, like chemistry: atoms combine into molecules, molecules into organisms, organisms fill templates, and pages are templates with real data.
| Level | What it is | Examples | Knows about |
|---|---|---|---|
| Atom | The smallest element, useless alone | button, input, icon, badge, label | tokens only |
| Molecule | A few atoms doing one thing | search field (label + input + button), form field | atoms |
| Organism | A section of a screen | user list, header, alarm table | molecules, atoms |
| Template | The layout of a screen, without data | two-column dashboard layout | organisms |
| Page | A template with real data, connected to the stores | users page | everything above, stores |
Two more layers carry the features:
| Layer | What it is | Knows about |
|---|---|---|
| Store | The state of a feature (NgRx signal store) | data-access |
| Data-access | The access to the back end (HttpClient, DTO mapping) | — |
| Models | Shared types (User) | — |
| Utils | Pure functions | — |
The rule: import downwards only
An atom never imports a molecule; only pages talk to the stores; only data-access talks to the back end. The lint does not force folder names nor who imports whom: the review checks it. The lint only asks that a relative import names a neighbour file or a folder (local/import-folders), so each layer exposes its public API through its index.ts, and import-x/no-cycle refuses import cycles.
In an Angular library
One folder per layer, one folder per element:
src/
atoms/button/button.component.{ts,html,scss}
molecules/search-field/…
organisms/user-list/…
templates/…
pages/users-page/…
stores/users/users.store.ts
data-access/users/users.api.ts
models/users/user.ts
utils/text/format-count.ts
styles/ tokens, layers, global stylesAn atom only uses tokens:
import { Component, input, output } from '@angular/core';
/** Visual role of a button. */
export type ButtonVariant = 'danger' | 'primary' | 'secondary';
/** Atom: a button of the design system, styled only with its tokens. */
@Component({
selector: 'ds-button',
templateUrl: './button.component.html',
styleUrl: './button.component.scss',
})
export class ButtonComponent {
/** Visual role of the button. */
public readonly variant = input.required<ButtonVariant>();
/** Emitted when the button is pressed (mouse, touch or keyboard). */
public readonly pressed = output();
}// Atom: every value is a token; the component only decides which token goes where.
@layer components {
.button {
padding-block: var(--space-inline);
padding-inline: var(--space-stack);
font-size: var(--font-size-body);
color: var(--color-action-text);
cursor: pointer;
background: var(--color-action);
border: none;
border-radius: var(--radius-control);
transition: opacity 150ms ease;
&:hover,
&:focus-visible {
background: var(--color-action-hover);
}
&.secondary {
color: var(--color-text);
background: var(--color-surface-muted);
}
&.danger {
background: var(--color-danger);
}
}
@media (prefers-reduced-motion: reduce) {
.button {
transition: none;
}
}
}An organism shows data and emits intents; it never injects a store (a page does), so it stays reusable and easy to test:
import { Component, input, output } from '@angular/core';
import type { User } from '../../models';
import { ButtonComponent } from '../../atoms';
import { SearchFieldComponent } from '../../molecules';
/** Organism: a searchable list of users; it shows data and emits intents, the page talks to the store. */
@Component({
selector: 'ds-user-list',
imports: [ButtonComponent, SearchFieldComponent],
templateUrl: './user-list.component.html',
styleUrl: './user-list.component.scss',
})
export class UserListComponent {
/** Users to show. */
public readonly users = input.required<readonly User[]>();
/** Emitted with the text of a search. */
public readonly searched = output<string>();
/** Emitted with the user to remove. */
public readonly removed = output<User>();
}The page connects the organisms to the store:
import { Component, computed, inject } from '@angular/core';
import { UserListComponent } from '../../organisms';
import { UsersStore } from '../../stores';
import { formatCount } from '../../utils';
import type { User } from '../../models';
/** Page: connects the organisms to the stores. */
@Component({
selector: 'ds-users-page',
imports: [UserListComponent],
templateUrl: './users-page.component.html',
})
export class UsersPageComponent {
protected readonly store = inject(UsersStore);
/** Title of the page, with the number of users. */
protected readonly title = computed(() => formatCount(this.store.users().length, 'user'));
/**
* Starts a search.
*
* @param text - The text to look for.
*/
protected async onSearched(text: string): Promise<void> {
await this.store.search(text);
}
/**
* Removes a user from the screen.
*
* @param user - The user to remove.
*/
protected onRemoved(user: User): void {
this.store.remove(user);
}
}The store, the only one to call the data-access layer:
import { inject } from '@angular/core';
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
import { UsersApi } from '../../data-access';
import type { User } from '../../models';
/** State of the users screen. */
interface UsersState {
/** Users matching the search. */
readonly users: readonly User[];
/** Whether a search is running. */
readonly isLoading: boolean;
}
const INITIAL_STATE: UsersState = { users: [], isLoading: false };
/** Users of the screen, loaded through the UsersApi; the state is changed only by its methods. */
export const UsersStore = signalStore(
{ providedIn: 'root' },
withState(INITIAL_STATE),
withMethods((store, api = inject(UsersApi)) => ({
async search(text: string): Promise<void> {
patchState(store, { isLoading: true });
const users = await api.search(text);
patchState(store, { users, isLoading: false });
},
remove(user: User): void {
patchState(store, { users: store.users().filter(({ id }) => id !== user.id) });
},
})),
);Which level?
- It has no meaning alone and no child component: atom.
- It combines atoms for one task, with no business data: molecule.
- It is a recognisable section of a screen, receives data through inputs: organism.
- It injects a store or a service: page (or a store if it holds state).
- In doubt, start lower: splitting an organism later is easy, un-mixing a smart atom is not.
Storybook
Stories follow the same levels (Atoms/Button, Molecules/SearchField): atoms and molecules get stories for every variant; organisms get stories with fake data, which is easy precisely because they do not inject stores.