Marketplace animated-focus
This document captures learnings from fixing keyboard navigation issues when floating components (Select, DropdownMenu, Popover) have CSS open/close animations.
git clone https://github.com/aiskillstore/marketplace
T=$(mktemp -d) && git clone --depth=1 https://github.com/aiskillstore/marketplace "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/andrehogberg/animated-focus" ~/.claude/skills/aiskillstore-marketplace-animated-focus && rm -rf "$T"
skills/andrehogberg/animated-focus/SKILL.mdFocus Management with CSS Animations
This document captures learnings from fixing keyboard navigation issues when floating components (Select, DropdownMenu, Popover) have CSS open/close animations.
The Problem
When floating content elements have CSS animations that start at
opacity: 0 (like Tailwind's animate-in fade-in-0), the browser may reject element.focus() calls because the element is invisible.
Symptoms
- Component opens correctly with mouse clicks
- Keyboard navigation (arrow keys, Escape) doesn't work after opening with keyboard
- Works fine in demos without animation classes
- Broken in demos with animation classes
Root Cause
- CSS animations like
start the element atfade-in-0opacity: 0 - When
is called immediately after render, the element is still invisiblefocus() - Browser rejects focus on invisible elements
- Focus stays on the trigger button instead of moving to content
- Keyboard events go to trigger (which does nothing when open) instead of content
Evidence from Console Debugging
// After opening select, keyboard events go to trigger, not content: Document keydown: ArrowDown Target: <button role="combobox" ...> // Active element is trigger, not content: Active: BUTTON summit-select-...-trigger
The Solution
Implement a retry mechanism for focus that allows the animation to progress past
opacity: 0 before giving up.
JavaScript Implementation
// src/SummitUI/Scripts/floating.js /** * Focus an element with retry mechanism for animated elements. * Elements with CSS animations starting at opacity:0 may reject focus initially. * This retries focus up to 5 times with 20ms delays to allow the animation * to progress past the invisible state. * @param {HTMLElement} element - Element to focus */ export function focusElement(element) { if (!element) return; function tryFocus(attempts) { element.focus(); // If focus didn't succeed and we have attempts left, retry if (document.activeElement !== element && attempts > 0) { setTimeout(() => tryFocus(attempts - 1), 20); } } // First attempt after one frame to let CSS apply requestAnimationFrame(() => tryFocus(5)); }
Key Points
- Use
first - Ensures CSS has been applied before attempting focusrequestAnimationFrame - Check
- Verify if focus actually succeededdocument.activeElement - Retry with delays - 20ms intervals allow animation to progress
- Limited attempts - 5 retries = 100ms max wait, enough for typical animations
- Apply to all focus functions - Both
andfocusElement(element)
need this patternfocusElementById(id)
Functions Updated
- Used by SelectContentfloating.js:focusElement(element)
- Used by DropdownMenuContentfloating.js:focusElementById(elementId)
Testing
Added "With Animations" sections to test demo pages and corresponding Playwright tests.
CSS Animation Classes for Testing
/* tests/SummitUI.Tests.Manual/SummitUI.Tests.Manual/wwwroot/app.css */ @keyframes fadeInZoomIn { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } } @keyframes fadeOutZoomOut { from { opacity: 1; transform: scale(1); } to { opacity: 0; transform: scale(0.95); } } .animated-content[data-state="open"] { animation: fadeInZoomIn 150ms ease-out forwards; } .animated-content[data-state="closed"] { animation: fadeOutZoomOut 150ms ease-in forwards; }
Test Cases
For each component (Select, DropdownMenu, Popover):
| Test | What It Verifies |
|---|---|
| Opens with keyboard when animations present |
| Arrow keys work after animated open |
| Can select/activate item after animated open |
| Escape triggers close animation |
Running Animation Tests
dotnet run --project tests/SummitUI.Tests.Playwright -- --treenode-filter '/*/*/*/Animated*'
Files Involved
| File | Purpose |
|---|---|
| Contains and functions |
| Calls on open |
| Calls for menu items |
| Manages focus for popover content |
Alternative Approaches Considered
- Longer initial delay - Could use 50-100ms delay before first focus attempt, but adds noticeable lag
- Focus before animation starts - Would require changes to render order, complex
- Disable animation during focus - Would cause visual glitch
- CSS
instead ofvisibility
- Would require changes to how animations are authoredopacity
The retry mechanism was chosen because it:
- Works with any animation duration
- Doesn't require changes to CSS authoring
- Has minimal performance impact
- Fails gracefully if focus never succeeds
Related Patterns
This pattern is similar to how bits-ui handles animated presence in Svelte components. The key insight is that DOM operations (like focus) may need to wait for CSS animations to reach a focusable state.