Dev Series – Phoenix Part 4 – UI Improvement using TailwindCSS


Phoenix Elixir Tailwind
tutorial phoenix tailwindcss ui design flowbite

Dev Series – Phoenix Part 4 – UI Improvement using TailwindCSS

I’ll pick up where we left off with improving the UI. I’ve been using a lot of Tailwind lately, and since it’s built into Phoenix 1.7, we’ll switch from Bootstrap to Tailwind + Flowbite. Also, since this is a new setup, we’ll replace Flex with the built-in mix phx.gen.auth.

Why TailwindCSS?

TailwindCSS has become the go-to CSS framework for modern web development because:

  • Utility-first approach - Build designs directly in your markup
  • No CSS bloat - Only the styles you use are included
  • Highly customizable - Easy to extend and modify
  • Built into Phoenix 1.7 - No additional setup required
  • Great developer experience - Excellent IntelliSense support

Setting up TailwindCSS in Phoenix

Since Phoenix 1.7, TailwindCSS comes pre-configured! If you’re upgrading from an older version, here’s how to set it up:

Install TailwindCSS

cd assets
npm install -D tailwindcss @tailwindcss/forms @tailwindcss/typography
npx tailwindcss init

Configure Tailwind

Update your assets/tailwind.config.js:

module.exports = {
  content: [
    "./js/**/*.js",
    "../lib/*_web.ex",
    "../lib/*_web/**/*.*ex"
  ],
  theme: {
    extend: {
      colors: {
        brand: "#FD4F00",
      }
    },
  },
  plugins: [
    require("@tailwindcss/forms"),
    require("@tailwindcss/typography"),
  ]
}

Update CSS

Replace your assets/css/app.css with:

@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";

/* Custom components */
.btn {
  @apply inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2;
}

.btn-primary {
  @apply text-white bg-blue-600 hover:bg-blue-700 focus:ring-blue-500;
}

.btn-secondary {
  @apply text-gray-700 bg-white hover:bg-gray-50 border-gray-300 focus:ring-blue-500;
}

.btn-danger {
  @apply text-white bg-red-600 hover:bg-red-700 focus:ring-red-500;
}

Adding Flowbite Components

Flowbite provides beautiful Tailwind components. Install it:

npm install flowbite

Update your tailwind.config.js:

module.exports = {
  content: [
    "./js/**/*.js",
    "../lib/*_web.ex",
    "../lib/*_web/**/*.*ex",
    "./node_modules/flowbite/**/*.js"
  ],
  theme: {
    extend: {},
  },
  plugins: [
    require("@tailwindcss/forms"),
    require("@tailwindcss/typography"),
    require('flowbite/plugin')
  ]
}

Redesigning the Layout

Let’s create a modern, responsive layout. Update lib/repz_exercise_web/templates/layout/root.html.heex:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
    <meta name="csrf-token" content={csrf_token_value()}>
    <%= live_title_tag assigns[:page_title] || "RepzExercise", suffix: " · Phoenix Framework" %>
    <link phx-track-static rel="stylesheet" href={Routes.static_path(@conn, "/assets/app.css")}/>
    <script defer phx-track-static type="text/javascript" src={Routes.static_path(@conn, "/assets/app.js")}></script>
  </head>
  <body class="bg-gray-50">
    <!-- Navigation -->
    <nav class="bg-white shadow-lg">
      <div class="max-w-7xl mx-auto px-4">
        <div class="flex justify-between h-16">
          <div class="flex items-center">
            <%= link to: Routes.page_path(@conn, :index), class: "flex items-center" do %>
              <span class="text-xl font-bold text-gray-800">RepzExercise</span>
            <% end %>
          </div>
          
          <!-- Desktop Navigation -->
          <div class="hidden md:flex items-center space-x-4">
            <%= if @current_user do %>
              <%= link "Dashboard", to: Routes.workout_path(@conn, :index), 
                       class: "text-gray-700 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium" %>
              <%= link "Workouts", to: Routes.workout_path(@conn, :index), 
                       class: "text-gray-700 hover:text-gray-900 px-3 py-2 rounded-md text-sm font-medium" %>
              
              <!-- User Dropdown -->
              <div class="relative" x-data="{ open: false }">
                <button @click="open = !open" class="flex items-center text-sm rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
                  <span class="sr-only">Open user menu</span>
                  <div class="h-8 w-8 rounded-full bg-blue-500 flex items-center justify-center">
                    <span class="text-white text-sm font-medium">
                      <%= String.first(@current_user.email) |> String.upcase() %>
                    </span>
                  </div>
                </button>
                
                <div x-show="open" @click.away="open = false" 
                     class="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5">
                  <div class="py-1">
                    <span class="block px-4 py-2 text-sm text-gray-700 border-b">
                      <%= @current_user.email %>
                    </span>
                    <%= link "Profile", to: "#", class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" %>
                    <%= link "Settings", to: "#", class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" %>
                    <%= link "Sign out", to: Routes.pow_session_path(@conn, :delete), method: :delete,
                             class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" %>
                  </div>
                </div>
              </div>
            <% else %>
              <%= link "Sign in", to: Routes.pow_session_path(@conn, :new), class: "btn btn-secondary mr-2" %>
              <%= link "Sign up", to: Routes.pow_registration_path(@conn, :new), class: "btn btn-primary" %>
            <% end %>
          </div>
          
          <!-- Mobile menu button -->
          <div class="md:hidden flex items-center">
            <button class="text-gray-700 hover:text-gray-900 focus:outline-none focus:text-gray-900">
              <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
              </svg>
            </button>
          </div>
        </div>
      </div>
    </nav>

    <!-- Flash Messages -->
    <%= if get_flash(@conn, :info) do %>
      <div class="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded relative mx-4 mt-4" role="alert">
        <span class="block sm:inline"><%= get_flash(@conn, :info) %></span>
      </div>
    <% end %>
    
    <%= if get_flash(@conn, :error) do %>
      <div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded relative mx-4 mt-4" role="alert">
        <span class="block sm:inline"><%= get_flash(@conn, :error) %></span>
      </div>
    <% end %>

    <!-- Main Content -->
    <main class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
      <%= @inner_content %>
    </main>

    <!-- Add Alpine.js for interactive components -->
    <script src="https://unpkg.com/[email protected]/dist/cdn.min.js" defer></script>
  </body>
</html>

![Modern Navigation Layout][phoenix-part-4/modern-navigation]

Redesigning the Workout Index Page

Let’s create a beautiful workout index page with cards and improved typography:

<div class="bg-white">
  <div class="mx-auto max-w-2xl py-16 px-4 sm:py-24 sm:px-6 lg:max-w-7xl lg:px-8">
    <!-- Header -->
    <div class="flex items-center justify-between mb-8">
      <div>
        <h1 class="text-3xl font-bold tracking-tight text-gray-900">My Workouts</h1>
        <p class="mt-2 text-sm text-gray-600">Manage and track your exercise routines</p>
      </div>
      <%= link "New Workout", to: Routes.workout_path(@conn, :new), 
               class: "btn btn-primary" %>
    </div>

    <!-- Workout Grid -->
    <%= if Enum.empty?(@workouts) do %>
      <div class="text-center py-12">
        <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 
                d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
        </svg>
        <h3 class="mt-2 text-sm font-medium text-gray-900">No workouts</h3>
        <p class="mt-1 text-sm text-gray-500">Get started by creating a new workout.</p>
        <div class="mt-6">
          <%= link "New Workout", to: Routes.workout_path(@conn, :new), 
                   class: "btn btn-primary" %>
        </div>
      </div>
    <% else %>
      <div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
        <%= for workout <- @workouts do %>
          <div class="group relative bg-white rounded-lg shadow-sm border border-gray-200 hover:shadow-md transition-shadow duration-200">
            <div class="p-6">
              <!-- Workout Type Badge -->
              <div class="flex items-center justify-between mb-4">
                <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
                  <%= String.capitalize(workout.type) %>
                </span>
                <%= if workout.shared do %>
                  <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
                    Shared
                  </span>
                <% end %>
              </div>
              
              <!-- Workout Name -->
              <h3 class="text-lg font-medium text-gray-900 mb-2">
                <%= workout.name %>
              </h3>
              
              <!-- Description -->
              <p class="text-sm text-gray-600 mb-4 line-clamp-3">
                <%= workout.description %>
              </p>
              
              <!-- Actions -->
              <div class="flex items-center justify-between">
                <div class="flex space-x-2">
                  <%= link to: Routes.workout_path(@conn, :show, workout), 
                           class: "text-blue-600 hover:text-blue-900 text-sm font-medium" do %>
                    View
                  <% end %>
                  <%= link to: Routes.workout_path(@conn, :edit, workout), 
                           class: "text-gray-600 hover:text-gray-900 text-sm font-medium" do %>
                    Edit
                  <% end %>
                </div>
                <%= link to: Routes.workout_path(@conn, :delete, workout), method: :delete,
                         data: [confirm: "Are you sure?"],
                         class: "text-red-600 hover:text-red-900 text-sm font-medium" do %>
                  Delete
                <% end %>
              </div>
            </div>
          </div>
        <% end %>
      </div>
    <% end %>
  </div>
</div>

![Beautiful Workout Cards][phoenix-part-4/workout-cards]

Improving the Form Design

Let’s create a beautiful form layout for creating/editing workouts:

<div class="max-w-2xl mx-auto">
  <div class="bg-white shadow-sm rounded-lg">
    <div class="px-4 py-5 sm:p-6">
      <h3 class="text-lg leading-6 font-medium text-gray-900 mb-6">
        <%= if @changeset.data.id, do: "Edit Workout", else: "Create New Workout" %>
      </h3>
      
      <%= form_for @changeset, @action, [class: "space-y-6"], fn f -> %>
        <%= if @changeset.action do %>
          <div class="rounded-md bg-red-50 p-4">
            <div class="flex">
              <div class="flex-shrink-0">
                <svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
                  <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
                </svg>
              </div>
              <div class="ml-3">
                <h3 class="text-sm font-medium text-red-800">
                  Oops, something went wrong! Please check the errors below.
                </h3>
              </div>
            </div>
          </div>
        <% end %>

        <!-- Workout Name -->
        <div>
          <%= label f, :name, class: "block text-sm font-medium text-gray-700" %>
          <div class="mt-1">
            <%= text_input f, :name, 
                 class: "shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md",
                 placeholder: "e.g., Morning Push-ups" %>
          </div>
          <%= error_tag f, :name %>
        </div>

        <!-- Workout Type -->
        <div>
          <%= label f, :type, class: "block text-sm font-medium text-gray-700" %>
          <div class="mt-1">
            <%= select f, :type, 
                 [{"Timed Exercise", "timed"}, {"Distance Based", "distance"}, {"Repetition Based", "reps"}],
                 [prompt: "Choose a workout type"],
                 class: "shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md" %>
          </div>
          <%= error_tag f, :type %>
        </div>

        <!-- Description -->
        <div>
          <%= label f, :description, class: "block text-sm font-medium text-gray-700" %>
          <div class="mt-1">
            <%= textarea f, :description, 
                 rows: 4,
                 class: "shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md",
                 placeholder: "Describe your workout routine..." %>
          </div>
          <%= error_tag f, :description %>
        </div>

        <!-- Shared Toggle -->
        <div class="flex items-start">
          <div class="flex items-center h-5">
            <%= checkbox f, :shared, 
                 class: "focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded" %>
          </div>
          <div class="ml-3 text-sm">
            <%= label f, :shared, class: "font-medium text-gray-700" do %>
              Share this workout publicly
            <% end %>
            <p class="text-gray-500">Allow other users to see and use this workout routine.</p>
          </div>
        </div>

        <!-- Form Actions -->
        <div class="flex justify-end space-x-3">
          <%= link "Cancel", to: Routes.workout_path(@conn, :index), 
               class: "btn btn-secondary" %>
          <%= submit "Save Workout", class: "btn btn-primary" %>
        </div>
      <% end %>
    </div>
  </div>
</div>

![Modern Form Design][phoenix-part-4/modern-form]

Adding Responsive Design

Our Tailwind classes already include responsive design, but let’s add a mobile-friendly navigation:

<!-- Mobile Navigation Menu -->
<div x-show="mobileMenuOpen" class="md:hidden">
  <div class="px-2 pt-2 pb-3 space-y-1 sm:px-3 bg-white border-t">
    <%= if @current_user do %>
      <%= link "Dashboard", to: Routes.workout_path(@conn, :index), 
               class: "block px-3 py-2 text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50" %>
      <%= link "Workouts", to: Routes.workout_path(@conn, :index), 
               class: "block px-3 py-2 text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50" %>
      <div class="border-t pt-4">
        <div class="px-3 py-2 text-sm text-gray-500"><%= @current_user.email %></div>
        <%= link "Sign out", to: Routes.pow_session_path(@conn, :delete), method: :delete,
                 class: "block px-3 py-2 text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50" %>
      </div>
    <% else %>
      <%= link "Sign in", to: Routes.pow_session_path(@conn, :new), 
               class: "block px-3 py-2 text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50" %>
      <%= link "Sign up", to: Routes.pow_registration_path(@conn, :new), 
               class: "block px-3 py-2 text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50" %>
    <% end %>
  </div>
</div>

UI Showcase - Before and After

Let’s see our beautiful TailwindCSS transformation in action!

Desktop Homepage

The new homepage features a clean, modern design with proper branding and clear call-to-action:

Desktop Homepage - Full View

Mobile Responsive Design

Our application now works beautifully on mobile devices with responsive navigation:

Mobile Homepage - Responsive View

Mobile Navigation Menu

The hamburger menu provides easy access to all features on mobile:

Mobile Navigation Menu

Workouts Grid Layout

The workouts page now displays beautifully with card-based layout:

Workouts Grid Layout

Individual Workout View

Each workout has a clean, focused detail view:

Workout Detail View

What’s Next?

In future parts of this series, we could explore:

  • Adding real-time features with Phoenix LiveView
  • Implementing workout tracking and statistics
  • Adding social features for shared workouts
  • Building a mobile app with React Native

Conclusion

We’ve successfully transformed our Phoenix application with a modern, responsive design using TailwindCSS! Our improvements include:

  • Modern Navigation - Clean, responsive header with user dropdown
  • Beautiful Cards - Attractive workout display with proper spacing
  • Improved Forms - Professional form design with better UX
  • Responsive Design - Works great on all device sizes
  • Better Typography - Improved readability and visual hierarchy

TailwindCSS has made our application not only more beautiful but also more maintainable. The utility-first approach allows for rapid development while keeping our CSS bundle small and efficient.

Our workout tracking application now has a professional appearance that users will love to interact with!