Building a Responsive Web App with Tailwind CSS and Alpine.js

Learn how to build a responsive web application with Tailwind CSS and Alpine.js, creating dynamic navigation menus and mobile-friendly layouts.

responsive web app with tailwind alpine

I still remember the sinking feeling when I deployed a web application to production and realized that it was completely unusable on smaller screens. The navigation menu took up half the page, the buttons were tiny, and the layout was all over the place. It was a code review nightmare.

You’ll build a responsive web application with Tailwind CSS and Alpine.js that adapts seamlessly to any screen size or device. By the end of this tutorial, you’ll have created a dynamic navigation menu using utility classes, implemented mobile-friendly layouts with grid and flexbox, and handled form validation with ease.

Getting Set Up with Tailwind CSS and Alpine.js

Setting up a New Project

To get started with Tailwind CSS and Alpine.js, I’ll assume you have a fresh Laravel project set up using Composer and the default Laravel installation.

First, let’s install the necessary dependencies:

npm install -D tailwindcss @tailwindcss/vite

This will install Tailwind CSS as a part of your Laravel setup. Next, we need to configure it for use in our application. Laravel builds front-end assets with Vite, so open vite.config.js:

import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';

export default defineConfig({
    plugins: [
        laravel({
            input: ['resources/css/app.css', 'resources/js/app.js'],
            refresh: true,
        }),
    ],
});

Update this to include the following code:

import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import tailwindcss from '@tailwindcss/vite';

export default defineConfig({
    plugins: [
        laravel({
            input: ['resources/css/app.css', 'resources/js/app.js'],
            refresh: true,
        }),
        tailwindcss(),
    ],
});

This allows us to use Tailwind through its official Vite plugin, so no separate PostCSS configuration is needed. (New Laravel 12 projects already ship with Tailwind CSS v4 and this plugin configured, so if your vite.config.js already calls tailwindcss(), you can skip these steps.) We’ll import Tailwind into resources/css/app.css later in this tutorial.

Now, let’s install Alpine.js using npm or yarn:

npm install alpinejs
# Or, if you prefer yarn:
yarn add alpinejs

Finally, update the resources/js/app.js file to include Alpine.js as follows:

import './bootstrap';
import Alpine from 'alpinejs';

window.Alpine = Alpine;
Alpine.start();

This configuration will allow us to use both Tailwind and Alpine.js within our Laravel application. In the next section, we’ll build a responsive navigation menu using Tailwind’s utility classes.

Building a Responsive Navigation Menu with Tailwind Utility Classes

Let’s create a responsive navigation menu using Tailwind CSS utility classes. This will help us understand how to utilize utility classes for building layouts and components.

First, let’s create the basic HTML structure for our navigation menu:

<nav class="bg-gray-800 text-white py-4">
    <div class="container mx-auto flex justify-between items-center">
        <a href="#" class="text-lg font-bold">Logo</a>
        <ul class="flex space-x-4">
            <li><a href="#" class="hover:text-gray-400 transition duration-300 ease-in-out">Home</a></li>
            <li><a href="#" class="hover:text-gray-400 transition duration-300 ease-in-out">About</a></li>
        </ul>
    </div>
</nav>

As you can see, we’re utilizing utility classes to style our navigation menu. The bg-gray-800 and text-white classes are used for the background color and text color respectively. We’re also using the py-4 class to add some padding to the top and bottom of our navigation menu.

Next, let’s use Tailwind CSS’s utility classes to make our navigation menu responsive. We’ll use media queries to hide or show certain elements based on screen size:

<nav class="bg-gray-800 text-white py-4">
    <div class="container mx-auto flex justify-between items-center">
        <a href="#" class="text-lg font-bold">Logo</a>

        <ul class="hidden md:flex space-x-4">
            <li><a href="#" class="hover:text-gray-400 transition duration-300 ease-in-out">Home</a></li>
            <li><a href="#" class="hover:text-gray-400 transition duration-300 ease-in-out">About</a></li>
            <li><a href="#" class="hover:text-gray-400 transition duration-300 ease-in-out">Contact</a></li>
        </ul>

        <div x-data="{ open: false }" class="md:hidden relative">
            <button id="menu-button" type="button" @click="open = !open" class="bg-gray-800 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded">Menu</button>
            <ul id="menu" x-show="open" x-cloak @click.outside="open = false" class="bg-gray-800 text-white p-4 w-48 absolute right-0 top-14 z-10 space-y-4">
                <li><a href="#" class="hover:text-gray-400">Home</a></li>
                <li><a href="#" class="hover:text-gray-400">About</a></li>
                <li><a href="#" class="hover:text-gray-400">Contact</a></li>
            </ul>
        </div>
    </div>
</nav>

We’ve used the hidden and md:flex utility classes to hide certain elements on smaller screens. We’ve also added a button that, when clicked, toggles Alpine’s open state to show or hide the navigation menu on small screens, and @click.outside closes it again.

This concludes our responsive navigation menu example using Tailwind CSS utility classes. The code is now set up for further customization and addition of interactivity with Alpine.js.

Creating Dynamic Content with Alpine.js Components

Alpine.js provides an efficient way to create dynamic content by allowing you to define reusable UI components. These components can be used throughout your application, reducing code duplication and making maintenance easier.

To demonstrate this concept, let’s create a simple Post component that displays a post’s title, author, and content:

First, register the component’s behaviour once with Alpine.data() in resources/js/app.js, before Alpine.start() is called:

// resources/js/app.js
import './bootstrap';
import Alpine from 'alpinejs';

Alpine.data('post', () => ({
    expanded: false,

    toggle() {
        this.expanded = !this.expanded;
    },
}));

window.Alpine = Alpine;
Alpine.start();

Next, create an anonymous Blade component that renders the post on the server and attaches the Alpine.js component to it:

{{-- resources/views/components/post.blade.php --}}
@props(['post'])

<article x-data="post" class="container mx-auto p-4 mt-6">
    <h1 class="text-2xl font-bold">{{ $post->title }}</h1>
    <p class="text-sm text-gray-500">By {{ $post->author }}</p>

    <p x-show="!expanded">{{ Str::limit($post->content, 200) }}</p>
    <p x-show="expanded" x-cloak>{{ $post->content }}</p>

    <button type="button" @click="toggle" x-text="expanded ? 'Show less' : 'Read more'" class="mt-2 text-blue-600 hover:underline"></button>
</article>

To use the Post component, you can simply call it in your view:

{{-- resources/views/posts/show.blade.php --}}
@extends('layouts.app')

@section('content')
    <h2 class="text-xl font-semibold">Showing a post</h2>

    <x-post :post="$post" />
@endsection

This is just a basic example of how you can create dynamic content with Alpine.js components. You can customize the component to suit your application’s needs and use it in various contexts throughout your web application.

Implementing Mobile-Friendly Layouts with Grid and Flexbox

To create a responsive layout that adapts to different screen sizes, we’ll utilize Tailwind’s grid and flexbox utilities. These tools enable us to efficiently manage layout complexities without writing custom CSS.

First, let’s modify our resources/css/app.css file to include the necessary configuration for grid and flexbox:

@import "tailwindcss";

[x-cloak] {
    display: none !important;
}

.card-grid {
    @apply grid gap-4 md:grid-cols-2 lg:grid-cols-3;
}

.toolbar {
    @apply flex justify-between items-center mb-4;
}

We’ve defined two custom classes, .card-grid and .toolbar, which will be used to apply grid and flexbox layouts respectively. The @apply directive is used to inject Tailwind’s utility classes into our custom classes, and the [x-cloak] rule keeps Alpine-controlled elements hidden until Alpine.js has initialised.

Now, let’s update our layout file (resources/views/layouts/app.blade.php) to include these new classes:

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
    <!-- ... -->
    @vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="antialiased">
    <div class="container mx-auto p-4 md:p-6 lg:p-8 card-grid">
        @yield('content')
    </div>

    <footer class="bg-gray-800 text-white py-4">
        <!-- ... -->
    </footer>
</body>
</html>

Notice that we’ve applied the .card-grid class to our container element, which will display three columns on large screens and two columns on medium-sized screens. The @vite directive loads our compiled Tailwind CSS and Alpine.js bundle, and the .toolbar class can be used for rows of controls such as page headers and form actions.

By leveraging Tailwind’s grid and flexbox utilities, we can create complex layouts with ease while maintaining a responsive design that adapts to various screen sizes.

Handling Form Validation with Alpine.js and Tailwind’s Utility Classes

So far, we’ve covered building responsive navigation menus and creating dynamic content using Alpine.js components. Now it’s time to tackle form validation – a crucial aspect of web development. In this section, we’ll demonstrate how to use Alpine.js and Tailwind CSS utility classes to validate user input.

Let’s assume we have a simple registration form with fields for name, email, and password:

{{-- resources/views/register.blade.php --}}
<form method="POST" action="/register" x-data="registerForm" @submit="validate($event)" class="max-w-md m-auto p-8 bg-white rounded-lg shadow-lg">
    @csrf

    <input type="text" name="name" x-model="name" placeholder="Name" class="block w-full p-2 mb-4 text-sm text-gray-700 border border-gray-400 focus:ring-blue-500 focus:border-blue-300 dark:focus:ring-blue-600 dark:focus:border-blue-700 @error('name') border-red-500 @enderror" :class="{ 'border-red-500': errors.name }" />
    <p x-show="errors.name" x-text="errors.name" class="-mt-3 mb-4 text-sm text-red-600"></p>
    @error('name') <p class="-mt-3 mb-4 text-sm text-red-600">{{ $message }}</p> @enderror

    <input type="email" name="email" x-model="email" placeholder="Email" class="block w-full p-2 mb-4 text-sm text-gray-700 border border-gray-400 focus:ring-blue-500 focus:border-blue-300 dark:focus:ring-blue-600 dark:focus:border-blue-700 @error('email') border-red-500 @enderror" :class="{ 'border-red-500': errors.email }" />
    <p x-show="errors.email" x-text="errors.email" class="-mt-3 mb-4 text-sm text-red-600"></p>
    @error('email') <p class="-mt-3 mb-4 text-sm text-red-600">{{ $message }}</p> @enderror

    <input type="password" name="password" x-model="password" placeholder="Password" class="block w-full p-2 mb-4 text-sm text-gray-700 border border-gray-400 focus:ring-blue-500 focus:border-blue-300 dark:focus:ring-blue-600 dark:focus:border-blue-700 @error('password') border-red-500 @enderror" :class="{ 'border-red-500': errors.password }" />
    <p x-show="errors.password" x-text="errors.password" class="-mt-3 mb-4 text-sm text-red-600"></p>
    @error('password') <p class="-mt-3 mb-4 text-sm text-red-600">{{ $message }}</p> @enderror

    <button type="submit" class="w-full px-4 py-2 tracking-wide font-semibold text-white transition-colors duration-200 bg-orange-500 hover:bg-orange-600 active:bg-orange-700 rounded-md">
        Register
    </button>
</form>

To validate the form in the browser, we’ll register a registerForm component with Alpine.js’s Alpine.data() and use its x-model, x-show and :class bindings with Tailwind CSS utility classes for styling errors. Blade’s @error directive still displays any errors returned by server-side validation:

// resources/js/app.js (before Alpine.start())
Alpine.data('registerForm', () => ({
    name: '',
    email: '',
    password: '',
    errors: {},

    validate(event) {
        this.errors = {};

        if (!this.name.trim()) {
            this.errors.name = 'Name is required.';
        }

        if (!/^\S+@\S+\.\S+$/.test(this.email)) {
            this.errors.email = 'Enter a valid email address.';
        }

        if (this.password.length < 8) {
            this.errors.password = 'Password must be at least 8 characters.';
        }

        if (Object.keys(this.errors).length > 0) {
            event.preventDefault();
        }
    },
}));

This is a basic example of how to handle form validation with Alpine.js and Tailwind’s utility classes. You can extend this approach by adding more complex validation rules using Laravel’s built-in validation features.

Adding Interactivity with Alpine.js Directives and Event Handling

Now that our application has a responsive design, it’s time to add some interactivity. Alpine.js provides several built-in directives that make it easy to attach behavior to elements with very little JavaScript.

One of the most powerful directives is x-model, which allows us to bind input fields to a component property. Let’s create a todoList component that includes a form for adding new todos:

// resources/js/app.js (before Alpine.start())
Alpine.data('todoList', () => ({
    todos: [],
    newTodo: '',
    error: '',

    addTodo() {
        if (!this.newTodo.trim()) {
            this.error = 'Please enter a todo.';
            return;
        }

        this.todos.push({ text: this.newTodo.trim() });
        this.newTodo = '';
        this.error = '';
    },
}));
{{-- resources/views/components/todo-list.blade.php --}}

<div x-data="todoList" class="p-4">
    <template x-for="(todo, index) in todos" :key="index">
        <div x-text="todo.text"></div>
    </template>

    <form @submit.prevent="addTodo">
        <input type="text" x-model="newTodo" placeholder="Add new todo...">
        <button type="submit">Add</button>
    </form>

    <p x-show="error" x-text="error" class="text-sm text-red-600"></p>
</div>

In this example, we use the x-model directive to bind the newTodo property to an input field. When the form is submitted, @submit.prevent calls the addTodo method, which checks the input and adds the new todo item to the todos array, and x-for re-renders the list automatically.

By using Alpine.js directives and event handling, we’ve made our application more interactive without writing complex JavaScript code.

Deploying the Responsive Web Application to Production

Now that your responsive web application is complete and thoroughly tested, it’s time to deploy it to production.

First, ensure you have a suitable hosting environment set up. For this example, we’ll use Laravel Forge to deploy our application.

In your project root directory, run the following commands:

npm ci
npm run build

This will install the necessary front-end dependencies and compile your Tailwind CSS and Alpine.js assets with Vite into public/build, ready for production.

Next, make sure the .env file on your server is configured for production:

APP_ENV=production
APP_DEBUG=false
APP_URL=https://example.com

Now, let’s update the deploy script for your site in Laravel Forge so the assets are built and Laravel’s caches are refreshed on every deployment:

cd /home/forge/example.com
git pull origin $FORGE_SITE_BRANCH

$FORGE_COMPOSER install --no-dev --no-interaction --prefer-dist --optimize-autoloader

npm ci
npm run build

$FORGE_PHP artisan migrate --force
$FORGE_PHP artisan optimize

Once Forge has your deploy script, you can deploy your application by pushing changes to your remote repository with Quick Deploy enabled. That’s it – your responsive web application is now live!

This concludes our tutorial on building responsive web applications with Tailwind CSS and Alpine.js.

Frequently Asked Questions

What is the difference between Tailwind CSS and other CSS frameworks like Bootstrap?

Tailwind CSS is a utility-first CSS framework, which means it provides pre-defined classes for common styling tasks. In contrast, Bootstrap is an opinionated framework that enforces a specific design language. With Tailwind, you have more control over the design of your application.

Why do I need to install @tailwindcss/vite in addition to tailwindcss?

@tailwindcss/vite is Tailwind’s official Vite plugin. Laravel uses Vite to build front-end assets, and the plugin lets Vite scan your templates and generate the Tailwind CSS your application uses. This ensures compatibility between Tailwind and your Laravel project.

What happens if I forget to update the resources/js/app.js file to include Alpine.js?

If you don’t include Alpine.js in your app.js file, you won’t be able to use its features in your application. You’ll need to add import Alpine from 'alpinejs'; and call Alpine.start(); to enable Alpine.js functionality.

Can I use Tailwind CSS with other JavaScript frameworks like React or Vue.js?

Yes, you can use Tailwind CSS with React, Vue.js or any other JavaScript framework. Tailwind generates plain CSS classes, so you simply add them to your components’ markup (using className in React) without needing a CSS-in-JS library.

Why do I need to use utility classes instead of writing custom CSS rules?

Utility classes provide a consistent and predictable way of styling your application. They also make it easier to maintain and update your design, as you can simply swap out one class for another without affecting the underlying CSS.

Comments

comments