Skip to content

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.

LevelWhat it isExamplesKnows about
AtomThe smallest element, useless alonebutton, input, icon, badge, labeltokens only
MoleculeA few atoms doing one thingsearch field (label + input + button), form fieldatoms
OrganismA section of a screenuser list, header, alarm tablemolecules, atoms
TemplateThe layout of a screen, without datatwo-column dashboard layoutorganisms
PageA template with real data, connected to the storesusers pageeverything above, stores

Two more layers carry the features:

LayerWhat it isKnows about
StoreThe state of a feature (NgRx signal store)data-access
Data-accessThe access to the back end (HttpClient, DTO mapping)—
ModelsShared types (User)—
UtilsPure 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 styles

An atom only uses tokens:

ts
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();
}
scss
// 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:

ts
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:

ts
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:

ts
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.

Generated from the JSDoc of the sources.