Features

#
  • Unstyled and lightweight (8kB) React menu components.
  • Unlimited levels of submenu
  • Supports dropdown, hover, and context menu
  • Supports radio and checkbox menu items
  • Flexible menu positioning
  • Comprehensive keyboard interactions
  • Customisable styling
  • Level 3 support of React 18 concurrent rendering
  • Supports server-side rendering
  • Implements WAI-ARIA menu pattern

Install

#

# with npm

npm install @szhsin/react-menu

# with Yarn

yarn add @szhsin/react-menu

Usage

#

Each of the following sections includes a live example. They are grouped into related categories.

#

Common usage examples of Menu, SubMenu, and MenuItem.

Basic menu

#

The most basic menu consists of several MenuItems wrapped in a Menu, and is controlled by a MenuButton.

import { Menu, MenuItem, MenuButton } from '@szhsin/react-menu';
import '@szhsin/react-menu/dist/index.css';
import '@szhsin/react-menu/dist/transitions/slide.css';

export default function Example() {
  return (
    <Menu menuButton={<MenuButton>Menu</MenuButton>} transition>
      <MenuItem>Cut</MenuItem>
      <MenuItem>Copy</MenuItem>
      <MenuItem>Paste</MenuItem>
    </Menu>
  );
}
#

SubMenu can be placed in a Menu and has its own MenuItems as children. You might also create nested submenus under a submenu.

<Menu menuButton={<MenuButton>Menu</MenuButton>}>
  <MenuItem>New File</MenuItem>
  <SubMenu label="Edit">
    <MenuItem>Cut</MenuItem>
    <MenuItem>Copy</MenuItem>
    <MenuItem>Paste</MenuItem>
    <SubMenu label="Find">
      <MenuItem>Find...</MenuItem>
      <MenuItem>Find Next</MenuItem>
      <MenuItem>Find Previous</MenuItem>
    </SubMenu>
  </SubMenu>
  <MenuItem>Print...</MenuItem>
</Menu>

The label prop of submenu accepts not only string type but any valid JSX. Thus, you can render images or icons in the label.

Event handling

#

When a menu item is activated, the onClick event fires on menu item. Unless the stopPropagation of event object is set true, the onItemClick of root menu component will fire afterwards. If the keepOpen of event object is set true, menu will be kept open after the menu item is clicked.

For details of the event object, please see MenuItem.

    <Menu
      menuButton={<MenuButton>Menu</MenuButton>}
      onItemClick={(e) => console.log(`[Menu] ${e.value} clicked`)}
    >
      <MenuItem value="Cut" onClick={(e) => console.log(`[MenuItem] ${e.value} clicked`)}>
        Cut
      </MenuItem>
    
      <MenuItem
        value="Copy"
        onClick={(e) => {
          console.log(`[MenuItem] ${e.value} clicked`);
          // Stop the `onItemClick` of root menu component from firing
          e.stopPropagation = true;
          // Keep the menu open after this menu item is clicked
          e.keepOpen = true;
        }}
      >
        Copy
      </MenuItem>
    
      <MenuItem value="Paste">Paste</MenuItem>
    </Menu>

    Radio group

    #

    You could make menu items behave like radio buttons by setting type="radio" and wrapping them in a MenuRadioGroup. The child menu item which has the same value (strict equality ===) as the radio group is marked as checked.

    Sample text
    const [textColor, setTextColor] = useState('red');
    
    <Menu menuButton={<MenuButton>Text color</MenuButton>}>
      <MenuRadioGroup
        value={textColor}
        onRadioChange={(e) => setTextColor(e.value)}
      >
        <MenuItem type="radio" value="red">
          Red
        </MenuItem>
        <MenuItem type="radio" value="green">
          Green
        </MenuItem>
        <MenuItem type="radio" value="blue">
          Blue
        </MenuItem>
      </MenuRadioGroup>
    </Menu>

    Checkbox

    #

    You could make menu items behave like checkboxes by setting type="checkbox".

    Sample text
    const [isBold, setBold] = useState(true);
    const [isItalic, setItalic] = useState(true);
    const [isUnderline, setUnderline] = useState(false);
    
    <Menu menuButton={<MenuButton>Text style</MenuButton>}>
      <MenuItem
        type="checkbox"
        checked={isBold}
        onClick={(e) => setBold(e.checked)}
      >
        Bold
      </MenuItem>
      <MenuItem
        type="checkbox"
        checked={isItalic}
        onClick={(e) => setItalic(e.checked)}
      >
        Italic
      </MenuItem>
      <MenuItem
        type="checkbox"
        checked={isUnderline}
        onClick={(e) => setUnderline(e.checked)}
      >
        Underline
      </MenuItem>
    </Menu>

    Header and divider

    #

    You could use MenuHeader and MenuDivider to group related menu items.

    <Menu menuButton={<MenuButton>Menu</MenuButton>}>
      <MenuItem>New File</MenuItem>
      <MenuItem>Save</MenuItem>
      <MenuItem>Close Window</MenuItem>
      <MenuDivider />
      <MenuHeader>Edit</MenuHeader>
      <MenuItem>Cut</MenuItem>
      <MenuItem>Copy</MenuItem>
      <MenuItem>Paste</MenuItem>
      <MenuDivider />
      <MenuItem>Print</MenuItem>
    </Menu>

    NOTE: you can render any valid JSX into menu children.

    Combined example

    #

    An example combines the usage of several components.

    Sample text
    #

    More examples with menu items.

    #

    MenuItem can be made a hyperlink by giving it a href prop. Even if it's a link, the onClick event still fires as normal. You could also disable a menu item using the disabled prop.

    <Menu menuButton={<MenuButton>Menu</MenuButton>}>
      <MenuItem href="https://www.google.com/">Google</MenuItem>
      <MenuItem
        href="https://github.com/szhsin/react-menu/"
        target="_blank"
        rel="noopener noreferrer"
      >
        GitHub <ExternalLinkIcon />
      </MenuItem>
      <MenuItem>Regular item</MenuItem>
      <MenuItem disabled>Disabled item</MenuItem>
    </Menu>

    NOTE: the href prop is meant to be a redirect which causes browser to reload the document at the URL specified. If you want to prevent the reload or work with React Router, please see this example.

    Icon and image

    #

    React-Menu doesn't include any imagery. However, you are free to use your own or third-party icons and images, as you could wrap anything in a MenuItem. This example uses Google's Material icons.

    <Menu menuButton={<MenuButton>Menu</MenuButton>}>
      <MenuItem href="https://github.com/szhsin/react-menu/">
        <img src="octocat.png" alt="octocat" role="presentation" />
        GitHub
      </MenuItem>
    
      <MenuDivider />
    
      <SubMenu
        label={
          <>
            <i className="material-icons">edit</i>Edit
          </>
        }
      >
        <MenuItem>
          <i className="material-icons">content_cut</i>Cut
        </MenuItem>
        <MenuItem>
          <i className="material-icons">content_copy</i>Copy
        </MenuItem>
        <MenuItem>
          <i className="material-icons">content_paste</i>Paste
        </MenuItem>
      </SubMenu>
    </Menu>

    Render prop

    #

    MenuItem manages some internal states one of which indicates whether the item is hovered. If you need to render dynamic contents in response to state updates, you can use children as a render prop and pass it a callback function.

    For more menu item states, please refer to MenuItem.

    <Menu menuButton={<MenuButton>Menu</MenuButton>}>
      <MenuItem>{({ hover }) => (hover ? 'Hovered!' : 'Hover me')}</MenuItem>
      <MenuDivider />
      <MenuItem style={{ justifyContent: 'center' }}>
        {({ hover }) => (
          <i className="material-icons md-48">
            {hover ? 'sentiment_very_satisfied' : 'sentiment_very_dissatisfied'}
          </i>
        )}
      </MenuItem>
    </Menu>

    The children of menu also supports render prop pattern. When a function is provided to a menu's children, it receives the menu's state and computed direction resulted from bounding box check.

    Focusable item

    #

    FocusableItem is a special menu item. It's used to wrap elements which are able to receive focus, such as input or button.

    It receives a render prop as children and passes down a ref and several other states. This example demonstrates how to use an input element to filter menu items.

    import { useState } from 'react';
    import { Menu, MenuItem, FocusableItem, MenuButton } from '@szhsin/react-menu';
    import '@szhsin/react-menu/dist/index.css';
    
    export default function Example() {
      const [filter, setFilter] = useState('');
    
      return (
        <Menu
          menuButton={<MenuButton>Menu</MenuButton>}
          onMenuChange={(e) => e.open && setFilter('')}
        >
          <FocusableItem>
            {({ ref }) => (
              <input
                ref={ref}
                type="text"
                placeholder="Type to filter"
                value={filter}
                onChange={(e) => setFilter(e.target.value)}
              />
            )}
          </FocusableItem>
          {['Apple', 'Banana', 'Blueberry', 'Cherry', 'Strawberry']
            .filter((fruit) =>
              fruit.toUpperCase().includes(filter.trim().toUpperCase())
            )
            .map((fruit) => (
              <MenuItem key={fruit}>{fruit}</MenuItem>
            ))}
        </Menu>
      );
    }
    #

    Control the display and position of menu related to menu button.

    #

    You can control the position of menu and how it behaves in response to window scroll event with the align, direction, position, and viewScroll props.

    Menu can be set to display an arrow pointing to its anchor element with the arrow prop. You can also adjust menu's position relating to its anchor using the gap and shift prop.

    Align with anchor
    Menu to anchor
    When window scrolls
    Menu position

    info Try to select different option combinations and scroll page up and down to see the behaviour.

    const [align, setAlign] = useState('center');
    const [position, setPosition] = useState('anchor');
    const [viewScroll, setViewScroll] = useState('auto');
    
    const menus = ['right', 'top', 'bottom', 'left'].map((direction) => (
      <Menu
        menuButton={<MenuButton>{direction}</MenuButton>}
        key={direction}
        direction={direction}
        align={align}
        position={position}
        viewScroll={viewScroll}
        arrow={hasArrow ? true : false}
        gap={hasGap ? 12 : 0}
        shift={hasShift ? 12 : 0}
      >
        {['Apple', 'Banana', 'Blueberry', 'Cherry', 'Strawberry'].map((fruit) => (
          <MenuItem key={fruit}>{fruit}</MenuItem>
        ))}
      </Menu>
    ))
    #

    When there isn't enough space for all menu items, you could use the overflow prop to make the menu list scrollable. The value of this prop is similar to the CSS overflow property.

    Setting the overflow prop could make a menu touch screen edges. If this is visually unpleasant, you may use boundingBoxPadding to add space around the menu.

    If you want to fix some items at the top or bottom, set setDownOverflow prop on Menu and takeOverflow prop on a MenuGroup which makes the group scrollable.

    Overflow
    Menu position
    import {
      Menu,
      MenuItem,
      MenuButton,
      FocusableItem,
      MenuGroup
    } from '@szhsin/react-menu';
    import '@szhsin/react-menu/dist/index.css';
    
    const [overflow, setOverflow] = useState('auto');
    const [position, setPosition] = useState('auto');
    const [filter, setFilter] = useState('');
    
    <Menu
      menuButton={<MenuButton>Overflow</MenuButton>}
      overflow={overflow}
      position={position}
    >
      {new Array(100).fill(0).map((_, i) => (
        <MenuItem key={i}>Item {i + 1}</MenuItem>
      ))}
    </Menu>
    
    <Menu
      menuButton={<MenuButton>Grouping</MenuButton>}
      overflow={overflow}
      setDownOverflow
      position={position}
      boundingBoxPadding="10"
      onMenuChange={(e) => e.open && setFilter('')}
    >
      <FocusableItem>
        {({ ref }) => (
          <input
            ref={ref}
            type="text"
            placeholder="Type a number"
            value={filter}
            onChange={(e) => setFilter(e.target.value)}
          />
        )}
      </FocusableItem>
      <MenuGroup takeOverflow>
        {new Array(100)
          .fill(0)
          .map((_, i) => `Item ${i + 1}`)
          .filter((item) => item.includes(filter.trim()))
          .map((item, i) => (
            <MenuItem key={i}>{item}</MenuItem>
          ))}
      </MenuGroup>
      <MenuItem>Last (fixed)</MenuItem>
    </Menu>

    A menu with overflowing items prevents arrow from displaying properly. To get around it, you can use a MenuGroup, please see a CodeSandbox example.

    Bounding box

    #

    By default, menu positions itself within its nearest ancestor element which a CSS overflow value other than visible, or the browser viewport when such an element is not present. Alternalively, you can use the portal prop to make menu visually “break out” of its scrollable container. Also, you can specify a container in the page as the bounding box for a menu using the boundingBoxRef prop. Menu will try to position itself within that container.

    const boundingBoxRef = useRef(null);
    const leftAnchor = useRef(null);
    const rightAnchor = useRef(null);
    const [{ state }, toggleMenu] = useMenuState();
    const [portal, setPortal] = useState(false);
    
    useEffect(() => {
        toggleMenu(true);
    }, [toggleMenu]);
    
    const tooltipProps = {
        state,
        captureFocus: false,
        arrow: true,
        role: 'tooltip',
        align: 'center',
        viewScroll: 'auto',
        position: 'anchor',
        boundingBoxPadding: '1 8 1 1'
    };
    
    <label>
      <input type="checkbox" checked={portal} 
        onChange={(e) => setPortal(e.target.checked)} />
      Render via portal
    </label>
    
    <div ref={boundingBoxRef} style={{ overflow: 'auto', position: 'relative' }}>
        <div ref={leftAnchor} />
        <ControlledMenu {...tooltipProps} portal={portal}
            anchorRef={leftAnchor} direction="top">
            I can flip over if you scroll this block
        </ControlledMenu>
    
        <div ref={rightAnchor} />
        {/* explicitly set bounding box with the boundingBoxRef prop */}
        <ControlledMenu {...tooltipProps} boundingBoxRef={boundingBoxRef}
            anchorRef={rightAnchor} direction="right">
            I'm a tooltip built with React-Menu
        </ControlledMenu>
    </div>

    NOTE: when there is an ancestor element which has a CSS overflow value other than visible above menu, please ensure at least one element between that overflow element and menu has a CSS position value other than static. For example, you could add position: relative to the ancestor element which has overflow: auto.

    TIP: you could render menu into a specified DOM node instead of document.body using the portal prop, please see a CodeSandbox example.

    #

    Customising the menu button.

    Render prop

    #

    If you need to dynamically render menu button based on menu state, the menuButton supports the render prop pattern.

    <Menu
      menuButton={({ open }) => <MenuButton>{open ? 'Close' : 'Open'}</MenuButton>}
    >
      <MenuItem>Cut</MenuItem>
      <MenuItem>Copy</MenuItem>
      <MenuItem>Paste</MenuItem>
    </Menu>

    Using any button

    #

    You can use a native button element with Menu, or use your own React button component which implements a forwarding ref and accepts onClick and onKeyDown event props.

    Menu also works well with popular React libraries, such as the Material-UI. See a CodeSandbox example.

    The benefit of using MenuButton from this package is it has WAI-ARIA compliant attributes.

    <Menu menuButton={<button type="button">Menu</button>}>
      <MenuItem>Cut</MenuItem>
      <MenuItem>Copy</MenuItem>
      <MenuItem>Paste</MenuItem>
    </Menu>

    Controlled menu

    #

    Get control of menu's open or close state with ControlledMenu.

    Controlling state

    #

    In some use cases you may need to access a menu's state and control how the menu is open or closed. This can be implemented using a ControlledMenu.

    You need to provide at least a state prop, and a ref of an element to which menu will be positioned. You also need to update state in response to the onClose event.

    You can optionally leverage a useClick hook which helps create a similar toggle menu experience to the Menu component.

    import { useRef, useState } from 'react';
    import { ControlledMenu, MenuItem, useClick } from '@szhsin/react-menu';
    import '@szhsin/react-menu/dist/index.css';
    
    export default function () {
      const ref = useRef(null);
      const [isOpen, setOpen] = useState(false);
      const anchorProps = useClick(isOpen, setOpen);
    
      return (
        <>
          <button type="button" ref={ref} {...anchorProps}>
            Menu
          </button>
    
          <ControlledMenu
            state={isOpen ? 'open' : 'closed'}
            anchorRef={ref}
            onClose={() => setOpen(false)}
          >
            <MenuItem>Cut</MenuItem>
            <MenuItem>Copy</MenuItem>
            <MenuItem>Paste</MenuItem>
          </ControlledMenu>
        </>
      );
    }

    useMenuState

    #

    useMenuState Hook works with ControlledMenu and help you manage the state transition/animation when menu opens and closes.

    Please see useMenuState for more details.

    import { useRef } from 'react';
    import { ControlledMenu, MenuItem, useClick, useMenuState } from '@szhsin/react-menu';
    import '@szhsin/react-menu/dist/index.css';
    import '@szhsin/react-menu/dist/transitions/slide.css';
    
    export default function () {
      const ref = useRef(null);
      const [menuState, toggleMenu] = useMenuState({ transition: true });
      const anchorProps = useClick(menuState.state, toggleMenu);
    
      return (
        <>
          <button type="button" ref={ref} {...anchorProps}>
            Menu
          </button>
    
          <ControlledMenu {...menuState} anchorRef={ref} onClose={() => toggleMenu(false)}>
            <MenuItem>Cut</MenuItem>
            <MenuItem>Copy</MenuItem>
            <MenuItem>Paste</MenuItem>
          </ControlledMenu>
        </>
      );
    }

    Hover menu

    #

    You can create a hover menu with the useHover hook and ControlledMenu.

    A hover menu created using the useHover hook can work on both desktop and touch screens. Keyboard navigation is still supported.

    Similar to the click menu, you can create menu state using useState (w/o transition), or useMenuState hook (with transition).

    Hover
    Hover with transition
    import { useRef, useState } from 'react';
    import { ControlledMenu, MenuItem, useHover, useMenuState } from '@szhsin/react-menu';
    import '@szhsin/react-menu/dist/index.css';
    import '@szhsin/react-menu/dist/transitions/slide.css';
    
    const HoverMenu = () => {
      const ref = useRef(null);
      const [isOpen, setOpen] = useState(false);
      const { anchorProps, hoverProps } = useHover(isOpen, setOpen);
    
      return (
        <>
          <div ref={ref} {...anchorProps}>
            Hover
          </div>
    
          <ControlledMenu
            {...hoverProps}
            state={isOpen ? 'open' : 'closed'}
            anchorRef={ref}
            onClose={() => setOpen(false)}
          >
            <MenuItem>Cut</MenuItem>
            <MenuItem>Copy</MenuItem>
            <MenuItem>Paste</MenuItem>
          </ControlledMenu>
        </>
      );
    };
    
    const HoverMenuWithTransition = () => {
      const ref = useRef(null);
      const [menuState, toggle] = useMenuState({ transition: true });
      const { anchorProps, hoverProps } = useHover(menuState.state, toggle);
    
      return (
        <>
          <div ref={ref} {...anchorProps}>
            Hover with transition
          </div>
    
          <ControlledMenu
            {...hoverProps}
            {...menuState}
            anchorRef={ref}
            onClose={() => toggle(false)}
          >
            <MenuItem>Cut</MenuItem>
            <MenuItem>Copy</MenuItem>
            <MenuItem>Paste</MenuItem>
          </ControlledMenu>
        </>
      );
    };

    Context menu

    #

    Context menu is implemented using a ControlledMenu.

    You need to provide an anchorPoint of viewport coordinates to which menu will be positioned.

    Right click to open context menu
    import { useState } from 'react';
    import { ControlledMenu, MenuItem } from '@szhsin/react-menu';
    import '@szhsin/react-menu/dist/index.css';
    
    export default function () {
      const [isOpen, setOpen] = useState(false);
      const [anchorPoint, setAnchorPoint] = useState({ x: 0, y: 0 });
    
      return (
        <div
          onContextMenu={(e) => {
            if (typeof document.hasFocus === 'function' && !document.hasFocus()) return;
            
            e.preventDefault();
            setAnchorPoint({ x: e.clientX, y: e.clientY });
            setOpen(true);
          }}
        >
          Right click to open context menu
          <ControlledMenu
            anchorPoint={anchorPoint}
            state={isOpen ? 'open' : 'closed'}
            direction="right"
            onClose={() => setOpen(false)}
          >
            <MenuItem>Cut</MenuItem>
            <MenuItem>Copy</MenuItem>
            <MenuItem>Paste</MenuItem>
          </ControlledMenu>
        </div>
      );
    }

    TIP: sometimes you may want to reuse one menu for both dropdown and context menu. In this case, you can provide ControlledMenu with both the anchorRef and anchorPoint props and dynamically switch between them, please see a CodeSandbox example.

    Styling

    #

    React-Menu is unopinionated when it comes to styling. It doesn't depend on any particular CSS-in-JS runtime and works with all flavours of front-end stack. Please checkout the respective CodeSandbox example below:

    All styles are locally scoped to the components except in the CSS/SASS example.

    You may import the @szhsin/react-menu/dist/core.css which contains minimal style and some reset. However, this is optional as you can define everything from scratch without importing any css files. There is a style-utils which helps write selectors for CSS-in-JS. You can find a complete list of CSS selectors in the styling guide.

    In addition, you can use *className props.

    className prop

    #

    You can provide components with CSS classes using the various *className props. Optionally, you may pass a function to the props and return different CSS class names under different component states.

    For more details about available states, please refer to the *className props under each component.

    // When using the functional form of className prop,
    // it's advisable to put it outside React component scope.
    const menuItemClassName = ({ hover }) =>
      hover ? 'my-menuitem-hover' : 'my-menuitem';
    
    <Menu menuButton={<MenuButton>Menu</MenuButton>} menuClassName="my-menu">
      <MenuItem>New File</MenuItem>
      <MenuItem>Save</MenuItem>
      <MenuItem className={menuItemClassName}>I'm special</MenuItem>
    </Menu>
    
    /* CSS file */
    .my-menu {
      border: 2px solid green;
    }
    
    .my-menuitem {
      color: blue;
      background-color: yellow;
    }
    
    .my-menuitem-hover {
      color: yellow;
      background-color: black;
    }