Why Won't My Selected Row Turn Blue? The Hover vs Selection Mystery
I implemented keyboard navigation for my table, and it worked perfectly—until I tried using the mouse. The selected row would stay gray instead of blue, but only when my mouse was hovering over it. A debugging journey through CSS pseudo-class conflicts.
The Weird Behavior
I'm building a Browse window for my Electron note-taking app. The feature is straightforward:
- Click a note → highlight with blue background
- Use arrow keys (↑↓) → navigate between notes
- The selected note should always show the blue highlight
But something bizarre was happening:
✅ Use arrow keys → Blue background appears immediately
❌ Click with mouse → Gray background, no blue
❌ Keep mouse over row → Still gray... still gray...
✅ Move mouse away → Blue suddenly appears!
Wait, what? The blue background only appears when I move my mouse away?!
The Investigation: Logic vs Visual
My first instinct: "The state isn't updating."
const handleRowClick = (note, index) => {
console.log('Setting selectedIndex to:', index);
setSelectedIndex(index);
};
I added debug logs everywhere:
Console output when clicking row 3:
Setting selectedIndex to: 3 ✅
Component re-rendering... ✅
isSelected for row 3: true ✅
className includes: bg-blue-600/40 ✅
Everything in JavaScript-land is working! The state is updating, the component is re-rendering, the className is correct. But the background is still gray.
This must be a CSS issue.
The Original Code
Here's the JSX that was causing the problem:
<tr
onClick={() => handleRowClick(note, index)}
className={`border-b border-white/5 hover:bg-white/5 cursor-pointer ${
isSelected ? 'bg-blue-600/40' : ''
}`}
>
<td>{note.title}</td>
<td>{note.date}</td>
</tr>
At first glance, this looks reasonable:
hover:bg-white/5→ Gray hover effectbg-blue-600/40→ Blue when selected
But let's trace what happens when you click with the mouse:
The Timeline of a Mouse Click
Frame 1: Before Click
Mouse Position: Over row 3
:hover state: ACTIVE ✅
Classes applied: hover:bg-white/5
Background: Gray (from hover)
Frame 2: Click Event
Mouse Position: Still over row 3 (hasn't moved!)
:hover state: STILL ACTIVE ✅
onClick fires: setSelectedIndex(3)
State updates: selectedIndex = 3
Frame 3: Re-render
Mouse Position: Still over row 3
:hover state: STILL ACTIVE ✅
Classes applied: hover:bg-white/5 + bg-blue-600/40
Background: ??? (Both classes are applied!)
Frame 4: CSS Specificity Battle
/* Both of these are applied: */
.hover\:bg-white\/5:hover {
background-color: rgba(255, 255, 255, 0.05);
}
.bg-blue-600\/40 {
background-color: rgba(37, 99, 235, 0.4);
}
Who wins? Let's think about CSS specificity:
hover:bg-white/5 = .hover\:bg-white\/5:hover
→ Class selector + Pseudo-class
→ Specificity: (0, 1, 1)
bg-blue-600/40 = .bg-blue-600\/40
→ Class selector only
→ Specificity: (0, 1, 0)
The :hover pseudo-class gives higher specificity!
So even though both classes are applied, hover:bg-white/5 wins because :hover adds specificity.
Why Keyboard Navigation Works
Now let's trace what happens with arrow key navigation:
Using Arrow Keys
1. Press ↓ arrow key
2. keyDown event handler fires
3. setSelectedIndex(selectedIndex + 1)
4. Component re-renders
5. Mouse might be ANYWHERE (not necessarily over the row)
6. :hover state: INACTIVE ❌
7. Only bg-blue-600/40 applied
8. Background: Blue! ✅
The key difference: The mouse isn't necessarily hovering over the newly selected row!
The Aha Moment
The problem is clear now:
Mouse Click:
→ Mouse IS over the row
→ :hover is active
→ hover:bg-white/5 wins
→ Gray background
Arrow Keys:
→ Mouse is NOT over the row
→ :hover is inactive
→ bg-blue-600/40 wins
→ Blue background
The hover state is overriding the selection state!
The Solution
The fix is simple but elegant: Only apply hover when NOT selected
<tr
onClick={() => handleRowClick(note, index)}
className={`border-b border-white/5 cursor-pointer transition-colors ${
isSelected ? 'bg-blue-600/40' : 'hover:bg-white/5'
}`}
>
<td>{note.title}</td>
<td>{note.date}</td>
</tr>
Now the logic is:
if (isSelected) {
// Apply: bg-blue-600/40
// NO hover class at all!
} else {
// Apply: hover:bg-white/5
// Only when not selected
}
Before vs After
Before (Buggy):
className={`
hover:bg-white/5 ← Applied to ALL rows
${isSelected ? 'bg-blue-600/40' : ''}
`}
Problem: Both classes can be active simultaneously:
/* When mouse is over selected row: */
.hover:bg-white/5:hover { } /* This wins! */
.bg-blue-600/40 { } /* This loses! */
After (Fixed):
className={`
${isSelected
? 'bg-blue-600/40' ← Selected: Blue, NO hover
: 'hover:bg-white/5' ← Not selected: Hover works
}
`}
Solution: Classes are mutually exclusive:
/* When mouse is over selected row: */
.bg-blue-600/40 { } /* Only this! */
/* When mouse is over unselected row: */
.hover:bg-white/5:hover { } /* Only this! */
Testing the Fix
After applying the fix, let's test all scenarios:
Test 1: Click with Mouse
1. Click row 3
2. Mouse still over row 3
3. Background: Blue immediately! ✅
Test 2: Hover Over Unselected Row
1. Selected: row 3 (blue)
2. Mouse over row 5 (unselected)
3. Row 5 background: Gray hover ✅
4. Row 3 background: Still blue ✅
Test 3: Arrow Keys
1. Selected: row 3
2. Press ↓ arrow
3. Row 4 background: Blue immediately ✅
4. Row 3 background: Back to transparent ✅
Test 4: Mouse Over Selected Row
1. Selected: row 3 (blue)
2. Move mouse over row 3
3. Background: Stays blue! ✅
All scenarios work perfectly! ✨
The Visual Behavior Comparison
Before (Buggy):
State: Not Selected
Mouse: Away → Transparent ✅
Mouse: Hover → Gray ✅
State: Selected
Mouse: Away → Blue ✅
Mouse: Hover → Gray ❌ (Bug!)
After (Fixed):
State: Not Selected
Mouse: Away → Transparent ✅
Mouse: Hover → Gray ✅
State: Selected
Mouse: Away → Blue ✅
Mouse: Hover → Blue ✅ (Fixed!)
Understanding UI State Hierarchy
This bug reveals an important principle: UI states have a hierarchy.
The Hierarchy
1. Selected/Active ← Highest priority
2. Focus ← Medium priority
3. Hover ← Lowest priority
Implementation in Code
❌ Wrong: All states applied simultaneously
className={`
hover:bg-gray-100
focus:ring-2
${isSelected ? 'bg-blue-500' : ''}
`}
// Hover can override selection!
✅ Correct: States are mutually exclusive
className={`
${isSelected
? 'bg-blue-500' // No hover/focus when selected
: 'hover:bg-gray-100 focus:ring-2' // Hover/focus only when not selected
}
`}
// Selection always wins!
Real-World Examples
This pattern appears everywhere in UIs:
Example 1: Navigation Menu
<NavLink
className={({ isActive }) =>
isActive
? 'bg-blue-600 text-white' // Selected: solid blue
: 'text-gray-700 hover:bg-gray-100' // Not selected: gray hover
}
>
Dashboard
</NavLink>
Example 2: Button States
<button
className={`
${isActive
? 'bg-blue-600 text-white' // Active: no hover
: 'bg-white hover:bg-gray-50' // Inactive: hover works
}
`}
>
Click me
</button>
Example 3: Table Rows (Our Case)
<tr
className={`
${isSelected
? 'bg-blue-600/40' // Selected: blue
: 'hover:bg-white/5' // Not selected: gray hover
}
`}
>
The pattern: Selection state excludes hover state.
CSS Specificity Deep Dive
Let's understand why the hover was winning:
Specificity Calculation
/* Hover class with pseudo-class */
.hover\:bg-white\/5:hover {
/* Specificity: (0, 1, 1) */
/* = 0 IDs + 1 class + 1 pseudo-class */
}
/* Regular background class */
.bg-blue-600\/40 {
/* Specificity: (0, 1, 0) */
/* = 0 IDs + 1 class + 0 pseudo-classes */
}
Result: (0, 1, 1) > (0, 1, 0) → Hover wins!
Why This Matters
Even though both classes are applied:
<tr class="hover:bg-white/5 bg-blue-600/40">
The browser evaluates:
1. Both classes are present ✓
2. Mouse is hovering ✓
3. Apply hover:bg-white/5 with higher specificity ✓
4. Apply bg-blue-600/40 with lower specificity ✗ (overridden)
Common Variations of This Bug
Variation 1: Multiple States
// ❌ Bug: All states can conflict
<div className={`
hover:bg-gray-100
focus:bg-blue-100
${isActive && 'bg-green-500'}
${isError && 'bg-red-500'}
`}>
// ✅ Fix: Clear priority
<div className={`
${isError
? 'bg-red-500'
: isActive
? 'bg-green-500'
: 'hover:bg-gray-100 focus:bg-blue-100'
}
`}>
Variation 2: Disabled State
// ❌ Bug: Hover works on disabled button
<button className={`
hover:bg-blue-600
${isDisabled && 'bg-gray-300 cursor-not-allowed'}
`}>
// ✅ Fix: No hover when disabled
<button className={`
${isDisabled
? 'bg-gray-300 cursor-not-allowed'
: 'hover:bg-blue-600'
}
`}>
Variation 3: Loading State
// ❌ Bug: Hover visible while loading
<button className={`
hover:bg-blue-700
${isLoading && 'bg-blue-600 cursor-wait'}
`}>
// ✅ Fix: No hover while loading
<button className={`
${isLoading
? 'bg-blue-600 cursor-wait'
: 'hover:bg-blue-700'
}
`}>
Debugging Checklist
When you encounter unexpected hover behavior:
Step 1: Verify State Logic
console.log('State:', { isSelected, isHovered });
// Is the state updating correctly?
Step 2: Inspect Applied Classes
console.log('Classes:', element.className);
// Are both hover and selection classes present?
Step 3: Check CSS Specificity
Open DevTools → Elements → Computed
Look for both styles and see which wins
Step 4: Test Mouse Position
Keep mouse OVER element while clicking
Does behavior change when mouse moves away?
Step 5: Test Keyboard Navigation
Use keyboard to change selection
Does it work correctly without mouse?
The TailwindCSS Pattern
With Tailwind, this pattern is common and easy to implement:
// Pattern: Conditional hover based on state
className={`
${condition
? 'primary-state-classes' // No hover modifiers
: 'default-state hover:...' // Hover only in default state
}
`}
Examples:
// Selected vs not selected
${isSelected ? 'bg-blue-600' : 'hover:bg-gray-100'}
// Active vs inactive
${isActive ? 'text-white bg-blue-600' : 'text-gray-700 hover:text-gray-900'}
// Disabled vs enabled
${isDisabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-blue-700'}
Performance Consideration
Before (Unnecessary Re-renders):
// Hover class always present, even when selected
className={`hover:bg-white/5 ${isSelected ? 'bg-blue-600/40' : ''}`}
Browser still evaluates hover pseudo-class on selected elements (wasted work).
After (Optimized):
// Hover class only when needed
className={isSelected ? 'bg-blue-600/40' : 'hover:bg-white/5'}
Browser only evaluates hover when actually needed.
Performance gain: Minimal but cleaner CSS cascade.
Accessibility Bonus
The fix also improves accessibility:
Before:
Selected row with mouse over:
- Visual: Gray (confusing!)
- Screen reader: "Selected" (correct)
- Visual ≠ Screen reader announcement
After:
Selected row with mouse over:
- Visual: Blue (clear!)
- Screen reader: "Selected" (correct)
- Visual = Screen reader announcement ✓
Visual state now matches semantic state!
Key Takeaways
-
CSS pseudo-classes have specificity that can override regular classes
-
UI states have hierarchy:
- Selection/Active (highest)
- Focus (medium)
- Hover (lowest)
-
Conditional hover pattern:
${isSelected ? 'selected-classes' : 'hover:hover-classes'} -
Mouse position persists during click events:
- Click doesn't move mouse
:hoverstays active- Can cause unexpected visual states
-
Test both input methods:
- Mouse interactions
- Keyboard navigation
- Touch (if applicable)
-
Debug visual bugs by checking:
- State logic (JavaScript)
- Applied classes (DOM)
- CSS specificity (DevTools)
- Mouse/focus position
-
Tailwind best practice: Use conditional className, not layered classes for conflicting states
Conclusion
What seemed like a mysterious bug—"why won't my selected row turn blue?"—had a perfectly logical explanation: CSS pseudo-class specificity.
The hover state was literally overpowering the selection state because :hover adds specificity. The fix was simple: don't apply hover classes to selected elements.
The broader lesson: When working with interactive UIs, always think about state hierarchy. Selection should override hover, not the other way around.
And remember: if your state logic is correct but the visual isn't, it's probably a CSS issue! 🎨
Technical Details
- Framework: React with Electron
- CSS Framework: TailwindCSS
- Issue Type: CSS pseudo-class specificity conflict
- Solution: Conditional hover class application
- Lines Changed: 1 line (but what a difference!)
Tags: #css #react #debugging #tailwind #ui-states #electron
This bug was discovered during the development of my Electron note-taking app's browse window. Sometimes the simplest bugs teach the most valuable lessons!
