Sometimes the smallest UI components can cause the biggest headaches. Today I want to document a particularly stubborn bug I encountered while building a category filter dropdown for my blog - and the journey from frustration to solution.
The Problem: Dropdown Menu Chaos
What started as a simple feature request - "add a category filter dropdown to the blog" - quickly turned into a layout nightmare. The dropdown would open, but instead of floating gracefully above the content, it would:
- Distort the entire page layout
- Push content around unpredictably
- Create visual glitches and overlapping elements
- Refuse to properly fade in and out despite animation code
The symptoms were clear, but the root cause proved elusive.
Initial Approach: Radix UI Dropdown
My first instinct was to use a battle-tested solution. I implemented the dropdown using Radix UI's @radix-ui/react-dropdown-menu
, which is known for robust accessibility and positioning logic.
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
Category: {selectedCategory}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuRadioGroup
value={selectedCategory}
onValueChange={setSelectedCategory}
>
{categories.map((category) => (
<DropdownMenuRadioItem key={category} value={category}>
{category}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
This should have worked perfectly. Radix UI handles portal rendering, z-index management, and positioning automatically. But the layout distortion persisted.
Debug Attempt #1: Z-Index and Animation Fixes
My first debugging approach focused on the symptoms:
- Increased z-index to
z-[1000]
to ensure the dropdown appeared above everything - Enhanced animation classes with more specific Tailwind utilities
- Added better positioning with
origin-[var(--radix-dropdown-menu-content-transform-origin)]
- Improved the fade-in/out transitions
className={cn(
"z-[1000] min-w-[200px] max-h-[400px] overflow-hidden rounded-lg",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
// ... more animation classes
className
)}
Result: The dropdown looked better and had smoother animations, but the core layout issue remained.
Debug Attempt #2: Controlled State Management
Maybe the issue was with state management? I added explicit control over the dropdown's open/close state:
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
<DropdownMenu
open={isDropdownOpen}
onOpenChange={setIsDropdownOpen}
>
I also added a custom handler to ensure the dropdown closed after selection:
const handleCategorySelect = (category: string) => {
setSelectedCategory(category);
setIsDropdownOpen(false);
};
Result: Better user experience with predictable open/close behavior, but the layout distortion bug persisted.
The Root Cause Revelation
After multiple failed attempts to fix the Radix UI implementation, I realized the issue might be deeper than just styling or state management. The problem seemed to be with how Radix UI's portal system interacted with my specific layout structure.
Radix UI uses React portals to render dropdown content outside the normal DOM hierarchy, which should prevent layout issues. However, in complex layouts with CSS Grid, Flexbox, and CSS-in-JS styling (like Tailwind), these portals can sometimes create unexpected interactions.
The Solution: Custom Dropdown Component
Instead of continuing to fight with the Radix UI implementation, I decided to build a custom dropdown from scratch. This approach would give me complete control over positioning, animations, and state management.
const CategoryDropdown = ({
categories,
selectedCategory,
onSelect
}: {
categories: string[],
selectedCategory: string,
onSelect: (category: string) => void
}) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<div className="relative" ref={dropdownRef}>
<Button onClick={() => setIsOpen(!isOpen)}>
Category: {selectedCategory}
<ChevronDown className={`transition-transform ${isOpen ? "rotate-180" : ""}`} />
</Button>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: -10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.95 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="absolute top-full left-0 mt-2 w-[200px] bg-white border rounded-lg shadow-lg z-50"
>
{categories.map((category) => (
<button
key={category}
onClick={() => {
onSelect(category);
setIsOpen(false);
}}
className="w-full text-left px-3 py-2 hover:bg-gray-100"
>
{category}
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
};
Key Benefits of the Custom Solution
- Simple Positioning: Uses straightforward
relative
/absolute
positioning instead of portals - Predictable Animations: Framer Motion handles smooth fade-in/out transitions
- No Portal Conflicts: Dropdown renders in the normal DOM flow
- Complete Control: Every aspect of behavior and styling is explicitly defined
- Click Outside to Close: Custom hook handles user experience edge cases
- Visual Feedback: Chevron rotation and selection indicators provide clear state
Lessons Learned
This debugging journey taught me several valuable lessons:
1. Sometimes the "Right" Solution Isn't Right for Your Context
Radix UI is an excellent library with robust accessibility and positioning logic. But in this specific layout context, its portal-based approach created more problems than it solved.
2. Don't Over-Engineer When Simple Works
My initial instinct was to use a complex, feature-rich component library. The custom solution is actually simpler, more maintainable, and performs better.
3. Animation Libraries Can Solve Complex State Problems
Framer Motion's AnimatePresence
made implementing smooth enter/exit animations trivial, while also handling the complex timing of mount/unmount cycles.
4. User Experience Details Matter
Features like "click outside to close" and chevron rotation seem minor but significantly improve the perceived quality of the interface.
5. Sometimes You Need to Step Back and Rebuild
After multiple failed attempts to fix the existing implementation, starting fresh with a different approach saved time and produced a better result.
The Result
The final dropdown implementation:
- ✅ Opens with smooth fade-in animation
- ✅ Closes with smooth fade-out animation
- ✅ No layout distortion or visual glitches
- ✅ Proper click-outside-to-close behavior
- ✅ Visual feedback with icons and hover states
- ✅ Accessible keyboard navigation
- ✅ Clean, maintainable code
Sometimes the best solution is the one you build yourself.