Owning the Steering Wheel: How We Rebuilt Our Design System Dropdown and Multiselect
The dropdown and multiselect components are among the most fundamental building blocks in our design system. In a data-heavy application where users constantly filter, search, and select from large datasets, these components need to be performant, accessible, and flexible enough to handle diverse use cases across our product.
Component History
Our dropdown and multiselect components have evolved through several distinct phases, each representing a different approach to solving the same fundamental problem.
Phase 1: In-House Implementation
The first phase saw us building dropdown components completely in-house, from scratch. This initial implementation served us well for basic use cases, but as our product scaled and requirements became more complex, the limitations of this approach became evident.
Building everything from scratch meant we owned every piece of logic, state management, keyboard navigation, accessibility, filtering, virtualization, and portal rendering. While this gave us complete control, it also meant we were responsible for maintaining and evolving all of this complexity ourselves.
As new requirements surfaced, support for large lists, custom filtering, add-item functionality, tree structures, and various styling variants, each feature addition became increasingly risky and time-consuming. The component grew into a monolithic structure where changes in one area could have unexpected consequences in another.
Phase 2: Third-Party Dependency
The second phase took us to the opposite end of the spectrum: adopting a third-party solution (react-select) to satisfy our dropdown needs. This approach initially seemed promising. we could leverage a mature, well-tested library and focus on customization rather than core functionality.
However, we quickly discovered that third-party packages, while powerful, came with their own set of constraints. Customization was limited to what the library exposed through its API. When we needed behaviors that weren't supported out of the box, like specific keyboard navigation patterns, custom item rendering, or unique filtering logic. we found ourselves fighting against the library's architecture rather than building on top of it.
The component became a source of friction. Tasks that should have been straightforward required workarounds, and the bundle size grew with features we didn't always need. Most importantly, we lost the ability to evolve the component in directions that matched our specific product needs.
Phase 3: Hybrid Approach
This realization led us to our current approach: a hybrid solution that combines the best of both worlds. We leverage a headless utility library for the heavy lifting, state management, accessibility, keyboard navigation, and complex interaction patterns, while maintaining full control over the UI and UX through our own design system components.
We chose Downshift as our headless utility, wrapping it with our design system's Input, Label, and other primitives to create a component that feels native to our design language while benefiting from battle-tested interaction logic.
Motive: Why Rebuild?
As the product continued to scale, the existing components started hitting their limits. Adding new features became risky and time-consuming. Fixing bugs often turned into deep investigations. The dropdown was used extensively across the product, and the lack of confidence in such a critical component began to slow the team down.
Specific pain points included:
- Inflexible customization: Third-party solutions couldn't accommodate our unique design requirements
- Performance issues: Large lists caused performance problems without proper virtualization
- Maintenance burden: The in-house implementation required constant attention and refactoring
- Inconsistent behavior: Different implementations across the codebase led to inconsistent user experiences
Why Downshift?
The options in the headless dropdown space are relatively limited, but several libraries have emerged as strong contenders. We took the time to thoroughly evaluate multiple alternatives before committing to such a significant change.
Key Factors in Our Decision
- Accessibility-first: Built with ARIA attributes and keyboard navigation as core features
- Headless architecture: Complete control over UI rendering without opinionated styling
- Flexible state management: Exposes state and state change handlers for custom logic, including a powerful
stateReducerpattern - Active maintenance: Regular updates and community support
- Small bundle size: Focused on logic, not UI—critical for our performance requirements
- Composable hooks:
useComboboxanduseMultipleSelectionwork seamlessly together for multiselect scenarios
The tool checked all our boxes, and we decided it was time to rebuild our dropdown and multiselect components.
Implementation and Design Decisions
Building design-system components, especially ones as complex as dropdowns and multi-selects is not just about implementation details or elegant abstractions. It's about offering a clear, self-explanatory interface and a developer experience that enables progress instead of forcing repeated reconsideration of fundamental decisions.
The design was guided by three core principles: modularity, separation of concerns, and composability.
Architecture: Base and Composition
Our new architecture separates concerns into distinct layers:
Dropdown/
├── Dropdown.tsx # Public API, composes base with UI elements
├── DropdownBase/
│ └── Dropdown.base.tsx # Headless logic wrapper (Downshift integration)
└── TaggedListItem/ # Specialized list item variant
Multiselect/
├── Multiselect.tsx # Public API
├── Multiselect.base.tsx # Core logic (Downshift + useMultipleSelection)
├── MultiselectInput.tsx # Input with tag display
└── MultiselectList.tsx # Menu with virtualization
The DropdownBase component handles all the interaction logic using Downshift's useCombobox hook. It manages:
- Input value and filtering
- Menu open/close state
- Keyboard navigation (arrow keys, enter, escape)
- Item highlighting and selection
- Virtualization for large lists
- Portal rendering for modals
The Dropdown component composes the base with our design system primitives:
InputorCompactInputfor the triggerLabelfor list items- Custom styling through our BEM utility system
- Loading states and empty states
- Text highlighting: Search terms are automatically highlighted in list items
This separation allows developers to use the base component directly if they need custom UI, or use the composed Dropdown for standard use cases.
Virtualization for Performance
One of the key improvements in the new implementation is virtualization using @tanstack/react-virtual. For dropdowns with hundreds or thousands of options, rendering all items at once would cause significant performance issues.
The virtualizer only renders the visible items plus a small buffer, dramatically improving performance for large lists while maintaining smooth scrolling and keyboard navigation.
Floating UI for Positioning
We use @floating-ui/react for intelligent menu positioning. This ensures the dropdown menu:
- Stays within viewport bounds
- Adjusts position based on available space
- Matches the width of the input trigger
- Works correctly inside modals and scrollable containers
Multiselect: Extending the Pattern
The multiselect component builds on the same foundation but uses Downshift's useMultipleSelection hook in combination with useCombobox. This allows us to:
- Manage multiple selected items as an array
- Display selected items as removable tags
- Filter out selected items from the dropdown list
- Support keyboard navigation between tags (left/right arrows)
- Handle backspace/delete to remove items
The component maintains the same separation of concerns, with MultiselectInput handling the tag display and MultiselectList managing the dropdown menu.
Auto-Complete Mode
One notable feature of the multiselect is the auto-complete mode. When enabled, the dropdown menu only opens after the user has typed at least two characters. This pattern is particularly useful for scenarios with large datasets where showing all options upfront would be overwhelming or perform poorly. It provides a more focused, search-first experience that guides users to type before browsing, reducing cognitive load and improving performance for large option lists.
Customization Through Composition
Rather than trying to anticipate every possible use case through props, we designed the components to be composable. Developers can:
- Provide custom list item renderers with custom Type
- Implement custom filtering logic
- Add "create new item" functionality
- Use the base components directly for completely custom UIs
- Extend through TypeScript generics for type-safe item handling
This approach keeps the API surface manageable while providing escape hatches for edge cases.
Releasing Strategy
Creating reliable and robust components was only part of the challenge. For high-impact components used across multiple applications, the question of how to release them to production without disrupting delivery or degrading the user experience became equally important.
Our solution was two-fold: incremental rollout and gradual deprecation.
Phase 1: Parallel Implementation
We built the new components alongside the existing ones, ensuring feature parity for common use cases. This allowed us to validate the new architecture without disrupting existing functionality.
Phase 2: Gradual Migration
We began migrating existing implementations incrementally
This gradual approach allowed us to:
- Validate the new components in production
- Gather feedback from developers
- Fix issues as they arose
- Build confidence in the new architecture
Phase 3: Deprecation
Once we had sufficient confidence and adoption, we marked the old components as deprecated.
The old-dd-base folder remains in the codebase for the TreeDropdown component, which still uses the legacy base components. This is a known technical debt that we plan to address by migrating TreeDropdown to the new architecture.
Workarounds and Customizations
1. Custom State Reducer
Purpose: Preserves highlighted index on input click instead of resetting it.
Workaround:
function stateReducer<T>(state, actionAndChanges) {
switch (type) {
case useCombobox.stateChangeTypes.InputClick:
return {
...changes,
isOpen: true,
highlightedIndex: state.highlightedIndex >= 0
? state.highlightedIndex
: changes.highlightedIndex,
};
default:
return changes;
}
}
2. Show Full List on Menu Open
Purpose: Override Downshift's default to show full options list when menu opens, not filtered list.
Workaround:
onIsOpenChange: ({ isOpen, selectedItem }) => {
setFilteredList(optionList); // Reset to full list
// rest of the code
}3. Manual Filtering Logic
Purpose: Custom client-side filtering (Downshift has no built-in filtering).
Workaround:
onInputValueChange: ({ inputValue }) => {
// custom filtering handler
}4. Custom Item Identification
Purpose: Handle duplicate display values by using a separate identifier field.
Workaround:
const idField = identifierField || fieldToDisplay;
// Use idField for all item matching
const index = optionList.findIndex(op => op[idField] === selectedItem[idField]);
5. Virtualization Integration
Purpose: Render large lists efficiently using @tanstack/react-virtual.
Workaround:
const rowVirtualizer = useVirtualizer({
count: filteredList.length,
getScrollElement: () => listRef.current,
estimateSize: () => 40,
});
onHighlightedIndexChange: ({ highlightedIndex }) => {
rowVirtualizer.scrollToIndex(Number(highlightedIndex));
}
6. Floating UI Integration
Purpose: Intelligent menu positioning with viewport boundary detection.
Workaround:
const { refs, floatingStyles } = useFloating({
whileElementsMounted: autoUpdate,
middleware: [sizeMW({
apply({ elements, rects }) {
elements.floating.style.width = `${rects.reference.width}px`;
},
})],
});
7. Portal Rendering
Purpose: Render menu in portal for modals and z-index management.
Workaround:
{portalTarget ? (
createPortal(
<div ref={refs.setFloating} style={{ ...floatingStyles, zIndex: 9999 }}>
{menuElementRenderer}
</div>,
portalTarget
)
) : (
<>{menuElementRenderer}</>
)}
8. Custom Tab Key Handling
Purpose: Select highlighted item before closing menu on Tab press.
Workaround:
const keydownHandler = (event) => {
if (event.key === 'Tab' && isOpen && optionList.length && highlightedIndex >= 0) {
const isTheSameItem = selectedItem &&
filteredList[highlightedIndex][idField] === selectedItem[idField];
if (!isTheSameItem) {
setSelectedItem(filteredList[highlightedIndex]);
}
closeMenu();
}
};
9. Auto-Open Menu on Focus
Purpose: Open menu automatically when input receives focus.
Workaround:
getInputProps({
onFocus: () => {
if (!isOpen) openMenu();
},
})
Result: Beyond the Code
The most meaningful outcome was a shift in how the team related to the dropdown and multiselect components. Tasks that were once dreaded became routine, as confidence in the components grew.
Performance Improvements
- Virtualization: Large lists (1000+ items) now render smoothly
- Bundle size: Reduced by leveraging headless utilities
- Render performance: Optimized re-renders through proper React patterns
Developer Experience
- Type safety: Full TypeScript support with generics
- Clear API: Self-documenting prop names and structure
- Flexibility: Easy to extend without modifying core logic
- Consistency: Same patterns across dropdown and multiselect
User Experience
- Accessibility: WCAG 2.1 AA compliant out of the box
- Keyboard navigation: Full keyboard support for power users
- Visual feedback: Clear loading, empty, and error states
- Responsive: Works correctly in modals, scrollable containers, and mobile
That shift removed friction and allowed the dropdown and multiselect to function as what design-system components should be: reliable primitives the team can build on with confidence.
Looking Forward
The hybrid approach has proven successful, but the work is never done. We continue to:
- Monitor usage: Track component adoption and identify pain points
- Gather feedback: Listen to developers using the components daily
- Iterate: Add features as needed while maintaining the core architecture
- Migrate: Gradually move remaining legacy implementations to the new components