When building product, you will reach a point where you need to implement a dropdown component. Dropdowns can take on many different shapes and forms, appearing as action lists, navigation menus, tooltips, or even autocomplete fields. Although they may look different and serve various purposes, their behavior significantly overlaps. Therefore, it's beneficial to create an abstraction that can handle all types of features these components need to support.
Supporting all these variations can be quite challenging and time-consuming, which is why most product teams rely on available component libraries and design systems. However, even if you're not building a dropdown from scratch, it's valuable to understand the internals and know what features the component you're using should support. Without this knowledge, it's easy to overlook important details and face difficulties when migrating to a different solution, which can become costly.
In this article, I'll outline the high-level expectations for the underlying utility, which I will refer to as Flyout. I'll explore several rabbit-hole topics, explaining their challenges and potential solutions. The architecture for addressing these issues can vary, so all examples and code snippets are drawn from my personal experience while building Reshaped. All examples are React-based, but you can adapt these concepts to other frameworks as well.
When beginning to build any component, I start with a high-level list of requirements and imagine an ideal API that I would like to use as an engineer. For the dropdown, a few key considerations come to mind, so let's explore them one by one.
Most dropdowns in applications open when a trigger is pressed. When building a low-level abstraction like this, it's important not to restrict it to a specific trigger component. If you decide that your trigger will only work with your Button component, it might backfire later. You could end up needing to extend it to support additional components over time or rewrite the implementation to accommodate custom triggers.
What makes this particularly challenging is that a dropdown needs to assign multiple event handlers and pass additional attributes to the trigger, with the props API potentially differing for each component. For instance, if your dropdown opens on click, you will need at least an onClick handler, several aria- attributes for improved accessibility, and a ref passed to the trigger to calculate its position on the page.
There are several approaches to handling this. The one that has worked best for me is using the React render props pattern, where you render children as a function that receives props passed down by the component. This approach allows you to decide how these props should be applied to the component you're using as a trigger. In my case, an additional advantage is that every component in the system supports an attributes property, which then gets spread on root element. This means all components behave consistently, as if you were spreading them on an HTML element, and you don't have to wait for the library to add support for any missing attributes.
For example, this is how the call would look for the Flyout component built on top of this utility:
<Flyout> <Flyout.Trigger> {(attributes) => <Button attributes={attributes}>Open</Button>} </Flyout.Trigger> <Flyout.Content>...</Flyout.Content> </Flyout>
The Popover.Trigger syntax is an example of the compound components pattern. This approach allows you to collocate the logic of multiple components by exporting them through a single variable.
Since we're building a low-level utility, we should support multiple ways of triggering a flyout. The most common triggers are click for action menus and popovers, hover for navigation menus and tooltips, and focus for autocomplete fields. Let's create a property to select which trigger type to use. This property will likely be used by other components in the library without being exposed to library users. For example, tooltips will simply use the hover value internally.
const Tooltip = (props) => { return ( <Flyout {...props} triggerType="hover"> ... </Flyout> ); };
Each triggerType value has different requirements. For example, the hover type should trigger the flyout not only on mouseenter but also on focus to ensure keyboard accessibility. Similarly, it should close on mouseleave and blur.
While activating a flyout typically happens on a trigger interaction, closing it is a bit more complex. Besides clicking on the trigger again, you need to handle a few other use cases.
The most obvious case is handling clicks outside the flyout content. When the flyout is active, you need to subscribe to all click events on the page and check if they occur outside the content area and the trigger, as click events are already handled there.
It's a good idea to write a custom hook that can be used in other components, or you can reuse one of the open-source options:
useOnClickOutside([flyoutContentRef, flyoutTriggerRef], { active, handler: handleClose, });
The second part is closing the flyout with the Escape key. The implementation for this is quite straightforward: add a keydown event listener that closes the flyout when Escape is pressed. Ensure you add the event listener only when the flyout is active to avoid unnecessary listeners being added and triggered.
Lastly, you can close the flyout through an active property. Using this approach gives you full control over its state, meaning you must manage both opening and closing. This also allows you to change the state based on custom logic, such as data fetching, timeouts, or clicking on specific items within the content.
const [active, setActive] = React.useState(false); return ( <Flyout active={active} onOpen={() => setActive(true)} onClose={() => setActive(false)} > <Flyout.Trigger>...</Flyout.Trigger> <Flyout.Content> ... <Button onClick={() => setActive(false)}>Close</Button> </Flyout.Content> </Flyout> );
Offloading state management entirely to the user might not always provide the best experience. So as a bonus, you can expose an imperative API for your component using useImperativeHandle:
const Flyout = (props) => { const handleOpen = React.useCallback(() => {}, []); const handleClose = React.useCallback(() => {}, []); React.useImperativeHandle( props.instanceRef, () => ({ open: handleOpen, close: handleClose, }), [handleOpen, handleClose], ); return <>...</>; }; // Later you can use it in another component const YourComponent = () => { const ref = React.useRef(); const handleDataResponse = () => { ref.close(); }; return <Flyout instanceRef={ref}>...</Flyout>; };
All of these features together provide your component with a solid API surface, bringing you closer to building a fully-featured dropdown like this:
The most noticeable part that we haven't touched yet is positioning. So let's take a closer look at how to position our flyout content and what factors to consider during implementation.
To start off, let's consider how dropdowns are typically positioned. Across various design systems, low-level utilities like flyout usually support 12 position values. You have a primary and a secondary axis. The primary axis is the side of the trigger where you want to display the content: top, bottom, start, and end. The secondary axis is the alignment on that side: start/top, center, bottom/end, depending on the primary axis. When combined, you get values like top-start, start-center, end-bottom, and so on.
Note that we're using start and end instead of left and right, aligning the naming with CSS Logical Properties to accommodate RTL (right-to-left) languages, where the position of the flyout should automatically mirror.
To ensure the flyout isn't cropped by a parent's overflow: hidden or affected by z-index issues, we'll use a React portal to render the flyout directly in the document.body. This approach also simplifies position calculations. Since we need access to the DOM at this point, we'll render the flyout only on the client by checking if the component has mounted.
const FlyoutContent = () => { const [mounted, setMounted] = React.useState(false); React.useLayoutEffect(() => { setMounted(true); }, []); if (!mounted) return; return ReactDOM.createPortal(children, document.body); };
Now, let's address the positioning itself. Since we're rendering the content directly in the document.body, the first attempt could be to just use getBoundingClientRect() on the trigger and content elements based on their refs. This allows us to perform calculations based on the position property. For example:
const GAP = 8; const triggerBounds = triggerEl.getBoundingClientRect(); const contentBounds = contentEl.getBoundingClientRect(); let left = 0; let top = 0; switch (position) { // Align with the left side of the trigger // Align top side of the content with the bottom side of the trigger case "bottom-start": left = triggerBounds.left; top = triggerBounds.bottom + GAP; // Align right side of the content with the right side of the trigger // Align bottom side of the content with the top side of the trigger case "top-end": left = triggerBounds.right - contentBounds.width; top = triggerBounds.top - contentBounds.height - GAP; }
We're using left instead of start here because getBoundingClientRect() does not return logical CSS values. Instead, for RTL languages, we will mirror the position value beforehand.
One issue we encounter is that we can't calculate the contentBounds until the content is visible to the user. To address this, we'll use a technique similar to FLIP used in animations. Instead of rendering the flyout content directly, we'll create an invisible clone and render it at the top-left corner of the page with a fixed position. This allows us to determine the actual size of the content without worrying about it wrapping and altering the flyout size.
However, this approach means that instead of using a simple internal active state, we'll have to implement a multi-stepped status state:
Another common expectation for dropdowns is that they will attempt to remain visible within the viewport if the default position causes them to be cropped. Our previous approach with an invisible clone is particularly useful here, as we can reuse it to test all possible positions until we find one that works before changing the status.
We'll start by defining an optimal fallback order, then iterate through these positions one by one based on the starting position to find the closest match. This approach optimizes the fallbacks to feel more natural to the user. For example, if the bottom-start position doesn't fit within the bottom of the viewport, it won't suddenly switch to top-center. Instead, it will try top-start first.
// All available positions for each side const positions = { top: ["top-start", "top-end", "top"], bottom: ["bottom-start", "bottom-end", "bottom"], start: ["start-top", "start-bottom", "start"], end: ["end-top", "end-bottom", "end"], }; // Order of sides to try depending on the starting side const fallbackOrder = { top: ["bottom", "start", "end"], bottom: ["top", "end", "start"], start: ["end", "top", "bottom"], end: ["start", "bottom", "top"], };
Besides resolving position fallbacks, there are a few other use cases to consider:
Whenever any of these situations occur, we'll use the same updatePosition function that we use internally for positioning the content. In the first two cases, we'll call this function inside the observer callbacks or within a useEffect. For the last case, we'll add this function to our useImperativeHandle methods:
React.useImperativeHandle( props.instanceRef, () => ({ open: handleOpen, close: handleClose, updatePosition, }), [handleOpen, handleClose, updatePosition], );
Now that the flyout content supports dynamic positioning, we're ready for the last major step of the implementation — making it accessible for keyboard navigation. To make it work, let's see how to implement focus trapping and some of its common edge cases.
First of all, what is focus trapping? When you open a dropdown with the keyboard, neither regular keyboard navigation nor screen reader navigation works by default, as the focus remains on the trigger. Focus trapping is a focus management technique that programmatically locks the keyboard focus within a specific area, making all other elements unreachable.
A simple focus trap implementation consists of several steps:
Save the currently focused element using document.activeElement.
Find all focusable elements within the flyout content and focus the first one. There is no native function for retrieving focusable elements, so you can use a selector to identify them and then exclude disabled elements, elements with zero height, and those with a tabIndex of -1.
const focusableSelector = 'a,button,input:not([type="hidden"]),textarea,select,details,[tabindex],[contenteditable]';
Add a custom keydown event listener that prevents default keyboard navigation and instead finds the previous or next focusable element from the array. This array should be updated every time navigation is triggered, in case the content changes between keystrokes.
Return a release function that cleans up all custom behavior and returns focus to the trigger element saved in the first step.
When using a screen reader, users can still leave the content area because screen readers use different key bindings for navigation. To address this, you can add an aria-hidden attribute to all elements that are not ancestors of the content. Alternatively, a modern approach is to use the inert attribute, depending on your browser support.
As we discussed earlier, flyouts can serve various use cases, from popovers with forms to action menus and tooltips. Depending on the role, in addition to using the triggerType, we can also assign a trapFocusMode. After experimenting with different options in Reshaped, I've settled on the following modes:
dialog: This mode implements a classic focus trap. Pressing Tab or Shift + Tab moves the focus forward or backward, and navigation is looped. This means pressing Tab while on the last element will move you to the first element, and vice versa. Since the focus is completely trapped, ensure there is a button among the focusable elements that closes the content.
action-menu: Instead of relying on Tab navigation, this mode uses arrow keys for navigation and does not loop; it stops at the first and last items. Pressing Tab or Shift + Tab deactivates the focus trap and moves the focus to the next focusable element adjacent to the trigger.
content-menu: This mode uses Tab for navigation but closes the content after tabbing from the last item, simulating a natural flow of content while still behaving like a flyout and rendering the content in a portal. It's typically used for website navigation menus.
selection-menu: This mode is used for selecting a value from a list, such as in comboboxes. When the content is opened, the real focus remains on the trigger, while a pseudo-focus highlights elements using a custom CSS selector. Arrow keys move this pseudo-focus across focusable elements, while the real focus stays on the trigger. This mode is often used for input triggers, allowing users to continue typing while navigating the content.
For all four modes, focus moves to the first element of the content if the triggerType is click, and stays on the trigger for other types. However, in practice, you might not use some combinations. For example, I haven't yet encountered a use case for hover + dialog, as it would force the focus to move too aggressively.
Since we're handling keyboard navigation ourselves and preventing default behavior, we must also address potential edge cases:
These are some of the most prominent edge cases related to focus trapping, and there are even more details that are easy to overlook in dropdowns. Each one is a rabbit hole that can be challenging to maintain, yet they are quite fun to work on.
We have covered the main aspects of building a dropdown and some of its challenges. However, the journey doesn't end there. When developing complex products, you'll inevitably encounter new edge cases and opportunities to enhance the user experience. In this final section, I'd like to go one step further and share some of the rabbit holes I've discovered along the way. Time to get nerdy! 🤓
Adding animations to any component that handles dynamic content rendering and non-trivial state management often introduces additional challenges. I encountered two of these challenges that were quite unexpected the first time.
When you have animated content rendering, you need to ensure the content transition completes before removing it from the DOM. The first edge case I encountered was related to how OS settings can affect this. The issue happened on macOS, which has a Key Repeat Rate setting to speed up navigation when holding the Tab key, and it can potentially occur in other environments as well. When multiple hover triggers are displayed next to each other, holding Tab will quickly change the state, cycling through each phase from idle to rendered and back. However, this happens so quickly that the opening transition may not start, and therefore, the transitionend event never triggers, blocking the final state update. To solve this, I save a flag at the transitionstart, and if the transition hasn't started, I immediately unmount the content on close.
const handleTransitionStart = (e) => { if (!active) return; // Ignore if event bubbled up from content if (flyoutElRef.current !== e.currentTarget) return; transitionStartedRef.current = true; }; useLayoutEffect(() => { if (transitionStartedRef.current) { hide(); } else { remove(); } }, [hide, remove]);
The second edge case involves trigger transitions and occurs when a trigger has a transform, such as a scale, applied on click. When using getBoundingClientRect to calculate the content position, the coordinates include the shift caused by the transform. To avoid this, you should first save the trigger coordinates on mousedown and then use these saved coordinates when opening the content on click.
Here you can see how the content position would be shifted due to the scale effect applied on click if you don't handle this edge case. While it's not a significant shift, small details like this can accumulate, making the design appear incorrect.
When building applications that use many flyouts, such as tooltips, you need to balance multiple behaviors. On one hand, you don't want to show tooltips immediately when hovering over the trigger, as you might just be moving the mouse around the screen. On the other hand, when you have multiple triggers with tooltips, it's common practice to reduce this delay after the first tooltip is opened and reset it back after the last tooltip is closed.
Whether you choose to implement something like Flyout.Group or manage this behavior globally across the entire application, you'll need a separate logical layer to manage the status and detect when to adjust the timeout value. Here is a simplified version of the implementation I use in Reshaped to give you a better idea of the internals:
class Cooldown { status = "cold"; timer; warm = () => { clearTimeout(this.timer); // Still not disabled from previous tooltip - immediately enable if (this.status === "cooling") { this.status = "warm"; return; } // Enable after a short timeout this.status = "warming"; this.timer = setTimeout(() => { this.status = "warm"; }, 100); }; cool = () => { clearTimeout(this.timer); // Hasn't finished opening – immediately disable if (this.status === "warming") { this.status = "cold"; return; } // Disable after a longer timeout in case another tooltip gets enabled meanwhile this.status = "cooling"; this.timer = setTimeout(() => { this.status = "cold"; }, 500); }; } const cooldown = new Cooldown(); const Flyout = () => { const handleMouseEnter = () => { cooldown.warm(); // Use a long timeout for the first tooltip and a short one for the following ones setTimeout(handleOpen, cooldown.status === "warming" ? 800 : 100); }; const handleMouseLeave = () => { cooldown.cool(); handleClose(); }; useLayoutEffect(() => { if (active) return; if (cooldown.status === "cooling") { hide(); // In case another tooltip is already activated - remove previous one instantly } else { remove(); } }, [active]); };
When rendering content, we typically use a ReactDOM.createPortal to document.body by default. However, there are two edge cases where this approach might not work as expected.
The first issue happens when using flyouts inside scrollable areas or elements with position: fixed or sticky. If you render the content directly in the document.body, scrolling the page or that element would keep the content in place instead of moving it along with the trigger. An initial thought might be to recalculate the position on scroll, but this can be computationally expensive when using methods like getComputedStyle or may lag behind the scrolled content.
A more efficient way is to rely on the native scrolling behavior by rendering the content in the closest parent of the trigger that matches those criteria:
export const findClosestRenderContainer = (args) => { const { el } = args; const { overflowY, position } = window.getComputedStyle(el); const isScrollable = overflowY?.includes("scroll") || overflowY?.includes("auto"); const isFixed = position === "fixed" || position === "sticky"; if (el === document.body) return document.body; if (isScrollable && el.scrollHeight > el.clientHeight) return el; if (isFixed) return el; return findClosestRenderContainer({ el: el.parentElement }); };
Another similar scenario involves using a flyout inside the shadow DOM, which is primarily related to how you style your application. Since the shadow DOM scopes the CSS within it, the content should also be rendered inside it. If your trigger is rendered inside the shadow DOM, you don't need to recursively go through each parent. You can simply access the shadow root directly:
export const getShadowRoot = (el) => { const rootNode = el.getRootNode(); return rootNode instanceof ShadowRoot ? rootNode : null; }; // in findClosestRenderContainer const shadowRoot = getShadowRoot(el); if (shadowRoot?.firstElementChild) { return shadowRoot.firstElementChild; }
Similarly, you'll need to replace all the flyout logic that uses document or document.body with logic that potentially uses getShadowRoot.
My thinking has always been about how component composition can complicate things and why building components with the entire system in mind is superior to shipping individual components.
One example of this, when building dropdowns, is how clicking outside the content can depend on other components. It's straightforward when you render it directly on the page, but consider using it inside another Modal component. If you click outside the content and the click target happens to be the Modal overlay, it would be frustrating for the user if both the dropdown and the modal close. For this reason, you can have such components connected with a global tracking of their state to ensure that clicking outside multiple components only closes the most recent one.
Similarly, you should consider moving the release function's focus management to a similar queue and prepare for various edge cases. For instance, if a DropdownMenu opens a Modal but closes itself, closing the Modal should return the focus to the DropdownMenu trigger.
The last edge case I want to explore is how developers sometimes need to combine multiple flyouts to work with the same trigger. For example, you might want to show a tooltip on hover and a menu on click, positioning both based on the same trigger coordinates. Since our implementation doesn't control the trigger rendering and only provides the attributes, we could offload the task of merging two refs to the user. However, handling this logic internally is one of the biggest advantages a design system can offer.
To achieve this, before creating a new ref for the trigger internally, we'll first check if the component is our Flyout with another Flyout and reuse the parent ref instead.
// useRef is not optional so we always call it const internalTriggerElRef = React.useRef<HTMLButtonElement>(null); const { elRef: parentTriggerRef } = useFlyoutTriggerContext() || {}; const triggerElRef = parentTriggerRef || internalTriggerElRef;
As we've seen, building a dropdown component from scratch can be a lengthy and complex process. I haven't covered all the edge cases I had to handle while building Reshaped and there might be more you find by yourself. So most of the time, I recommend using an existing component library to handle this for you. However, it's still important to understand what you should expect from a library when deciding on using one.
If you're looking for more high-level component examples, here are a few links to components from the Reshaped documentation:
I would also like to give a shout-out to:
Everything written here is based on my own experience, so in case you know better ways of solving some challenges or got any other feedback – drop me a message.