下拉菜单

菜单提供了一种简单的方式来构建自定义的、可访问的下拉组件,并提供强大的键盘导航支持。

要开始使用,请通过 npm 安装无状态 UI

npm install @headlessui/react

菜单使用 MenuMenuButtonMenuItemsMenuItem 组件构建。

import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'

function Example() {
  return (
    <Menu>
      <MenuButton>My account</MenuButton>
      <MenuItems anchor="bottom">
        <MenuItem>
          <a className="block data-[focus]:bg-blue-100" href="/settings">
            Settings
          </a>
        </MenuItem>
        <MenuItem>
          <a className="block data-[focus]:bg-blue-100" href="/support">
            Support
          </a>
        </MenuItem>
        <MenuItem>
          <a className="block data-[focus]:bg-blue-100" href="/license">
            License
          </a>
        </MenuItem>
      </MenuItems>
    </Menu>
  )
}

MenuButton 会在点击时自动打开和关闭 MenuItems,并且当菜单打开时,项目列表会获得焦点,并可以通过键盘进行导航。

无状态 UI 会跟踪每个组件的很多状态,例如,哪个菜单项当前通过键盘获得焦点,弹出框是打开还是关闭,或者哪个列表框选项当前被选中。

但是,因为组件是无状态的,并且在开箱即用时完全没有样式,所以你无法在 UI 中看到这些信息,除非你自己为每个状态提供你想要的样式。

为无状态 UI 组件的不同状态设置样式最简单的方法是使用每个组件公开的 data-* 属性。

例如,MenuButton 组件公开了 data-active 属性,它告诉你这是否当前打开了菜单,而 MenuItem 组件公开了 data-focus 属性,它告诉你这是否当前通过鼠标或键盘将菜单项设置为焦点。

<!-- Rendered `MenuButton`, `MenuItems`, and `MenuItem` -->
<button data-active>Options</button>
<div data-open>
  <a href="/settings">Settings</a>
  <a href="/support" data-focus>Support</a>
  <a href="/license">License</a>
</div>

使用 CSS 属性选择器 根据这些数据属性的存在有条件地应用样式。如果你使用的是 Tailwind CSS,那么 数据属性修饰符 可以简化操作。

import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'

const links = [
  { href: '/settings', label: 'Settings' },
  { href: '/support', label: 'Support' },
  { href: '/license', label: 'License' },
]

function Example() {
  return (
    <Menu>
<MenuButton className="data-[active]:bg-blue-200">My account</MenuButton>
<MenuItems anchor="bottom"> {links.map((link) => (
<MenuItem key={link.href} className="block data-[focus]:bg-blue-100">
<a href={link.href}>{link.label}</a> </MenuItem> ))} </MenuItems> </Menu> ) }

有关所有可用数据属性的列表,请参见 组件 API

每个组件还通过 渲染道具 公开其当前状态的信息,你可以使用它有条件地应用不同的样式或渲染不同的内容。

例如,MenuButton 组件公开了 active 状态,它告诉你这是否当前打开了菜单,而 MenuItem 组件公开了 focus 状态,它告诉你这是否当前通过鼠标或键盘将菜单项设置为焦点。

import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'
import clsx from 'clsx'
import { Fragment } from 'react'

const links = [
  { href: '/settings', label: 'Settings' },
  { href: '/support', label: 'Support' },
  { href: '/license', label: 'License' },
]

function Example() {
  return (
    <Menu>
<MenuButton as={Fragment}>
{({ active }) => <button className={clsx(active && 'bg-blue-200')}>My account</button>}
</MenuButton>
<MenuItems anchor="bottom"> {links.map((link) => (
<MenuItem key={link.href} as={Fragment}>
{({ focus }) => (
<a className={clsx('block', focus && 'bg-blue-100')} href={link.href}>
{link.label}
</a>
)}
</MenuItem>
))} </MenuItems> </Menu> ) }

有关所有可用渲染道具的列表,请参见 组件 API

除了链接之外,你还可以将按钮用于 MenuItem。当你想要触发一个动作,例如打开对话框或提交表单时,这很有用。

import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'

function Example() {
  function showSettingsDialog() {
    alert('Open settings dialog!')
  }

  return (
    <Menu>
      <MenuButton>My account</MenuButton>
      <MenuItems anchor="bottom">
<MenuItem>
<button onClick={showSettingsDialog} className="block w-full text-left data-[focus]:bg-blue-100">
Settings
</button>
</MenuItem>
<MenuItem> <a className="block data-[focus]:bg-blue-100" href="/support"> Support </a> </MenuItem> <MenuItem> <a className="block data-[focus]:bg-blue-100" href="/license"> License </a> </MenuItem>
<form action="/logout" method="post">
<MenuItem>
<button type="submit" className="block w-full text-left data-[focus]:bg-blue-100">
Sign out
</button>
</MenuItem>
</form>
</MenuItems> </Menu> ) }

使用 disabled 道具可以禁用 MenuItem 并阻止其被选中。

import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'

function Example() {
  return (
    <Menu>
      <MenuButton>My account</MenuButton>
      <MenuItems anchor="bottom">
        <MenuItem>
          <a className="block data-[focus]:bg-blue-100" href="/settings">
            Settings
          </a>
        </MenuItem>
        <MenuItem>
          <a className="block data-[focus]:bg-blue-100" href="/support">
            Support
          </a>
        </MenuItem>
        <MenuItem>
          <a className="block data-[focus]:bg-blue-100" href="/license">
            License
          </a>
        </MenuItem>
<MenuItem disabled>
<a className="block data-[disabled]:opacity-50" href="/invite-a-friend">
Invite a friend (coming soon!)
</a>
</MenuItem>
</MenuItems> </Menu> ) }

使用 MenuSeparator 组件可以在菜单中的项目之间添加视觉分隔符。

import { Menu, MenuButton, MenuItem, MenuItems, MenuSeparator } from '@headlessui/react'

function Example() {
  return (
    <Menu>
      <MenuButton>My account</MenuButton>
      <MenuItems anchor="bottom">
        <MenuItem>
          <a className="block data-[focus]:bg-blue-100" href="/settings">
            Settings
          </a>
        </MenuItem>
<MenuSeparator className="my-1 h-px bg-black" />
<MenuItem> <a className="block data-[focus]:bg-blue-100" href="/support"> Support </a> </MenuItem> <MenuItem> <a className="block data-[focus]:bg-blue-100" href="/license"> License </a> </MenuItem> </MenuItems> </Menu> ) }

使用 MenuSectionMenuHeadingMenuSeparator 组件可以将项目分组到带有标签的部分中。

import { Menu, MenuButton, MenuHeading, MenuItem, MenuItems, MenuSection, MenuSeparator } from '@headlessui/react'

function Example() {
  return (
    <Menu>
      <MenuButton>My account</MenuButton>
      <MenuItems anchor="bottom">
<MenuSection>
<MenuHeading className="text-sm opacity-50">Settings</MenuHeading>
<MenuItem> <a className="block data-[focus]:bg-blue-100" href="/profile"> My profile </a> </MenuItem> <MenuItem> <a className="block data-[focus]:bg-blue-100" href="/notifications"> Notifications </a> </MenuItem>
</MenuSection>
<MenuSeparator className="my-1 h-px bg-black" />
<MenuSection>
<MenuHeading className="text-sm opacity-50">Support</MenuHeading>
<MenuItem> <a className="block data-[focus]:bg-blue-100" href="/support"> Documentation </a> </MenuItem> <MenuItem> <a className="block data-[focus]:bg-blue-100" href="/license"> License </a> </MenuItem>
</MenuSection>
</MenuItems> </Menu> ) }

MenuItems 下拉默认情况下没有设置宽度,但你可以使用 CSS 添加宽度。

import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'

function Example() {
  return (
    <Menu>
      <MenuButton>My account</MenuButton>
<MenuItems anchor="bottom" className="w-52">
<MenuItem> <a className="block data-[focus]:bg-blue-100" href="/settings"> Settings </a> </MenuItem> <MenuItem> <a className="block data-[focus]:bg-blue-100" href="/support"> Support </a> </MenuItem> <MenuItem> <a className="block data-[focus]:bg-blue-100" href="/license"> License </a> </MenuItem> </MenuItems> </Menu> ) }

如果你希望下拉宽度与 MenuButton 宽度匹配,请使用 MenuItems 元素上公开的 --button-width CSS 变量。

import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'

function Example() {
  return (
    <Menu>
      <MenuButton>My account</MenuButton>
<MenuItems anchor="bottom" className="w-[var(--button-width)]">
<MenuItem> <a className="block data-[focus]:bg-blue-100" href="/settings"> Settings </a> </MenuItem> <MenuItem> <a className="block data-[focus]:bg-blue-100" href="/support"> Support </a> </MenuItem> <MenuItem> <a className="block data-[focus]:bg-blue-100" href="/license"> License </a> </MenuItem> </MenuItems> </Menu> ) }

MenuItems 添加 anchor 道具可以自动将下拉菜单定位到 MenuButton 的相对位置。

import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'

function Example() {
  return (
    <Menu>
      <MenuButton>My account</MenuButton>
<MenuItems anchor="bottom start">
<MenuItem> <a className="block data-[focus]:bg-blue-100" href="/settings"> Settings </a> </MenuItem> <MenuItem> <a className="block data-[focus]:bg-blue-100" href="/support"> Support </a> </MenuItem> <MenuItem> <a className="block data-[focus]:bg-blue-100" href="/license"> License </a> </MenuItem> </MenuItems> </Menu> ) }

使用 toprightbottomleft 值可以将下拉菜单沿着相应的边缘居中,或者将其与 startend 结合使用,以将下拉菜单对齐到特定角落,例如 top startbottom end

要控制按钮和下拉菜单之间的间隙,请使用 --anchor-gap CSS 变量。

import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'

function Example() {
  return (
    <Menu>
      <MenuButton>My account</MenuButton>
<MenuItems anchor="bottom start" className="[--anchor-gap:4px] sm:[--anchor-gap:8px]">
<MenuItem> <a className="block data-[focus]:bg-blue-100" href="/settings"> Settings </a> </MenuItem> <MenuItem> <a className="block data-[focus]:bg-blue-100" href="/support"> Support </a> </MenuItem> <MenuItem> <a className="block data-[focus]:bg-blue-100" href="/license"> License </a> </MenuItem> </MenuItems> </Menu> ) }

此外,你可以使用 --anchor-offset 控制下拉菜单应该从其原始位置偏移的距离,以及使用 --anchor-padding 控制下拉菜单和视窗之间应该存在的最小空间。

anchor 道具还支持对象 API,允许你使用 JavaScript 控制 gapoffsetpadding 值。

import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'

function Example() {
  return (
    <Menu>
      <MenuButton>My account</MenuButton>
<MenuItems anchor={{ to: 'bottom start', gap: '4px' }}>
<MenuItem> <a className="block data-[focus]:bg-blue-100" href="/settings"> Settings </a> </MenuItem> <MenuItem> <a className="block data-[focus]:bg-blue-100" href="/support"> Support </a> </MenuItem> <MenuItem> <a className="block data-[focus]:bg-blue-100" href="/license"> License </a> </MenuItem> </MenuItems> </Menu> ) }

有关这些选项的更多信息,请参见 MenuItems API

要为下拉菜单的打开和关闭添加动画,请向 MenuItems 组件添加 transition 道具,然后使用 CSS 为过渡的不同阶段设置样式。

import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'

function Example() {
  return (
    <Menu>
      <MenuButton>My account</MenuButton>
      <MenuItems
        anchor="bottom"
transition
className="origin-top transition duration-200 ease-out data-[closed]:scale-95 data-[closed]:opacity-0"
>
<MenuItem> <a className="block data-[focus]:bg-blue-100" href="/settings"> Settings </a> </MenuItem> <MenuItem> <a className="block data-[focus]:bg-blue-100" href="/support"> Support </a> </MenuItem> <MenuItem> <a className="block data-[focus]:bg-blue-100" href="/license"> License </a> </MenuItem> </MenuItems> </Menu> ) }

在内部,transition 道具的实现方式与 Transition 组件完全相同。有关更多信息,请参见 过渡文档

无状态 UI 还与 React 生态系统中的其他动画库(例如 Framer MotionReact Spring)很好地组合在一起。你只需要向这些库公开一些状态。

例如,要使用 Framer Motion 为菜单设置动画,请向 MenuItems 组件添加 static 道具,然后根据 open 渲染道具有条件地渲染它。

import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'
import { AnimatePresence, motion } from 'framer-motion'

function Example() {
  return (
    <Menu>
{({ open }) => (
<> <MenuButton>My account</MenuButton> <AnimatePresence>
{open && (
<MenuItems
static
as={motion.div} initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }} anchor="bottom" className="origin-top" >
<MenuItem> <a className="block data-[focus]:bg-blue-100" href="/settings"> Settings </a> </MenuItem> <MenuItem> <a className="block data-[focus]:bg-blue-100" href="/support"> Support </a> </MenuItem> <MenuItem> <a className="block data-[focus]:bg-blue-100" href="/license"> License </a> </MenuItem> </MenuItems>
)}
</AnimatePresence> </>
)}
</Menu> ) }

默认情况下,Menu 会在点击 MenuItem 时关闭。但是,一些第三方 Link 组件使用 event.preventDefault(),这会阻止菜单在点击时关闭。

在这些情况下,你可以使用 MenuMenuItem 组件上都可用的 close 渲染道具以命令式地关闭菜单。

import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'
import { MyCustomLink } from './MyCustomLink'

function Example() {
  return (
    <Menu>
      <MenuButton>Terms</MenuButton>
      <MenuItems anchor="bottom">
        <MenuItem>
{({ close }) => (
<MyCustomLink href="/" onClick={close}>
Read and accept </MyCustomLink>
)}
</MenuItem> </MenuItems> </Menu> ) }

默认情况下,Menu 及其子组件都会渲染一个适合该组件的默认元素。

例如,MenuButton 默认情况下会渲染一个 button,而 MenuItems 会渲染一个 div。相比之下,MenuMenuItem 不会渲染元素,而是默认情况下直接渲染其子级。

使用 as 道具可以将组件渲染为不同的元素或你自己的自定义组件,确保你的自定义组件 转发 ref,以便无状态 UI 可以正确地连接所有内容。

import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'
import { forwardRef } from 'react'

let MyCustomButton = forwardRef(function (props, ref) {
return <button className="..." ref={ref} {...props} />
})
function Example() { return ( <Menu>
<MenuButton as={MyCustomButton}>My account</MenuButton>
<MenuItems anchor="bottom" as="section">
<MenuItem as="a" className="block data-[focus]:bg-blue-100" href="/settings">
Settings </MenuItem>
<MenuItem as="a" className="block data-[focus]:bg-blue-100" href="/support">
Support </MenuItem>
<MenuItem as="a" className="block data-[focus]:bg-blue-100" href="/license">
License </MenuItem> </MenuItems> </Menu> ) }

要告诉元素直接渲染其子级,而没有包装元素,请使用 as={Fragment}

import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'
import { Fragment } from 'react'

function Example() {
  return (
    <Menu>
<MenuButton as={Fragment}>
<button>My account</button>
</MenuButton>
<MenuItems anchor="bottom"> <MenuItem> <a className="block data-[focus]:bg-blue-100" href="/settings"> Settings </a> </MenuItem> <MenuItem> <a className="block data-[focus]:bg-blue-100" href="/support"> Support </a> </MenuItem> <MenuItem> <a className="block data-[focus]:bg-blue-100" href="/license"> License </a> </MenuItem> </MenuItems> </Menu> ) }

如果你在 MenuItem 中使用交互式元素,例如 <a> 标签,这很重要。如果 MenuItem 有一个 as="div",那么无状态 UI 提供的道具将转发到 div 而不是 a,这意味着你无法再通过键盘转到 <a> 标签提供的 URL 了。

在 Next.js v13 之前,Link 组件不会将未知道具转发到底层的 a 元素,从而阻止菜单在用在 MenuItem 中时在点击时关闭。

如果你使用的是 Next.js v12 或更早版本,可以通过创建一个包装 Link 组件并转发未知属性到子 a 元素来解决这个问题。

import { forwardRef } from 'react'
import Link from 'next/link'
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/react'

const MyLink = forwardRef((props, ref) => {
let { href, children, ...rest } = props
return (
<Link href={href}>
<a ref={ref} {...rest}>
{children}
</a>
</Link>
)
})
function Example() { return ( <Menu> <MenuButton>My account</MenuButton> <MenuItems anchor="bottom"> <MenuItem>
<MyLink href="/settings">Settings</MyLink>
</MenuItem> </MenuItems> </Menu> ) }

这将确保 Headless UI 需要添加到 a 元素的所有事件监听器都被正确应用。

Next.js v13 中更改了此行为,因此不再需要此解决方法。

命令描述

EnterSpaceMenuButton 获得焦点时

打开菜单并聚焦第一个未禁用的项目

向下箭头向上箭头MenuButton 获得焦点时

打开菜单并聚焦第一个/最后一个未禁用的项目

Esc当菜单打开时

关闭任何打开的菜单

向下箭头向上箭头当菜单打开时

聚焦上一个/下一个未禁用的项目

HomePageUp当菜单打开时

聚焦第一个未禁用的项目

EndPageDown当菜单打开时

聚焦最后一个未禁用的项目

EnterSpace当菜单打开时

激活/点击当前菜单项

A–Za–z当菜单打开时

聚焦第一个匹配键盘输入的项目

属性默认值描述
asFragment
字符串 | 组件

菜单应该呈现为的元素或组件。菜单

数据属性渲染道具描述
data-openopen

布尔值

菜单是否打开。菜单

close

() => void

关闭菜单并重新聚焦 MenuButton

属性默认值描述
asbutton
字符串 | 组件

菜单应该呈现为的元素或组件。菜单按钮

disabledfalse
布尔值

菜单是否打开。菜单按钮是否禁用.

数据属性渲染道具描述
data-openopen

布尔值

菜单是否打开。菜单

data-focusfocus

布尔值

菜单是否打开。菜单按钮是否获得焦点。

data-hoverhover

布尔值

菜单是否打开。菜单按钮是否悬停。

data-activeactive

布尔值

菜单是否打开。菜单按钮是否处于活动或按下状态。

data-autofocusautofocus

布尔值

autoFocus 属性是否设置为 true

属性默认值描述
asdiv
字符串 | 组件

菜单应该呈现为的元素或组件。菜单项

transitionfalse
布尔值

元素是否应该渲染过渡属性,例如 data-closed data-enterdata-leave

anchor
对象

配置下拉菜单如何锚定到按钮。

anchor.tobottom
字符串

下拉菜单相对于触发器的位置。菜单项使用 toprightbottomleft 值将下拉菜单沿相应边缘居中,或与 startend 组合以将下拉菜单对齐到特定角落,例如 top startbottom end

菜单项菜单项

anchor.gap0
数字 | 字符串

下拉菜单和触发器之间的间距。菜单按钮菜单项.

也可以使用 --anchor-gap CSS 变量控制。

anchor.offset0
数字 | 字符串

下拉菜单从其原始位置偏移的距离。菜单项

也可以使用 --anchor-offset CSS 变量控制。

anchor.padding0
数字 | 字符串

下拉菜单和视窗之间的最小间距。菜单项

也可以使用 --anchor-padding CSS 变量控制。

staticfalse
布尔值

元素是否应该忽略内部管理的打开/关闭状态。

unmounttrue
布尔值

元素是否应该根据打开/关闭状态卸载或隐藏。

portalfalse
布尔值

元素是否应该在门户中渲染。

当设置 anchor 属性时,自动设置为 true

modaltrue
布尔值

是否启用无障碍功能,例如滚动锁定、焦点捕获,以及使其他元素 inert.

数据属性渲染道具描述
data-openopen

布尔值

菜单是否打开。菜单

属性默认值描述
asFragment
字符串 | 组件

菜单应该呈现为的元素或组件。菜单项

disabledfalse
布尔值

菜单是否打开。菜单项是否禁用用于键盘导航和 ARIA 目的.

数据属性渲染道具描述
data-disableddisabled

布尔值

菜单是否打开。菜单项是否禁用。

data-focusfocus

布尔值

菜单是否打开。菜单项是否获得焦点。

close

() => void

关闭菜单并重新聚焦 MenuButton

MenuItem 组件列表划分为带有适当无障碍语义的部分。

属性默认值描述
asdiv
字符串 | 组件

菜单应该呈现为的元素或组件。菜单部分

MenuSection 添加无障碍标签。

属性默认值描述
asheader
字符串 | 组件

菜单应该呈现为的元素或组件。菜单标题

使用适当的无障碍语义将两个 MenuSection 组件分开。

属性默认值描述
asdiv
字符串 | 组件

菜单应该呈现为的元素或组件。菜单分隔符

如果你对使用 Headless UI 的预先设计的 Tailwind CSS 下拉菜单组件示例 感兴趣,请查看 **Tailwind UI** — 我们精心设计和制作的精美组件集合。

这是支持我们参与此类开源项目工作的好方法,并且使我们能够改进这些项目并保持其良好维护。