Install any skill in seconds. Free to start, no credit card required.
Get Started Free →This document captures learnings from fixing keyboard navigation issues when floating components (Select, DropdownMenu, Popover) have CSS open/close animations.
.claude/skills/aiskillstore-animated-focus/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 11% | 0% |
| case-02 | ✗→✓ | ▲ Improved | -1% | 0% |
| case-07 | ✗→✓ | ▲ Improved | -20% | 0% |
| case-08 | ✗→✓ | ▲ Improved | -6% | 0% |
| case-09 | ✗→✓ | ▲ Improved | -18% | 0% |
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 invisiblejavascript// 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.
javascript// 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.
css/* 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 |
bashdotnet 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.
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 22,547 | 24,953 | +11% | 1 | 1 | 0% | 3,617 | 4,030 | +11% | 0 | 0 | — |
case-02 | fail→pass | 18,158 | 15,567 | -14% | 1 | 1 | 0% | 3,418 | 3,391 | -1% | 0 | 0 | — |
case-03 | fail→fail | 21,313 | 16,780 | -21% | 1 | 1 | 0% | 4,245 | 4,356 | +3% | 0 | 0 | — |
case-04 | pass→pass | 15,620 | 17,521 | +12% | 1 | 1 | 0% | 2,865 | 3,715 | +30% | 0 | 0 | — |
case-05 | pass→pass | 15,712 | 25,116 | +60% | 1 | 1 | 0% | 2,562 | 4,578 | +79% | 0 | 0 | — |
case-06 | pass→pass | 20,824 | 20,520 | -1% | 1 | 1 | 0% | 3,293 | 4,957 | +51% | 0 | 0 | — |
case-07 | fail→pass | 29,259 | 19,082 | -35% | 1 | 1 | 0% | 4,392 | 3,516 | -20% | 0 | 0 | — |
case-08 | fail→pass | 21,135 | 5,534 | -74% | 1 | 1 | 0% | 2,651 | 2,484 | -6% | 0 | 0 | — |
case-09 | fail→pass | 13,602 | 2,321 | -83% | 1 | 1 | 0% | 2,104 | 1,726 | -18% | 0 | 0 | — |
case-10 | fail→pass | 9,667 | 7,595 | -21% | 1 | 1 | 0% | 1,489 | 1,797 | +21% | 0 | 0 | — |
case-11 | pass→pass | 13,454 | 9,318 | -31% | 1 | 1 | 0% | 2,378 | 2,095 | -12% | 0 | 0 | — |
case-12 | pass→pass | 18,513 | 16,526 | -11% | 1 | 1 | 0% | 2,380 | 3,311 | +39% | 0 | 0 | — |
case-22 | fail→pass | 17,775 | 2,574 | -86% | 1 | 1 | 0% | 2,058 | 1,809 | -12% | 0 | 0 | — |
case-13 | pass→pass | 16,530 | 14,465 | -12% | 1 | 1 | 0% | 2,635 | 3,467 | +32% | 0 | 0 | — |
case-14 | fail→fail | 11,910 | 8,193 | -31% | 1 | 1 | 0% | 947 | 1,810 | +91% | 0 | 0 | — |
case-15 | pass→pass | 16,176 | 8,238 | -49% | 1 | 1 | 0% | 1,962 | 1,923 | -2% | 0 | 0 | — |
case-16 | fail→pass | 27,652 | 2,719 | -90% | 1 | 1 | 0% | 3,734 | 1,839 | -51% | 0 | 0 | — |
case-17 | fail→pass | 20,332 | 12,007 | -41% | 1 | 1 | 0% | 2,347 | 2,628 | +12% | 0 | 0 | — |
case-18 | fail→pass | 14,739 | 6,975 | -53% | 1 | 1 | 0% | 2,230 | 2,560 | +15% | 0 | 0 | — |
case-19 | fail→pass | 16,452 | 15,789 | -4% | 1 | 1 | 0% | 2,517 | 2,968 | +18% | 0 | 0 | — |
case-20 | pass→pass | 8,124 | 9,458 | +16% | 1 | 1 | 0% | 1,559 | 2,243 | +44% | 0 | 0 | — |
case-21 | pass→pass | 10,115 | 3,462 | -66% | 1 | 1 | 0% | 1,649 | 1,957 | +19% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +50 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.