Fixed bugs: Dropdown Menu Layout Distortion in React

Today

React
Next.js
TypeScript
Debugging
UI Components
Framer Motion

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:

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:

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

  1. Simple Positioning: Uses straightforward relative/absolute positioning instead of portals
  2. Predictable Animations: Framer Motion handles smooth fade-in/out transitions
  3. No Portal Conflicts: Dropdown renders in the normal DOM flow
  4. Complete Control: Every aspect of behavior and styling is explicitly defined
  5. Click Outside to Close: Custom hook handles user experience edge cases
  6. 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:

Sometimes the best solution is the one you build yourself.


← Back to Blog