DEV Community

Alexander Opalic
Alexander Opalic

Posted on • Originally published at alexop.dev on

From Atoms to Pages: Applying Atomic Design in Vue and Nuxt Projects

Introduction

Clear writing requires clear thinking. The same is valid for coding. Throwing all components into one folder may work when starting a personal project. But as projects grow, especially with larger teams, this approach leads to problems:

  • Duplicated code
  • Oversized, multipurpose components
  • Difficult-to-test code

Atomic Design offers a solution. Letโ€™s examine how to apply it to a Nuxt project.

What is Atomic Design

Brad Frost developed Atomic Design as a methodology for creating design systems. It is structured into five levels inspired by chemistry:

  1. Atoms: Basic building blocks (e.g. form labels, inputs, buttons)
  2. Molecules: Simple groups of UI elements (e.g. search forms)
  3. Organisms: Complex components made of molecules/atoms (e.g. headers)
  4. Templates: Page-level layouts
  5. Pages: Specific instances of templates with content

Tip: ๐Ÿ’ For a better exploration of Atomic Design principles, I recommend reading Brad Frost's blog post: Atomic Web Design

For Nuxt, we can adapt these definitions:

  • Atoms: Pure, single-purpose components
  • Molecules: Combinations of atoms with minimal logic
  • Organisms: Larger, self-contained, reusable components
  • Templates: Nuxt layouts defining page structure
  • Pages: Components handling data and API calls

Organisms vs Molecules: What's the Difference?

Molecules and organisms can be confusing. Here's a simple way to think about them:

  • Molecules are small and simple. They're like LEGO bricks that snap together.
    Examples:

    • A search bar (input + button)
    • A login form (username input + password input + submit button)
    • A star rating (5 star icons + rating number)
  • Organisms are bigger and more complex. They're like pre-built LEGO sets.
    Examples:

    • A full website header (logo + navigation menu + search bar)
    • A product card (image + title + price + add to cart button)
    • A comment section (comment form + list of comments)

Remember: Molecules are parts of organisms, but organisms can work independently.

Code Example: Before and After

Consider this non-Atomic Design todo app component:

Screenshot of the app

<template>
  <div class="container mx-auto p-4">
    <h1 class="text-2xl font-bold mb-4 text-gray-800 dark:text-gray-200">Todo App</h1>

    <!-- Add Todo Form -->
    <form @submit.prevent="addTodo" class="mb-4">
      <input
        v-model="newTodo"
        type="text"
        placeholder="Enter a new todo"
        class="border p-2 mr-2 bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded"
      />
      <button type="submit" class="bg-blue-500 hover:bg-blue-600 text-white p-2 rounded transition duration-300">
        Add Todo
      </button>
    </form>

    <!-- Todo List -->
    <ul class="space-y-2">
      <li
        v-for="todo in todos"
        :key="todo.id"
        class="flex justify-between items-center p-3 bg-gray-100 dark:bg-gray-700 rounded shadow-sm"
      >
        <span class="text-gray-800 dark:text-gray-200">{{ todo.text }}</span>
        <button
          @click="deleteTodo(todo.id)"
          class="bg-red-500 hover:bg-red-600 text-white p-1 rounded transition duration-300"
        >
          Delete
        </button>
      </li>
    </ul>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'

interface Todo {
  id: number
  text: string
}

const newTodo = ref('')
const todos = ref<Todo[]>([])

const fetchTodos = async () => {
  // Simulating API call
  todos.value = [
    { id: 1, text: 'Learn Vue.js' },
    { id: 2, text: 'Build a Todo App' },
    { id: 3, text: 'Study Atomic Design' }
  ]
}

const addTodo = async () => {
  if (newTodo.value.trim()) {
    // Simulating API call
    const newTodoItem: Todo = {
      id: Date.now(),
      text: newTodo.value
    }
    todos.value.push(newTodoItem)
    newTodo.value = ''
  }
}

const deleteTodo = async (id: number) => {
  // Simulating API call
  todos.value = todos.value.filter(todo => todo.id !== id)
}

onMounted(fetchTodos)
</script>
Enter fullscreen mode Exit fullscreen mode

This approach leads to large, difficult-to-maintain components. Letโ€™s refactor using Atomic Design:

This will be the refactored structure

๐Ÿ“ Template (Layout)
   โ”‚
   โ””โ”€โ”€โ”€ ๐Ÿ“„ Page (TodoApp)
        โ”‚
        โ””โ”€โ”€โ”€ ๐Ÿ“ฆ Organism (TodoList)
             โ”‚
             โ”œโ”€โ”€โ”€ ๐Ÿงช Molecule (TodoForm)
             โ”‚ โ”‚
             โ”‚ โ”œโ”€โ”€โ”€ โš›๏ธ Atom (BaseInput)
             โ”‚ โ””โ”€โ”€โ”€ โš›๏ธ Atom (BaseButton)
             โ”‚
             โ””โ”€โ”€โ”€ ๐Ÿงช Molecule (TodoItems)
                  โ”‚
                  โ””โ”€โ”€โ”€ ๐Ÿงช Molecule (TodoItem) [multiple instances]
                       โ”‚
                       โ”œโ”€โ”€โ”€ โš›๏ธ Atom (BaseText)
                       โ””โ”€โ”€โ”€ โš›๏ธ Atom (BaseButton)
Enter fullscreen mode Exit fullscreen mode

Refactored Components

Tempalte Default

<template>
  <div class="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100 transition-colors duration-300">
    <header class="bg-white dark:bg-gray-800 shadow">
      <nav class="container mx-auto px-4 py-4 flex justify-between items-center">
        <NuxtLink to="/" class="text-xl font-bold">Todo App</NuxtLink>
        <ThemeToggle />
      </nav>
    </header>
    <main class="container mx-auto px-4 py-8">
      <slot />
    </main>
  </div>
</template>

<script setup lang="ts">
import ThemeToggle from '~/components/ThemeToggle.vue'
</script>
Enter fullscreen mode Exit fullscreen mode

Pages

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import TodoList from '../components/organisms/TodoList'

interface Todo {
  id: number
  text: string
}

const todos = ref<Todo[]>([])

const fetchTodos = async () => {
  // Simulating API call
  todos.value = [
    { id: 1, text: 'Learn Vue.js' },
    { id: 2, text: 'Build a Todo App' },
    { id: 3, text: 'Study Atomic Design' }
  ]
}

const addTodo = async (text: string) => {
  // Simulating API call
  const newTodoItem: Todo = {
    id: Date.now(),
    text
  }
  todos.value.push(newTodoItem)
}

const deleteTodo = async (id: number) => {
  // Simulating API call
  todos.value = todos.value.filter(todo => todo.id !== id)
}

onMounted(fetchTodos)
</script>

<template>
  <div class="container mx-auto p-4">
    <h1 class="text-2xl font-bold mb-4 text-gray-800 dark:text-gray-200">Todo App</h1>
    <TodoList 
      :todos="todos"
      @add-todo="addTodo"
      @delete-todo="deleteTodo"
    />
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

Organism (TodoList)

<script setup lang="ts">
import TodoForm from '../molecules/TodoForm.vue'
import TodoItem from '../molecules/TodoItem.vue'

interface Todo {
  id: number
  text: string
}

defineProps<{
  todos: Todo[]
}>()

defineEmits<{
  (e: 'add-todo', value: string): void
  (e: 'delete-todo', id: number): void
}>()
</script>

<template>
  <div>
    <TodoForm @add-todo="$emit('add-todo', $event)" />
    <ul class="space-y-2">
      <TodoItem
        v-for="todo in todos"
        :key="todo.id"
        :todo="todo"
        @delete-todo="$emit('delete-todo', $event)"
      />
    </ul>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

Molecules (TodoForm and TodoItem)

TodoForm.vue:
<script setup lang="ts">
import TodoForm from '../molecules/TodoForm.vue'
import TodoItem from '../molecules/TodoItem.vue'

interface Todo {
  id: number
  text: string
}

defineProps<{
  todos: Todo[]
}>()

defineEmits<{
  (e: 'add-todo', value: string): void
  (e: 'delete-todo', id: number): void
}>()
</script>

<template>
  <div>
    <TodoForm @add-todo="$emit('add-todo', $event)" />
    <ul class="space-y-2">
      <TodoItem
        v-for="todo in todos"
        :key="todo.id"
        :todo="todo"
        @delete-todo="$emit('delete-todo', $event)"
      />
    </ul>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

TodoItem.vue:

<script setup lang="ts">
import { ref } from 'vue'
import BaseInput from '../atoms/BaseInput.vue'
import BaseButton from '../atoms/BaseButton.vue'

const newTodo = ref('')
const emit = defineEmits<{
  (e: 'add-todo', value: string): void
}>()

const addTodo = () => {
  if (newTodo.value.trim()) {
    emit('add-todo', newTodo.value)
    newTodo.value = ''
  }
}
</script>

<template>
  <form @submit.prevent="addTodo" class="mb-4">
    <BaseInput v-model="newTodo" placeholder="Enter a new todo" />
    <BaseButton type="submit">Add Todo</BaseButton>
  </form>
</template>
Enter fullscreen mode Exit fullscreen mode

Atoms (BaseButton, BaseInput, BaseText)

BaseButton.vue:
<script setup lang="ts">
defineProps<{
  variant?: 'primary' | 'danger'
}>()
</script>

<template>
  <button
    :class="[
      'p-2 rounded transition duration-300',
      variant === 'danger'
        ? 'bg-red-500 hover:bg-red-600 text-white'
        : 'bg-blue-500 hover:bg-blue-600 text-white'
    ]"
  >
    <slot></slot>
  </button>
</template>
Enter fullscreen mode Exit fullscreen mode

BaseInput.vue:

<script setup lang="ts">
defineProps<{
  modelValue: string
  placeholder?: string
}>()
defineEmits<{
  (e: 'update:modelValue', value: string): void
}>()
</script>

<template>
  <input
    :value="modelValue"
    @input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
    type="text"
    :placeholder="placeholder"
    class="border p-2 mr-2 bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded"
  />
</template>
Enter fullscreen mode Exit fullscreen mode

Info: Want to check out the full example yourself? click me

Component Level Job Examples
Atoms Pure, single-purpose components BaseButton BaseInput BaseIcon BaseText
Molecules Combinations of atoms with minimal logic SearchBar LoginForm StarRating Tooltip
Organisms Larger, self-contained, reusable components. Can perform side effects and complex operations. TheHeader ProductCard CommentSection NavigationMenu
Templates Nuxt layouts defining page structure DefaultLayout BlogLayout DashboardLayout AuthLayout
Pages Components handling data and API calls HomePage UserProfile ProductList CheckoutPage

Summary

Atomic Design offers one path to a more apparent code structure. It works well as a starting point for many projects. But as complexity grows, other architectures may serve you better. Want to explore more options? Read my post on How to structure vue Projects. It covers approaches beyond Atomic Design when your project outgrows its initial structure.

Top comments (0)