Skip to content

HTMX

HTMX brings the power of modern web applications to SØAD without complex JavaScript frameworks. This recipe shows you how to create dynamic, interactive web pages using HTMX's declarative approach to AJAX, CSS transitions, and WebSockets.


Dynamic Todo List with HTMX

htmx_todo.py
from utils import render
from models import Task

class Htmx_todo(object):
    def view(self, ctx):
        """Main page with full layout"""
        tasks = Task.findAll().orderBy("created_at DESC")
        ctx.output["tasks"] = tasks
        ctx.go_to = render.as_view(ctx, "htmx_todo")

    def task_list(self, ctx):
        """Partial view - just the task list"""
        tasks = Task.findAll().orderBy("created_at DESC")
        ctx.output["tasks"] = tasks
        ctx.go_to = render.as_view(ctx, "htmx_task_list")

    def add_task(self, ctx):
        """Add new task via HTMX"""
        request = ctx.getRequest()
        task_text = request.getParameter("task")

        if task_text and len(task_text.strip()) > 0:
            # Create new task
            new_task = Task()
            new_task.set("text", task_text.strip())
            new_task.set("completed", False)
            new_task.save()

        # Return updated task list
        tasks = Task.findAll().orderBy("created_at DESC")
        ctx.output["tasks"] = tasks
        ctx.go_to = render.as_view(ctx, "htmx_task_list")

    def toggle_task(self, ctx):
        """Toggle task completion status"""
        request = ctx.getRequest()
        task_id = request.getParameter("id")

        if task_id:
            task = Task.findById(task_id)
            if task:
                # Toggle completion status
                current_status = task.getBoolean("completed")
                task.set("completed", not current_status)
                task.save()

        # Return updated task list
        tasks = Task.findAll().orderBy("created_at DESC")
        ctx.output["tasks"] = tasks
        ctx.go_to = render.as_view(ctx, "htmx_task_list")

    def delete_task(self, ctx):
        """Delete task"""
        request = ctx.getRequest()
        task_id = request.getParameter("id")

        if task_id:
            task = Task.findById(task_id)
            if task:
                task.delete()

        # Return updated task list
        tasks = Task.findAll().orderBy("created_at DESC")
        ctx.output["tasks"] = tasks
        ctx.go_to = render.as_view(ctx, "htmx_task_list")

    def edit_form(self, ctx):
        """Show edit form for a task"""
        request = ctx.getRequest()
        task_id = request.getParameter("id")

        if task_id:
            task = Task.findById(task_id)
            if task:
                ctx.output["task"] = task
                ctx.go_to = render.as_view(ctx, "htmx_edit_form")
                return

        # If task not found, return to task list
        self.task_list(ctx)

    def update_task(self, ctx):
        """Update task text"""
        request = ctx.getRequest()
        task_id = request.getParameter("id")
        new_text = request.getParameter("text")

        if task_id and new_text and len(new_text.strip()) > 0:
            task = Task.findById(task_id)
            if task:
                task.set("text", new_text.strip())
                task.save()

        # Return updated task list
        tasks = Task.findAll().orderBy("created_at DESC")
        ctx.output["tasks"] = tasks
        ctx.go_to = render.as_view(ctx, "htmx_task_list")
htmx_todo.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>HTMX Todo List</title>
    <link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.0/css/bootstrap.min.css" rel="stylesheet">
    <script src="https://unpkg.com/[email protected]"></script>
    <style>
        .htmx-indicator { display: none; }
        .htmx-request .htmx-indicator { display: inline; }
        .htmx-request.htmx-indicator { display: inline; }
        .completed { text-decoration: line-through; opacity: 0.6; }
        .fade-in { animation: fadeIn 0.3s ease-in; }
        .fade-out { animation: fadeOut 0.3s ease-out; }
        @keyframes fadeIn {
            from { opacity: 0; transform: translateY(-10px); }
            to { opacity: 1; transform: translateY(0); }
        }
        @keyframes fadeOut {
            from { opacity: 1; }
            to { opacity: 0; }
        }
    </style>
</head>
<body>
    <div class="container mt-4">
        <div class="row justify-content-center">
            <div class="col-md-8">
                <h1 class="text-center mb-4">HTMX Todo List</h1>
                <p class="text-center text-muted mb-4">Dynamic updates without page reloads!</p>

                <!-- Add Task Form -->
                <div class="card mb-4">
                    <div class="card-body">
                        <form hx-post="/t/example/htmx_todo/add_task"
                              hx-target="#task-list"
                              hx-swap="outerHTML"
                              hx-on::after-request="this.reset()"
                              class="d-flex">
                            <input type="text" 
                                   class="form-control me-2" 
                                   name="task" 
                                   placeholder="What needs to be done?" 
                                   required>
                            <button type="submit" class="btn btn-primary">
                                <span class="htmx-indicator spinner-border spinner-border-sm me-1"></span>
                                Add Task
                            </button>
                        </form>
                    </div>
                </div>

                <!-- Task List Container -->
                <div id="task-list">
                    {{> htmx_task_list}}
                </div>

                <!-- Stats -->
                <div class="text-center mt-4">
                    <small class="text-muted">
                        Powered by HTMX - No JavaScript framework needed!
                    </small>
                </div>
            </div>
        </div>
    </div>

    <!-- Toast for notifications -->
    <div class="position-fixed top-0 end-0 p-3" style="z-index: 11">
        <div id="notification-toast" class="toast" role="alert">
            <div class="toast-header">
                <strong class="me-auto">Todo List</strong>
                <button type="button" class="btn-close" data-bs-dismiss="toast"></button>
            </div>
            <div class="toast-body" id="toast-message">
                Task updated!
            </div>
        </div>
    </div>

    <script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.0/js/bootstrap.bundle.min.js"></script>
    <script>
        // Show toast notifications
        document.addEventListener('htmx:afterRequest', function(e) {
            if (e.detail.xhr.status === 200) {
                const toast = new bootstrap.Toast(document.getElementById('notification-toast'));
                toast.show();
            }
        });
    </script>
</body>
</html>
htmx_task_list.html
<div id="task-list" class="fade-in">
    {{#if tasks}}
        <div class="card">
            <div class="card-header d-flex justify-content-between align-items-center">
                <h5 class="mb-0">Tasks ({{tasks.length}})</h5>
                <button class="btn btn-sm btn-outline-secondary"
                        hx-get="/t/example/htmx_todo/task_list"
                        hx-target="#task-list"
                        hx-swap="outerHTML">
                    <span class="htmx-indicator spinner-border spinner-border-sm me-1"></span>
                    Refresh
                </button>
            </div>
            <div class="list-group list-group-flush">
                {{#each tasks}}
                <div class="list-group-item" id="task-{{id}}">
                    <div class="d-flex justify-content-between align-items-center">
                        <div class="flex-grow-1 {{#if completed}}completed{{/if}}">
                            <div class="form-check d-inline-block me-2">
                                <input class="form-check-input" 
                                       type="checkbox" 
                                       {{#if completed}}checked{{/if}}
                                       hx-post="/t/example/htmx_todo/toggle_task"
                                       hx-target="#task-list"
                                       hx-swap="outerHTML"
                                       hx-vals='{"id": "{{id}}"}'>
                            </div>
                            <span class="task-text">{{text}}</span>
                            <small class="text-muted d-block">{{created_at}}</small>
                        </div>
                        <div class="btn-group btn-group-sm">
                            <button class="btn btn-outline-warning"
                                    hx-get="/t/example/htmx_todo/edit_form"
                                    hx-target="#task-{{id}}"
                                    hx-swap="outerHTML"
                                    hx-vals='{"id": "{{id}}"}'>
                                <i class="bi bi-pencil"></i>
                            </button>
                            <button class="btn btn-outline-danger"
                                    hx-delete="/t/example/htmx_todo/delete_task"
                                    hx-target="#task-list"
                                    hx-swap="outerHTML"
                                    hx-vals='{"id": "{{id}}"}'
                                    hx-confirm="Are you sure you want to delete this task?">
                                <span class="htmx-indicator spinner-border spinner-border-sm"></span>
                                <i class="bi bi-trash"></i>
                            </button>
                        </div>
                    </div>
                </div>
                {{/each}}
            </div>
        </div>
    {{else}}
        <div class="alert alert-info text-center">
            <h5>No tasks yet!</h5>
            <p class="mb-0">Add your first task above to get started.</p>
        </div>
    {{/if}}
</div>
htmx_edit_form.html
<div class="list-group-item bg-light" id="task-{{task.id}}">
    <form hx-post="/t/example/htmx_todo/update_task"
          hx-target="#task-list"
          hx-swap="outerHTML"
          class="d-flex align-items-center">
        <input type="hidden" name="id" value="{{task.id}}">
        <div class="flex-grow-1 me-2">
            <input type="text" 
                   class="form-control" 
                   name="text" 
                   value="{{task.text}}" 
                   required 
                   autofocus>
        </div>
        <div class="btn-group btn-group-sm">
            <button type="submit" class="btn btn-success">
                <span class="htmx-indicator spinner-border spinner-border-sm me-1"></span>
                Save
            </button>
            <button type="button" 
                    class="btn btn-secondary"
                    hx-get="/t/example/htmx_todo/task_list"
                    hx-target="#task-list"
                    hx-swap="outerHTML">
                Cancel
            </button>
        </div>
    </form>
</div>
CREATE TABLE task (
    id int NOT NULL AUTO_INCREMENT,
    text varchar(500) NOT NULL,
    completed boolean DEFAULT false,
    created_at timestamp DEFAULT CURRENT_TIMESTAMP,
    updated_at timestamp DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    KEY idx_completed (completed),
    KEY idx_created (created_at)
);

INSERT INTO task (text, completed) VALUES
('Learn HTMX basics', false),
('Build dynamic todo list', false),
('Implement partial page updates', true),
('Add smooth animations', false),
('Test user interactions', false);

HTMX Features Demonstrated

  • Partial Updates: Only update specific parts of the page
  • Form Handling: Submit forms without page refresh
  • Loading Indicators: Visual feedback during requests
  • Inline Editing: Edit tasks without leaving the page
  • Confirmations: Built-in confirmation dialogs

Live Search with HTMX

htmx_search.py
from utils import render
from models import User
import time

class Htmx_search(object):
    def view(self, ctx):
        """Main search page"""
        ctx.go_to = render.as_view(ctx, "htmx_search")

    def search_users(self, ctx):
        """Partial view - search results only"""
        request = ctx.getRequest()
        query = request.getParameter("q") or ""

        # Simulate some processing time for demo
        time.sleep(0.1)

        results = []
        if len(query.strip()) >= 2:
            # Search users by name or email
            users = User.where("name LIKE ? OR email LIKE ?", 
                             "%%%s%%" % query, "%%%s%%" % query).limit(20)

            for user in users:
                results.append({
                    "id": user.get("id"),
                    "name": user.get("name"),
                    "email": user.get("email"),
                    "department": user.get("department"),
                    "avatar": user.get("avatar") or "/assets/default-avatar.png"
                })

        ctx.output["query"] = query
        ctx.output["results"] = results
        ctx.go_to = render.as_view(ctx, "htmx_search_results")

    def user_details(self, ctx):
        """Partial view - user details modal content"""
        request = ctx.getRequest()
        user_id = request.getParameter("id")

        if user_id:
            user = User.findById(user_id)
            if user:
                ctx.output["user"] = user
                ctx.go_to = render.as_view(ctx, "htmx_user_details")
                return

        # User not found
        ctx.output["error"] = "User not found"
        ctx.go_to = render.as_view(ctx, "htmx_user_details")
htmx_search.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Live Search with HTMX</title>
    <link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.0/css/bootstrap.min.css" rel="stylesheet">
    <link href="https://cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.css" rel="stylesheet">
    <script src="https://unpkg.com/[email protected]"></script>
    <style>
        .search-container {
            position: sticky;
            top: 0;
            background: white;
            z-index: 100;
            padding: 1rem 0;
            border-bottom: 1px solid #dee2e6;
        }
        .htmx-indicator { 
            display: none; 
        }
        .htmx-request .htmx-indicator { 
            display: inline; 
        }
        .user-card {
            transition: transform 0.2s, box-shadow 0.2s;
            cursor: pointer;
        }
        .user-card:hover {
            transform: translateY(-2px);
            box-shadow: 0 4px 12px rgba(0,0,0,0.15);
        }
        .avatar {
            width: 50px;
            height: 50px;
            object-fit: cover;
        }
        .search-loading {
            min-height: 200px;
            display: flex;
            align-items: center;
            justify-content: center;
        }
    </style>
</head>
<body>
    <div class="container-fluid">
        <!-- Search Header -->
        <div class="search-container">
            <div class="container">
                <div class="row justify-content-center">
                    <div class="col-md-8">
                        <h1 class="text-center mb-4">Live User Search</h1>
                        <div class="input-group input-group-lg">
                            <span class="input-group-text">
                                <i class="bi bi-search"></i>
                            </span>
                            <input type="text" 
                                   class="form-control" 
                                   placeholder="Search users by name or email..."
                                   hx-get="/t/example/htmx_search/search_users"
                                   hx-target="#search-results"
                                   hx-trigger="keyup changed delay:300ms, search"
                                   hx-indicator="#search-indicator"
                                   name="q"
                                   autocomplete="off">
                            <span class="input-group-text">
                                <div class="htmx-indicator spinner-border spinner-border-sm" id="search-indicator"></div>
                            </span>
                        </div>
                        <div class="form-text text-center">
                            Start typing to search... Results update as you type!
                        </div>
                    </div>
                </div>
            </div>
        </div>

        <!-- Search Results -->
        <div class="container mt-4">
            <div id="search-results">
                <div class="text-center text-muted py-5">
                    <i class="bi bi-people display-1 text-muted"></i>
                    <h3>Search for Users</h3>
                    <p>Enter at least 2 characters to start searching</p>
                </div>
            </div>
        </div>
    </div>

    <!-- User Details Modal -->
    <div class="modal fade" id="userModal" tabindex="-1">
        <div class="modal-dialog">
            <div class="modal-content" id="modal-content">
                <!-- Modal content will be loaded here -->
            </div>
        </div>
    </div>

    <script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.0/js/bootstrap.bundle.min.js"></script>
    <script>
        // Auto-focus search input
        document.querySelector('input[name="q"]').focus();

        // Handle modal events
        document.addEventListener('htmx:afterRequest', function(e) {
            if (e.detail.target.id === 'modal-content') {
                const modal = new bootstrap.Modal(document.getElementById('userModal'));
                modal.show();
            }
        });
    </script>
</body>
</html>
htmx_search_results.html
<div id="search-results">
    {{#if query}}
        {{#if results}}
            <div class="row mb-3">
                <div class="col">
                    <h4>Search Results for "{{query}}"</h4>
                    <small class="text-muted">Found {{results.length}} user(s)</small>
                </div>
            </div>
            <div class="row">
                {{#each results}}
                <div class="col-md-6 col-lg-4 mb-3">
                    <div class="card user-card h-100"
                         hx-get="/t/example/htmx_search/user_details"
                         hx-target="#modal-content"
                         hx-vals='{"id": "{{id}}"}'
                         data-bs-toggle="tooltip"
                         title="Click to view details">
                        <div class="card-body">
                            <div class="d-flex align-items-center">
                                <img src="{{avatar}}" 
                                     class="avatar rounded-circle me-3" 
                                     alt="{{name}}">
                                <div class="flex-grow-1">
                                    <h6 class="card-title mb-1">{{name}}</h6>
                                    <p class="card-text small text-muted mb-1">{{email}}</p>
                                    {{#if department}}
                                        <span class="badge bg-secondary">{{department}}</span>
                                    {{/if}}
                                </div>
                                <i class="bi bi-chevron-right text-muted"></i>
                            </div>
                        </div>
                    </div>
                </div>
                {{/each}}
            </div>
        {{else}}
            <div class="text-center py-5">
                <i class="bi bi-search display-1 text-muted"></i>
                <h4>No users found</h4>
                <p class="text-muted">Try searching with different keywords</p>
            </div>
        {{/if}}
    {{else}}
        <div class="text-center text-muted py-5">
            <i class="bi bi-people display-1 text-muted"></i>
            <h3>Search for Users</h3>
            <p>Enter at least 2 characters to start searching</p>
        </div>
    {{/if}}
</div>
htmx_user_details.html
{{#if error}}
    <div class="modal-header">
        <h5 class="modal-title">Error</h5>
        <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
    </div>
    <div class="modal-body">
        <div class="alert alert-danger">{{error}}</div>
    </div>
{{else}}
    <div class="modal-header">
        <h5 class="modal-title">{{user.name}}</h5>
        <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
    </div>
    <div class="modal-body">
        <div class="row">
            <div class="col-md-4 text-center">
                <img src="{{user.avatar}}" 
                     class="img-fluid rounded-circle mb-3" 
                     style="max-width: 120px;" 
                     alt="{{user.name}}">
            </div>
            <div class="col-md-8">
                <table class="table table-sm">
                    <tr>
                        <td><strong>Name:</strong></td>
                        <td>{{user.name}}</td>
                    </tr>
                    <tr>
                        <td><strong>Email:</strong></td>
                        <td><a href="mailto:{{user.email}}">{{user.email}}</a></td>
                    </tr>
                    <tr>
                        <td><strong>Department:</strong></td>
                        <td>{{user.department}}</td>
                    </tr>
                    <tr>
                        <td><strong>Phone:</strong></td>
                        <td>{{user.phone}}</td>
                    </tr>
                    <tr>
                        <td><strong>Location:</strong></td>
                        <td>{{user.location}}</td>
                    </tr>
                    <tr>
                        <td><strong>Joined:</strong></td>
                        <td>{{user.created_at}}</td>
                    </tr>
                </table>
            </div>
        </div>
    </div>
    <div class="modal-footer">
        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
        <a href="mailto:{{user.email}}" class="btn btn-primary">
            <i class="bi bi-envelope"></i> Send Email
        </a>
    </div>
{{/if}}
CREATE TABLE user (
    id int NOT NULL AUTO_INCREMENT,
    name varchar(100) NOT NULL,
    email varchar(150) NOT NULL,
    department varchar(50),
    phone varchar(20),
    location varchar(100),
    avatar varchar(255),
    created_at timestamp DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    KEY idx_name (name),
    KEY idx_email (email),
    KEY idx_department (department),
    FULLTEXT KEY idx_search (name, email)
);

INSERT INTO user (name, email, department, phone, location, avatar) VALUES
('Alice Johnson', '[email protected]', 'Engineering', '+1-555-0101', 'San Francisco, CA', 'https://i.pravatar.cc/150?img=1'),
('Bob Smith', '[email protected]', 'Marketing', '+1-555-0102', 'New York, NY', 'https://i.pravatar.cc/150?img=2'),
('Carol Davis', '[email protected]', 'Design', '+1-555-0103', 'Los Angeles, CA', 'https://i.pravatar.cc/150?img=3'),
('David Wilson', '[email protected]', 'Engineering', '+1-555-0104', 'Seattle, WA', 'https://i.pravatar.cc/150?img=4'),
('Emma Brown', '[email protected]', 'Product', '+1-555-0105', 'Austin, TX', 'https://i.pravatar.cc/150?img=5'),
('Frank Miller', '[email protected]', 'Sales', '+1-555-0106', 'Chicago, IL', 'https://i.pravatar.cc/150?img=6'),
('Grace Lee', '[email protected]', 'Engineering', '+1-555-0107', 'Boston, MA', 'https://i.pravatar.cc/150?img=7'),
('Henry Taylor', '[email protected]', 'HR', '+1-555-0108', 'Denver, CO', 'https://i.pravatar.cc/150?img=8'),
('Iris Chen', '[email protected]', 'Design', '+1-555-0109', 'Portland, OR', 'https://i.pravatar.cc/150?img=9'),
('Jack Anderson', '[email protected]', 'Marketing', '+1-555-0110', 'Miami, FL', 'https://i.pravatar.cc/150?img=10');

Live Search Features

  • Real-time Search: Results update as you type with debouncing
  • Loading Indicators: Visual feedback during search requests
  • Modal Details: Click users to view details in a modal
  • Smooth Animations: CSS transitions for better UX
  • Auto-focus: Search input is automatically focused

HTMX Key Benefits

No JavaScript Framework: HTMX works with HTML attributes
Partial Updates: Only update what changes, not the whole page
Progressive Enhancement: Works even if JavaScript is disabled
Server-Side Rendering: Keep your logic in familiar SØAD transactions
Easy Integration: Add dynamic behavior to existing HTML forms