Welcome back, future HTMX maestro! In the previous chapters, you’ve mastered the art of making your web pages dynamic and interactive using HTMX. You’ve learned how to fetch and swap content with hx-get, hx-post, and various hx-swap strategies. But what happens when these requests take a little longer than expected? How do you let your users know that something is happening behind the scenes, preventing them from clicking furiously or wondering if their action registered?
That’s exactly what we’ll tackle in this chapter! We’re diving into the crucial world of user feedback during asynchronous operations. You’ll learn how to implement visual indicators (like spinners, loading bars, or text changes) to show when an HTMX request is in progress. This isn’t just a fancy UI trick; it’s a fundamental aspect of building a smooth, professional, and user-friendly web application.
By the end of this chapter, you’ll be able to:
- Understand why loading indicators are essential for a great user experience.
- Use the
htmx-indicatorclass to create simple loading states. - Master the
hx-indicatorattribute to precisely control which indicators show for which requests. - Implement global loading indicators for a consistent feel across your application.
- Handle common challenges and best practices for indicators in complex projects.
Ready to make your applications feel snappier and more responsive, even when they’re busy working? Let’s get started!
Core Concepts: The Art of User Feedback
Imagine clicking a button on a website, and nothing seems to happen for a few seconds. Do you click it again? Do you wonder if the site is broken? This is a common frustration that good user feedback aims to solve. When an HTMX request is initiated, especially one that might take a moment (like fetching data from a database or processing a complex form), providing a visual cue is paramount.
HTMX makes this incredibly simple with its built-in indicator mechanism, primarily relying on CSS.
The htmx-request Class: HTMX’s Secret Signal
At the heart of HTMX’s indicator system is a special CSS class: htmx-request. When an HTMX request is in progress:
- The element that triggered the request (e.g., the button you clicked) gets the
htmx-requestclass added to it. - Any parent element of the trigger up to the
<body>tag also gets thehtmx-requestclass added. - Once the request completes (successfully or with an error), the
htmx-requestclass is removed from all these elements.
This dynamic class is what we’ll leverage to style our indicators.
The htmx-indicator Class: Your Loading State Blueprint
To create a loading indicator, you simply add the htmx-indicator class to any HTML element. By default, HTMX expects elements with this class to be hidden when no request is active and visible when a request is active.
How does this work? HTMX provides a default stylesheet rule that looks something like this:
.htmx-indicator {
opacity: 0;
transition: opacity 200ms ease-in;
}
.htmx-request .htmx-indicator {
opacity: 1;
}
This CSS snippet means:
- Any element with
htmx-indicatoris initially transparent (opacity: 0). - When a parent element (or the indicator itself) gains the
htmx-requestclass, the indicator’s opacity smoothly transitions to1, making it visible.
Why is this brilliant? Because it’s purely CSS-driven! You can style your htmx-indicator elements however you want – a simple text message, a spinning icon, a progress bar – and HTMX will handle the visibility toggle by applying htmx-request to the appropriate parent.
The hx-indicator Attribute: Precision Targeting
While the htmx-indicator class is great for showing indicators within the immediate vicinity of a request, you often need more control. What if you have multiple buttons, and each needs its own specific loading spinner, or a single global spinner for all requests?
That’s where the hx-indicator attribute comes in. You can add hx-indicator to your triggering element (the one with hx-get, hx-post, etc.) and point it to a specific indicator element.
The value of hx-indicator can be a CSS selector, just like hx-target. It tells HTMX: “When this request is active, add htmx-request to that specific indicator element, instead of (or in addition to) its default behavior.”
Common values for hx-indicator:
#my-spinner: Targets an element withid="my-spinner"..my-loader: Targets an element withclass="my-loader".closest .form-group: Targets the closest ancestor withclass="form-group".this: Targets the triggering element itself (very useful for disabling/spinning buttons).body: Targets the<body>element, perfect for global indicators.
By combining htmx-indicator with hx-indicator, you gain fine-grained control over your loading states.
Step-by-Step Implementation: Bringing Indicators to Life
Let’s put these concepts into practice. We’ll start with a basic setup and then incrementally add indicators. For our backend, we’ll use a super simple Flask application to simulate a slow request, but you can easily adapt this to any backend framework (Django, Node.js, etc.).
Prerequisites:
- A basic understanding of HTML and CSS.
- HTMX included in your project (as covered in Chapter 1).
- A simple backend server. If you’re following along with Python, ensure you have Flask installed (
pip install Flask).
Let’s create a server.py file and an index.html file.
1. Backend Setup (server.py)
# server.py
from flask import Flask, render_template, request
import time
app = Flask(__name__)
@app.route('/')
def index():
return render_template('index.html')
@app.route('/slow-data')
def slow_data():
# Simulate a slow network request
time.sleep(2)
return "<p>Data loaded after a 2-second delay! (Requested at {})</p>".format(time.strftime("%H:%M:%S"))
@app.route('/save-form', methods=['POST'])
def save_form():
time.sleep(1.5) # Simulate saving data
data = request.form.get('item_name', 'No item')
return f"<p class='text-green-600'>'{data}' saved successfully! (Processed at {time.strftime('%H:%M:%S')})</p>"
if __name__ == '__main__':
app.run(debug=True)
To run this, open your terminal in the same directory as server.py and execute: python server.py. Then navigate to http://127.0.0.1:5000/ in your browser.
2. Basic Frontend Setup (templates/index.html)
Create a templates folder and inside it, index.html.
<!-- templates/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTMX Indicators</title>
<!-- Include HTMX from a CDN. As of 2025-12-04, v1.9.10 is the latest stable release. -->
<script src="https://unpkg.com/[email protected]/dist/htmx.min.js"></script>
<style>
body { font-family: sans-serif; margin: 2rem; }
.container { max-width: 600px; margin: 0 auto; padding: 1.5rem; border: 1px solid #eee; border-radius: 8px; }
button {
padding: 0.75rem 1.25rem;
background-color: #007bff;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.2s ease;
}
button:hover { background-color: #0056b3; }
button:disabled { background-color: #cccccc; cursor: not-allowed; }
/* HTMX Indicator Styles - We'll add more here! */
.htmx-indicator {
display: none; /* Initially hidden */
margin-left: 10px;
color: #555;
font-style: italic;
}
/* When a parent has htmx-request, show the indicator */
.htmx-request .htmx-indicator {
display: inline-block; /* Show it! */
}
</style>
</head>
<body>
<div class="container">
<h1>HTMX Loading Indicators</h1>
<h2>Basic Indicator</h2>
<div id="data-container">
<p>Click the button to load data...</p>
</div>
<button hx-get="/slow-data" hx-target="#data-container" hx-swap="innerHTML">
Load Slow Data
</button>
<span class="htmx-indicator">Loading...</span>
<hr style="margin: 2rem 0;">
<h2>Targeted Indicator</h2>
<div id="another-data-container">
<p>Click this button too...</p>
</div>
<button hx-get="/slow-data" hx-target="#another-data-container" hx-swap="innerHTML" id="button-with-target">
Load More Slow Data
</button>
<span id="my-specific-spinner" class="htmx-indicator">
<i class="fas fa-spinner fa-spin"></i> Fetching...
</span>
<hr style="margin: 2rem 0;">
<h2>Button as Indicator & Form Saving</h2>
<form hx-post="/save-form" hx-target="#form-feedback" hx-swap="innerHTML">
<input type="text" name="item_name" value="New Item" style="padding: 8px; border: 1px solid #ccc; border-radius: 4px; margin-right: 10px;">
<button type="submit" class="save-button htmx-indicator" hx-disable>
Save Item
</button>
</form>
<div id="form-feedback" style="margin-top: 10px;"></div>
<hr style="margin: 2rem 0;">
<h2>Global Indicator</h2>
<button hx-get="/slow-data" hx-target="#global-target" hx-swap="innerHTML">
Trigger Global Load
</button>
<div id="global-target" style="margin-top: 10px;"></div>
</div>
<!-- For the spinner icon in targeted indicator -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
</body>
</html>
Explanation of the initial HTML:
- We’ve included HTMX via CDN (version
1.9.10, which is the latest stable as of 2025-12-04). Always checkhttps://htmx.org/download/or the GitHub releases (https://github.com/bigskysoftware/htmx/releases) for the absolute latest if you’re working on a real project. - Basic styling for readability.
- Crucially, we have a
.htmx-indicatorCSS rule. It starts withdisplay: none;and thendisplay: inline-block;when.htmx-requestis present on a parent. This is a common pattern. HTMX itself provides a default stylesheet you can link, but customizing it like this gives you full control. - We’ve set up a few sections, each with a button that triggers a request to our
/slow-dataendpoint.
Now, let’s go section by section and add indicator logic!
Step 1: Basic Indicator with htmx-indicator
Look at the “Basic Indicator” section in index.html:
<h2>Basic Indicator</h2>
<div id="data-container">
<p>Click the button to load data...</p>
</div>
<button hx-get="/slow-data" hx-target="#data-container" hx-swap="innerHTML">
Load Slow Data
</button>
<span class="htmx-indicator">Loading...</span>
When you click “Load Slow Data”:
- The
<button>element gets thehtmx-requestclass. - Its parent
<div>(thecontainer) also getshtmx-request. - The
<span>withclass="htmx-indicator"is a sibling to the button. HTMX will look forhtmx-indicatorelements within the scope of thehtmx-requestclass. Since the button’s parent (.container) receiveshtmx-request, and the<span>is inside that same parent, the CSS rule.htmx-request .htmx-indicator { display: inline-block; }will apply, making our “Loading…” text visible.
Try it out! Reload your browser (http://127.0.0.1:5000/) and click “Load Slow Data”. You should see “Loading…” appear next to the button for 2 seconds, then disappear as the data loads.
This is the simplest form of indicator. It works well for showing a general “something is happening” message near the action.
Step 2: Targeted Indicator with hx-indicator
What if you have multiple buttons or specific loading states you want to show? You use hx-indicator.
Find the “Targeted Indicator” section:
<h2>Targeted Indicator</h2>
<div id="another-data-container">
<p>Click this button too...</p>
</div>
<button hx-get="/slow-data" hx-target="#another-data-container" hx-swap="innerHTML" id="button-with-target">
Load More Slow Data
</button>
<span id="my-specific-spinner" class="htmx-indicator">
<i class="fas fa-spinner fa-spin"></i> Fetching...
</span>
Currently, this button will also trigger the first “Loading…” indicator if it’s a sibling of the first button’s indicator, or the my-specific-spinner if it’s a sibling within the same htmx-request scope. To make it only show my-specific-spinner, we’ll add hx-indicator to the button:
<button hx-get="/slow-data" hx-target="#another-data-container" hx-swap="innerHTML" id="button-with-target"
hx-indicator="#my-specific-spinner"> <!-- ADD THIS ATTRIBUTE -->
Load More Slow Data
</button>
Explanation:
hx-indicator="#my-specific-spinner"tells HTMX: “When this button initiates a request, also add thehtmx-requestclass specifically to the element withid="my-specific-spinner".”- This overrides the default behavior of adding
htmx-requestto all parents and allows precise control. - We’ve also included Font Awesome for a nice spinner icon (
<i class="fas fa-spinner fa-spin"></i>).
Try it out! Save index.html, reload your browser. Click “Load More Slow Data”. Now, only the “Fetching…” spinner should appear next to this button, and the first “Loading…” indicator should remain hidden.
Thought Question: What would happen if you had both hx-indicator="#my-specific-spinner" and hx-indicator="body" on the same button? (Hint: HTMX allows multiple indicators, separated by commas).
Step 3: Making the Trigger Element an Indicator & Disabling it
Often, you want the button itself to change state during a request. It might show a spinner, change its text, or, very importantly, disable itself to prevent multiple submissions.
Look at the “Button as Indicator & Form Saving” section:
<h2>Button as Indicator & Form Saving</h2>
<form hx-post="/save-form" hx-target="#form-feedback" hx-swap="innerHTML">
<input type="text" name="item_name" value="New Item" style="padding: 8px; border: 1px solid #ccc; border-radius: 4px; margin-right: 10px;">
<button type="submit" class="save-button htmx-indicator"> <!-- ALREADY HAS htmx-indicator -->
Save Item
</button>
</form>
<div id="form-feedback" style="margin-top: 10px;"></div>
We already added class="htmx-indicator" to our button. This means the button itself will be targeted by the htmx-request class when it’s clicked.
Now, let’s add some CSS to make the button change:
/* ... existing CSS ... */
button.htmx-indicator.htmx-request {
/* When the button itself is the indicator AND the request is active */
background-color: #5cb85c; /* Change color */
color: #fff;
position: relative; /* For spinner positioning */
pointer-events: none; /* Disable clicks during request */
}
button.htmx-indicator.htmx-request::before {
content: "\f110"; /* Font Awesome spinner icon */
font-family: "Font Awesome 6 Free"; /* Ensure Font Awesome is loaded */
font-weight: 900;
margin-right: 8px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
Where to add this: Place this CSS inside the <style> tags in your index.html.
Explanation:
button.htmx-indicator.htmx-request: This selector targets our “Save Item” button when it has both thehtmx-indicatorandhtmx-requestclasses.background-color,color: We change the button’s appearance.pointer-events: none;: This is a CSS trick to make the element unclickable. It visually disables it without using thedisabledattribute.::beforepseudo-element: We use this to inject a Font Awesome spinner icon before the button’s text.@keyframes spin: Defines the animation for the spinner.
Even better: Disabling the button with HTMX
HTMX provides a dedicated attribute for disabling elements during a request: hx-disable. If you want to disable the button and show a spinner inside it, you can add hx-disable to the button. This is often more robust than pointer-events: none; as it directly manipulates the disabled attribute.
Let’s modify the button to use hx-disable:
<button type="submit" class="save-button htmx-indicator" hx-disable> <!-- ADD hx-disable -->
Save Item
</button>
Now, when you click the “Save Item” button:
- The button immediately gets the
disabledattribute added by HTMX. - Because it also has
class="htmx-indicator", thehtmx-requestclass is added to it. - Our CSS rules then apply, showing the spinner and changing its background.
- When the
/save-formrequest completes, thedisabledattribute is removed, and thehtmx-requestclass is removed, returning the button to its original state.
Try it out! Reload the page. Type something into the input field and click “Save Item”. You should see the button visually change, show a spinner, and become unclickable until the form submission is complete and the feedback message appears.
Step 4: Global Indicator
For a consistent user experience, you might want a single indicator that shows up for any HTMX request across your entire page. This is perfect for things like a small spinner in the header or a progress bar at the top of the viewport.
To achieve this, we can use hx-indicator="body" on our triggering elements, or simply place a global indicator high up in the DOM structure.
Let’s add a global indicator at the top of our <body>:
<!-- templates/index.html - Inside the <body> tag, right after the opening tag -->
<body>
<div id="global-spinner" class="htmx-indicator" style="position: fixed; top: 10px; right: 10px; background-color: rgba(0,0,0,0.7); color: white; padding: 8px 15px; border-radius: 5px; z-index: 1000;">
<i class="fas fa-circle-notch fa-spin"></i> Loading...
</div>
<div class="container">
<!-- ... rest of your content ... -->
</div>
</body>
And then, for the “Trigger Global Load” button, we explicitly tell it to target the <body> element (which will then activate any htmx-indicator children within it, including our #global-spinner):
<h2>Global Indicator</h2>
<button hx-get="/slow-data" hx-target="#global-target" hx-swap="innerHTML" hx-indicator="body"> <!-- ADD hx-indicator="body" -->
Trigger Global Load
</button>
<div id="global-target" style="margin-top: 10px;"></div>
Explanation:
- The
#global-spinnerdivis a fixed-position element, ready to appear anywhere on the screen. - By adding
hx-indicator="body"to the button, we instruct HTMX to apply thehtmx-requestclass to the<body>element when this specific button’s request is active. - Since
#global-spinneris a descendant of<body>and hashtmx-indicator, our CSS rule.htmx-request .htmx-indicatorwill make it visible.
Try it out! Reload the page. Click “Trigger Global Load”. You should see the “Loading…” spinner appear in the top-right corner of your browser window, persist for 2 seconds, and then disappear.
Pro-Tip: If you want every HTMX request on your page to trigger a global indicator, you can place your global indicator (with htmx-indicator class) high enough in the DOM (e.g., as a direct child of <body>) and HTMX’s default behavior will often activate it without needing hx-indicator="body" on every element, unless you’ve used hx-indicator to specifically target other indicators, which would override the default. For clarity and robustness in complex applications, explicitly targeting hx-indicator="body" or hx-indicator="#your-global-id" is a good practice.
Mini-Challenge: Advanced Form Submission Indicator
You’ve learned how to make buttons into indicators and disable them. Now, let’s combine that with a slightly more complex scenario.
Challenge:
Create a new section in your index.html with a button that simulates submitting a large file or a complex save operation. This button should:
- Show a spinner and change text (e.g., “Saving…”) inside itself while the request is active.
- Disable itself to prevent multiple clicks.
- Display a success message in a
divbelow the button after the request completes. - Bonus: If you’re feeling adventurous, try to make the button’s text revert to “Save” (without the spinner) on success. (Hint: This often requires swapping the button itself, or using HTMX’s
hx-onevents with a little JavaScript, which we’ll cover in later chapters. For now, just focusing on the spinner during the request is great!)
Hint:
- You’ll need
hx-post(orhx-put) to a new backend endpoint (e.g.,/submit-large-data). - The button should have
class="htmx-indicator"andhx-disable. - You can use CSS similar to what we did for the “Save Item” button, but perhaps with different text or a different spinner icon.
- The
hx-targetattribute will point to thedivfor the success message.
What to Observe/Learn:
- How
hx-disableandhtmx-indicatorwork together on a single element. - The smooth transition of the button’s state.
- The separation of concerns: button handles its own loading state, a separate div handles the result.
Common Pitfalls & Troubleshooting
Even with HTMX’s simplicity, indicators can sometimes be tricky. Here are a few common issues:
Indicator Not Showing At All:
- CSS issue: Is your
.htmx-indicator { display: none; }and.htmx-request .htmx-indicator { display: block; }(oropacityrules) correctly defined and loaded? htmx-indicatorclass missing: Did you forget to addclass="htmx-indicator"to your loading element?htmx-requestclass not applied: Open your browser’s developer tools. Inspect the triggering element and its parents. Do you see thehtmx-requestclass being added and removed during the request? If not, there might be a more fundamental issue with your HTMX request itself (e.g., incorrecthx-getURL).- Incorrect
hx-indicatorselector: If you’re usinghx-indicator, double-check that the selector (e.g.,#my-spinner) correctly points to an existing element in the DOM.
- CSS issue: Is your
Indicator Disappears Too Soon or Not At All:
hx-swapstrategy: If your indicator is inside the element being swapped, and you’re usinghx-swap="outerHTML", the indicator itself will be replaced along with its parent, causing it to disappear prematurely. Consider placing indicators outside thehx-targetif you’re usingouterHTML. If you want the trigger button to be an indicator that swapsouterHTML, you might need to swap a sibling element and have the button trigger a separate indicator (or use a different swap strategy likeinnerHTMLif applicable).- Long-running JavaScript: If you have client-side JavaScript that runs for a long time after the HTMX request completes but before the DOM is fully updated, the
htmx-requestclass might be removed too early. This is less common.
Multiple Indicators Showing When Only One Should:
- This usually happens when you rely on the default parent-level
htmx-requestclass, and multiplehtmx-indicatorelements are within that scope. - Solution: Use
hx-indicatorwith a precise selector to target only the specific indicator you want for that particular request. This overrides the broad scope.
- This usually happens when you rely on the default parent-level
Summary
Phew! You’ve just mastered a critical aspect of building user-friendly web applications with HTMX. Let’s recap the key takeaways from this chapter:
- User feedback is crucial: It prevents frustration, improves perceived performance, and makes your app feel professional.
htmx-requestclass: HTMX dynamically adds this class to the triggering element and its parents during an active request.htmx-indicatorclass: Apply this to any element you want to serve as a loading indicator. By default, HTMX expects these to be hidden by CSS initially and shown whenhtmx-requestis present on a parent.hx-indicatorattribute: Use this on your triggering element to explicitly tell HTMX which specific indicator (or indicators, using comma-separated selectors) should receive thehtmx-requestclass.hx-indicator="this": A powerful way to make the triggering element (like a button) itself act as a loading indicator.hx-disableattribute: A convenient HTMX attribute to automatically disable an element during an active request, preventing multiple submissions.- CSS is your friend: Most of the indicator magic happens through well-crafted CSS rules that respond to the presence of the
htmx-requestclass. - Official Documentation: Always refer to the official HTMX documentation for the most up-to-date and comprehensive information on indicators: https://htmx.org/attributes/hx-indicator/
You now have the tools to make your web applications feel responsive and intuitive, even when they’re busy doing heavy lifting. This attention to detail significantly elevates the user experience.
In the next chapter, we’ll shift our focus to error handling. Because let’s face it, things don’t always go as planned, and knowing how to gracefully inform your users when something goes wrong is just as important as showing them when things are loading correctly. Get ready to make your errors informative, not alarming!