7.5 KiB
HTMX Loading Indicators - Fix Report
Date: 2025-11-15 Issue: HTMX loading indicators exist but never become visible Status: ✅ FIXED
Root Cause Analysis
Problem Identified
The loading indicators were destroyed mid-animation because they were children of elements being replaced by HTMX swap operations.
Original Structure (BROKEN):
<div class="language-selector" id="language-selector">
<button hx-get="/switch-language?lang=en"
hx-target="#language-selector" <!-- SWAPS THIS DIV -->
hx-swap="outerHTML">
<span>English</span>
<iconify-icon class="htmx-indicator"> <!-- GETS DESTROYED! -->
</iconify-icon>
</button>
</div>
Timeline of Failure:
- User clicks button
- HTMX adds
.htmx-requestclass to button - CSS starts opacity transition:
0 → 1 - HTMX swap replaces
#language-selector(includes the button!) - Indicator element destroyed at ~7ms into 200ms transition
- Opacity reaches only
0.003before destruction - New button rendered without
.htmx-requestclass
Evidence
From Playwright timeline monitoring:
Time 585ms: htmx-request=true, opacity=0.000000
Time 592ms: htmx-request=false, opacity=0.003076 ← Transitioning but...
Time 600ms+: opacity=NaN ← Element destroyed!
Solution Implemented
1. Restructure HTML - Move Indicators Outside Swap Target
File: templates/partials/navigation/language-selector.html
New Structure:
<div class="language-selector-wrapper">
<!-- Indicators OUTSIDE the swap target -->
<iconify-icon id="lang-indicator-en"
class="htmx-indicator spinning small light">
</iconify-icon>
<iconify-icon id="lang-indicator-es"
class="htmx-indicator spinning small light">
</iconify-icon>
<!-- Swap target - buttons get replaced, indicators persist -->
<div class="language-selector" id="language-selector">
<button hx-get="/switch-language?lang=en"
hx-target="#language-selector"
hx-indicator="#lang-indicator-en"> <!-- Points to external indicator -->
<span>English</span>
</button>
<button hx-get="/switch-language?lang=es"
hx-target="#language-selector"
hx-indicator="#lang-indicator-es">
<span>Español</span>
</button>
</div>
</div>
Key Changes:
- Indicators moved OUTSIDE
#language-selectordiv - Each button uses
hx-indicator="#id"to point to its indicator - Indicators persist during swap operation
- HTMX adds
.htmx-requestto the indicator itself (not the button)
File: templates/language-switch.html
Updated swap response to match new structure (removes indicators from buttons).
2. Update CSS
File: static/css/main.css
Added Wrapper Styles:
/* Language selector wrapper - contains indicators outside swap target */
.language-selector-wrapper {
position: relative;
display: inline-flex;
height: 100%;
}
/* Position language indicators next to their respective buttons */
#lang-indicator-en,
#lang-indicator-es {
position: absolute;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
z-index: 10;
}
/* Position indicators inside the button visual area */
#lang-indicator-en {
left: calc(1rem + 50px); /* Inside first button */
}
#lang-indicator-es {
left: calc(1rem + 135px); /* Inside second button */
}
Fixed CSS Specificity Issue:
/* Added !important to ensure opacity change takes precedence */
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
opacity: 1 !important; /* ← Added !important */
}
Removed Conflicting Display Rule:
/* BEFORE: Conflicted with base styles */
.htmx-indicator.spinning {
display: inline-block; /* ← REMOVED THIS */
animation: htmx-spin 1s linear infinite;
}
/* AFTER: Inherits display: inline-flex from .htmx-indicator */
.htmx-indicator.spinning {
animation: htmx-spin 1s linear infinite;
}
Added Iconify Override:
/* Ensure iconify-icon indicators override global iconify-icon display style */
iconify-icon.htmx-indicator {
display: inline-flex;
align-items: center;
justify-content: center;
}
How It Works Now
HTMX Behavior with External Indicators
When using hx-indicator="#id" to point to an external element:
- User clicks button
- HTMX adds
.htmx-requestclass to the INDICATOR (not the button) - CSS rule
.htmx-request.htmx-indicator { opacity: 1 !important; }triggers - Indicator transitions from
opacity: 0toopacity: 1(200ms) - HTMX swap replaces button (indicator PERSISTS outside swap target)
- Request completes
- HTMX removes
.htmx-requestfrom indicator - Indicator transitions back to
opacity: 0
CSS Cascade Order
Critical: The !important flag is necessary because there are multiple .htmx-indicator rules:
- Line 193:
.htmx-indicator { flex-shrink: 0; }- No conflict - Line 503:
.htmx-indicator { opacity: 0; ... }- Base hidden state - Line 513:
iconify-icon.htmx-indicator { display: inline-flex; }- Override global iconify - Line 521:
.htmx-request.htmx-indicator { opacity: 1 !important; }- SHOW INDICATOR - Line 585: Media query for reduced motion - No conflict
Without !important, later rules or specificity conflicts could override the visibility.
Verification Steps
Manual Testing (Browser)
- Open http://localhost:1999/?lang=en
- Open DevTools → Network tab
- Throttle network to "Slow 3G"
- Click "Español" button
- EXPECTED: Spinning loader appears next to button during request
- VERIFY: Opacity transitions from 0 to 1, spinner rotates
Automated Testing (Playwright)
// Test confirms:
// 1. Indicators exist in DOM
// 2. Initial opacity = 0
// 3. During HTMX request: .htmx-request class added to indicator
// 4. Opacity transitions to 1
// 5. Indicator visible and spinning
// 6. After request: opacity returns to 0
See /tmp/test-htmx-behavior.js for full test.
Files Modified
- ✅
templates/partials/navigation/language-selector.html- Restructure HTML - ✅
templates/language-switch.html- Update swap response - ✅
static/css/main.css- Fix CSS specificity and positioning
Lessons Learned
HTMX Swap Target Considerations
- Problem: Indicators inside elements being swapped get destroyed mid-animation
- Solution: Place indicators OUTSIDE swap targets, use
hx-indicatorattribute - Key Insight: HTMX adds
.htmx-requestto the indicator when using external reference
CSS Specificity & Cascade
- Problem: Multiple
.htmx-indicatorrules can conflict - Solution: Use
!importantfor visibility rule, remove conflictingdisplayproperties - Key Insight: CSS loaded order matters even with same specificity
Testing Methodology
- Problem: Fast local requests complete before indicators are visible
- Solution: Use network throttling or delays to observe transitions
- Key Insight: Playwright timeline monitoring reveals exact opacity values over time
Status
✅ FIXED: HTMX loading indicators now properly display during language switch requests
Next Steps:
- Apply same pattern to toggle controls if they have similar swap issues
- Add E2E tests with network throttling to verify indicators
- Consider adding visible feedback for very fast requests (min-duration CSS or JS)
Debugging Surgeon | 2025-11-15