This document captures learnings from fixing keyboard navigation issues when floating components (Select, DropdownMenu, Popover) have CSS open/close animations.
This document captures learnings from fixing keyboard navigation issues when floating components (Select, DropdownMenu, Popover) have CSS open/close animations.
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.
fade-in-0 start the element at opacity: 0focus() is called immediately after render, the element is still invisible// 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
Implement a retry mechanism for focus that allows the animation to progress past opacity: 0 before giving up.
// 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));
}
requestAnimationFrame first - Ensures CSS has been applied before attempting focusdocument.activeElement - Verify if focus actually succeededfocusElement(element) and focusElementById(id) need this patternfloating.js:focusElement(element) - Used by SelectContentfloating.js:focusElementById(elementId) - Used by DropdownMenuContentAdded "With Animations" sections to test demo pages and corresponding Playwright tests.
/* 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;
}
For each component (Select, DropdownMenu, Popover):
| Test | What It Verifies |
|---|---|
Animated*_ShouldOpen_OnEnterKey |
Opens with keyboard when animations present |
Animated*_ShouldNavigate_WithArrowKeys |
Arrow keys work after animated open |
Animated*_ShouldSelect/Activate_OnEnterKey |
Can select/activate item after animated open |
Animated*_ShouldClose_OnEscape |
Escape triggers close animation |
dotnet run --project tests/SummitUI.Tests.Playwright -- --treenode-filter '/*/*/*/Animated*'
| File | Purpose |
|---|---|
src/SummitUI/Scripts/floating.js |
Contains focusElement and focusElementById functions |
src/SummitUI/Components/Select/SelectContent.cs |
Calls FocusElementAsync on open |
src/SummitUI/Components/DropdownMenu/DropdownMenuContent.cs |
Calls FocusElementByIdAsync for menu items |
src/SummitUI/Components/Popover/PopoverContent.cs |
Manages focus for popover content |
visibility instead of opacity - Would require changes to how animations are authoredThe retry mechanism was chosen because it:
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.