Creating a select menu that is both responsive and accessible can be really hard. The menu itself may look nice on a desktop where there's plenty of space, but unfortunately most mobile devices lack the space to show the menu properly. For that reason some people believe it's best to avoid the idea of a menu popping up all together, or at least create separate designs for both mobile and desktop. While this is a legit solution, it introduces the burden of having to maintain two designs.
Another approach is to create an adaptive select menu. What I mean with adaptive in this case, is a single select-menu that looks and behaves differently based on the context it is used in. So instead of creating two different components, you'll end up with one component that implements different contexts (desktop / mobile in our case).
In this post I'd like to show you how to build a component like that. This is a preview of what we're about to build:
(tip: open the sandbox in a separate tab and resize the screen)
What do want to build?
So, we want to build an accessible select menu that works great on both desktop and mobile. Our select menu has two key components at play:
- a trigger - a button in our example
- a layer - the menu in our example
Let's describe how we want our component to look and behave:
Desktop and Mobile
- We want a component that takes a list of options
- We want a component that notifies us when an option was selected
- We want to tell the component which option is currently selected
- Our component should work on screen-readers
- We should interact with the component by only using the keyboard
- We want to close the menu when an option was selected or when the menu looses focus ('Escape' key / mouse-click elsewhere in the document)
Desktop
- The menu should be 'tied' to the button.
- Ideally, we want to position the menu on the left side of the button, and reposition it when there's not enough space left (when to user scrolls the page for instance).
- We want a smooth fade transition
Mobile
- The menu should be 'tied' to the bottom of the screen
- The menu should have the full width of the screen
- The menu should close when the trigger button is getting 'off-screen'
- We want a smooth slide transition
That is quite a list! Fortunately there are two libraries which will do a lot of hard work for us :)
Tools
In order to build this select menu, we're going to use two libraries:
downshift, a set of tools which help you to make accessible autocomplete / select / dropdown experiences. Basically, downshift takes care of things like keyboard navigation and aria-props, and serves you a bundle of props for you to place on the relevant elements (trigger / menu / menu-item / etc )
react-laag, a set of tools which takes care of positioning your layers, like tooltips and menu's. You could see react-laag as the React version of Popper.js + a couple of extra tools. You will see that both libraries complement each other really well. (disclaimer: I'm the author of react-laag)
Let's get started!
Ok, let's start off by defining how we would like to use the component:
function Example() {
const [selectedItem, setSelectedItem] = React.useState(null);
return (
<SelectMenu
items={["My Profile", "Settings", "Billing", "Notifications", "Logout"]}
selectedItem={selectedItem}
onSelect={setSelectedItem}
/>
);
}
Next, we should create the actual <SelectMenu />
:
function SelectMenu({ items, selectedItem, onSelect }) {
return null;
}
Toggleable layers
We don't want to show the menu (layer) right away. Instead, we want to show the menu when, when the user toggles it with help of the trigger-element (Button is our case). react-laag provides a <ToggleLayer />
component for this, since this pattern is so common:
import * as React from 'react';
import { ToggleLayer } from 'react-laag';
function SelectMenu({ items, selectedItem, onSelect }) {
return (
<ToggleLayer
// we'll add this in a minute
isOpen={false}
// render our menu
renderLayer={({ isOpen, layerProps }) => {
// don't render if the menu isn't open
if (!isOpen) {
return null;
}
return (
<DesktopMenu {...layerProps}>
{items.map((option) => (
<DesktopMenuItem key={option}>
{option}
</DesktopMenuItem>
))}
</DesktopMenu>
);
}}
// provide placement configuration
placement={{
// ideally, we want the menu on the left side of the button
anchor: "LEFT_CENTER",
// we want to reposition the menu when the menu doesn't
// fit the screen anymore
autoAdjust: true,
// we want some spacing between the menu and the button
triggerOffset: 12,
// we want some spacing between the menu and the screen
scrollOffset: 16
}}
>
{({ isOpen, triggerRef }) => (
<Button ref={triggerRef}>{isOpen ? "Hide" : "Show"}</Button>
)}
</ToggleLayer>
);
}
Basically, we're rendering the <Button />
inside of children
, and our menu inside of the renderLayer
prop. We also provide some configuration regarding positioning inside the placement
prop.
You may have noticed the
<Button />
and<Menu />
components. These are just presentational components, and contain no additional logic.
Detecting the viewport size
We want to style the menu differenly based in the viewport size of the user. Luckily, react-laag has a tool for that: useBreakpoint()
import { ToggleLayer, useBreakpoint } from "react-laag";
function SelectMenu({ items, selectedItem, onSelect }) {
// detect whether we are on a mobile device
const isMobile = useBreakpoint(480);
return (
<ToggleLayer
isOpen={false}
renderLayer={({ isOpen, layerProps }) => {
if (!isOpen) {
return null;
}
// Assign the right components based on `isMobile`
const Menu = isMobile ? MobileMenu : DesktopMenu;
const MenuItem = isMobile ? MobileMenuItem : DesktopMenuItem;
// Ignore `layerProps.style` on mobile, because
// we want it to be positioned `fixed` on the bottom
// of the screen
const style = isMobile ? {} : layerProps.style;
return (
<Menu ref={layerProps.ref} style={style}>
{items.map(option => (
<MenuItem key={option}>{option}</MenuItem>
))}
</Menu>
);
}}
// rest of props skipped for brevity...
/>
);
}
Adding some logic
Now that the essential components are in the correct place, we should add some logic. When should we show the menu? What happens when an user selects an option? etc...
This is where downshift comes in! We're going to use downshift's useSelect
:
import * as React from "react";
import { ToggleLayer, useBreakpoint } from "react-laag";
import { useSelect } from 'downshift';
function SelectMenu({ items, selectedItem, onSelect }) {
// detect whether we are on a mobile device
const isMobile = useBreakpoint(480);
const {
// tells us whether we should show the layer
isOpen,
// a couple of prop-getters which provides us
// with props that we should inject into our
// components
getToggleButtonProps,
getMenuProps,
getItemProps,
// which item is currently hightlighted?
highlightedIndex,
// action which sets `isOpen` to false
closeMenu
} = useSelect({
// pass in the props we defined earlier...
items,
selectedItem,
onSelectedItemChange: ({ selectedItem }) => {
if (selectedItem !== undefined) {
onSelect(selectedItem);
}
}
});
return (
<ToggleLayer
// we now know when the menu is open / closed :)
isOpen={isOpen}
renderLayer={({ isOpen, layerProps }) => {
if (!isOpen) {
return null;
}
// Assign the right components based on `isMobile`
const Menu = isMobile ? MobileMenu : DesktopMenu;
const MenuItem = isMobile ? MobileMenuItem : DesktopMenuItem;
// Ignore `layerProps.style` on mobile, because
// we want it to be positioned `fixed` on the bottom
// of the screen
const style = isMobile ? {} : layerProps.style;
return (
<Menu
// inject downshift's props and 'merge' them
// with our `layerProps.ref`
{...getMenuProps({ ref: layerProps.ref })}
style={style}
>
{items.map((item, index) => (
<MenuItem
style={
highlightedIndex === index
? { backgroundColor: "#eaf3f9" }
: {}
}
key={item}
// inject downshift's props
{...getItemProps({ item, index })}
>
{item}
</MenuItem>
))}
</Menu>
);
}}
// rest of props skipped for brevity...
>
{({ isOpen, triggerRef }) => (
<Button
// inject downshift's props and 'merge' them
// with our `triggerRef`
{...getToggleButtonProps({ ref: triggerRef })}
>
{isOpen ? "Hide" : "Show"}
</Button>
)}
</ToggleLayer>
);
}
Adding an arrow for desktop
It's pretty common for a menu on desktop to place a small arrow on the menu that points to the trigger element. react-laag provides us a small utility component for just that. Let's implement it:
import { ToggleLayer, useBreakpoint, Arrow } from "react-laag";
<ToggleLayer
renderLayer={({
isOpen,
layerProps,
// determines on which side the menu currently is
layerSide,
// the style we should pass to the <Arrow /> component
arrowStyle
}) => {
if (!isOpen) {
return null;
}
const Menu = isMobile ? MobileMenu : DesktopMenu;
const MenuItem = isMobile ? MobileMenuItem : DesktopMenuItem;
const style = isMobile ? {} : layerProps.style;
return (
<Menu
{...getMenuProps({ ref: layerProps.ref })}
style={style}
>
{!isMobile && (
// only render the arrow when on desktop
<Arrow
backgroundColor="white"
borderWidth={1}
borderColor={"#your-border-color"}
style={arrowStyle}
layerSide={layerSide}
/>
)}
{items.map((item, index) => (
<MenuItem
style={
highlightedIndex === index ? { backgroundColor: "#eaf3f9" } : {}
}
key={item}
{...getItemProps({ item, index })}
>
{item}
</MenuItem>
))}
</Menu>
);
}}
// rest of props skipped for brevity...
/>
Adding transitions
It's entirely up to you how to implement the transitions. You could use a library like react-spring or framer-motion for example. To keep things simple we are gonna use plain css-transitions and a little utility component from react-laag: <Transition />
.
import { ToggleLayer, useBreakpoint, Arrow, Transition } from "react-laag";
<ToggleLayer
renderLayer={({ isOpen, layerProps, layerSide, arrowStyle }) => {
const Menu = isMobile ? MobileMenu : DesktopMenu;
const MenuItem = isMobile ? MobileMenuItem : DesktopMenuItem;
// Wrap our <Menu /> component in <Transition />
// Apply styles / transitions based on:
// - isOpen
// - isMobile
return (
<Transition isOpen={isOpen}>
{(isOpen, onTransitionEnd) => (
<Menu
{...getMenuProps({ ref: layerProps.ref })}
// Inform <Transition /> that a transition has ended
onTransitionEnd={onTransitionEnd}
style={
isMobile
? {
transform: `translateY(${isOpen ? 0 : 100}%)`,
transition: "transform 0.2s"
}
: {
...layerProps.style,
opacity: isOpen ? 1 : 0,
transition: "opacity 0.2s"
}
}
>
{!isMobile && (
<Arrow
backgroundColor="white"
borderWidth={1}
borderColor={"#your-border-color"}
style={arrowStyle}
layerSide={layerSide}
/>
)}
{items.map((item, index) => (
<MenuItem
style={
highlightedIndex === index
? { backgroundColor: "#eaf3f9" }
: {}
}
key={item}
{...getItemProps({ item, index })}
>
{item}
</MenuItem>
))}
</Menu>
)}
</Transition>
);
}}
// rest of props skipped for brevity...
/>;
Close the menu when the button leaves the screen
Downshift already detects in various ways when the menu should be closed. There is, however, one thing that's missing, and that is when the user starts scrolling on mobile. By scrolling the button offscreen, is very well could be the user's intention to close the menu and move on. Fortunately there's an relatively easy way to detect this:
function Select({ selectedItem, onSelect, items }) {
const {
isOpen,
getToggleButtonProps,
getMenuProps,
highlightedIndex,
getItemProps,
// this one's important
closeMenu
} = useSelect({
items,
selectedItem,
onSelectedItemChange: ({ selectedItem }) => {
if (selectedItem !== undefined) {
onSelect(selectedItem);
}
}
});
return (
<ToggleLayer
isOpen={isOpen}
renderLayer={}
// we want out menu to behave as a
// 'fixed'-styled layer on mobile
fixed={isMobile}
// when the button disappears (offscreen),
// close the menu on mobile
onDisappear={() => {
if (isMobile) {
closeMenu();
}
}}
/>
);
}
Conclusion
I wanted to show you an example of how you could create an accessible select menu that works well on both desktop and mobile, with help of tools like downshift and react-laag. As you might have noticed, we didn't have to do any calculations or manual event-handling. All we did was connecting the right components together, and describe how we wanted certain things to behave. We also didn't really cover styling, because that's not where this post is about. The cool thing though, is that you could style this example however you like!
Check out the sandbox for the entire code if your interested.
For more information about downshift, check out their excellent docs.
Please visit react-laag's website for more information and use-cases, or star it on github ✨
Thanks for reading!
Top comments (2)
Hi Erik, Thanks for this read! great example. I tried to use this and found one issue – since the Menu is rendered in DOM not in root and right before body end the autofocus causes a automatic scroll to the end of my view (although the menu is in the header). I hope I did describe this clearly.
any ideas how to prevent this? thanks.
Add prop "container" (node element) to ToggleLayer.