🚧
此组件正在施工中....
自动安装
使用以下命令之一自动安装组件,根据你的包管理器选择:
npx tofu-ui-cli@latest add header
手动安装 (可选)
注意
如果你使用手动安装,将无法获取更新
如果你需要手动安装,请在项目目录中创建如下路径文件:
components/ui/tofu/header.tsx然后复制此代码:
"use client"
/**
* TofuUI Header 头部导航组件
* @author shuakami
* @version 1.9.2
* @copyright ByteFreeze&TofuUI
*/
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { SearchIcon, MenuIcon } from './svg';
// @ts-ignore
import { motion, AnimatePresence } from 'framer-motion';
import Search from './search';
// @ts-ignore
import { debounce } from 'lodash';
interface HeaderProps {
logo: React.ReactNode;
onClose?: () => void;
menuItems: Array<{ label: string; url?: string; subMenus?: Array<{ label: string; items: Array<{ label: string; url: string }> }> }>;
}
const Header: React.FC<HeaderProps> = ({ logo, menuItems }) => {
const [isVisible, setIsVisible] = useState(true);
const [lastScrollY, setLastScrollY] = useState(0);
const [activeSubMenu, setActiveSubMenu] = useState<number | null>(null);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [isTofuActive, setIsTofuActive] = useState(false);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [prevActiveSubMenu, setPrevActiveSubMenu] = useState<number | null>(null);
const handleSubMenuOpen = debounce((index: React.SetStateAction<number | null>) => {
setPrevActiveSubMenu(activeSubMenu);
setActiveSubMenu(index);
setIsTofuActive(true);
setIsSearchOpen(false);
}, 200);
const handleSubMenuClose = debounce(() => {
setActiveSubMenu(null);
setIsTofuActive(false);
}, 200);
const handleMobileMenuToggle = () => {
setIsMobileMenuOpen(!isMobileMenuOpen);
setIsSearchOpen(false);
};
useEffect(() => {
const handleScroll = () => {
const currentScrollY = window.scrollY;
setIsVisible(currentScrollY < lastScrollY || currentScrollY < 200);
setLastScrollY(currentScrollY);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [lastScrollY]);
const toggleMobileMenu = () => {
setIsMobileMenuOpen(!isMobileMenuOpen);
setIsTofuActive(!isMobileMenuOpen);
};
const handleCloseSearch = () => {
setIsSearchOpen(false);
};
const headerVariants = {
visible: { opacity: 1, y: 0 },
hidden: { opacity: 0, y: -100 },
};
useEffect(() => {
const tofuContainer = document.querySelector('.tofu-container');
if (tofuContainer) {
if (isTofuActive || activeSubMenu !== null) {
tofuContainer.classList.add('home-tofu');
} else {
tofuContainer.classList.remove('home-tofu');
}
}
}, [isTofuActive, activeSubMenu]);
return (
<AnimatePresence>
<motion.header
variants={headerVariants}
initial="visible"
animate={isVisible ? 'visible' : 'hidden'}
exit="hidden"
transition={{ duration: 0.3, ease: 'easeInOut' }}
className={`w-full flex items-center min-h-[3rem] justify-between fixed top-0 left-0 right-0 z-20 px-4 py-4 md:px-8`}
>
<motion.div
className="absolute inset-0 bg-tofu-light-bg/80 dark:bg-tofu-dark-bg/50 backdrop-blur-xl"
initial={{ opacity: 0, backdropFilter: 'blur(0px)' }}
animate={activeSubMenu !== null || isMobileMenuOpen || isSearchOpen ? 'hidden' : 'visible'}
variants={{
visible: { opacity: 1, backdropFilter: 'blur(12px)' },
hidden: { opacity: 0, backdropFilter: 'blur(0px)' },
}}
transition={{ duration: 0.3, ease: 'easeInOut' }}
/>
<div className="max-w-7xl mx-auto w-full flex items-center justify-between">
<Link href="/" aria-label="TofuUI Home"
className="transition ease-in-out duration-250 scale-85 origin-left">
{logo}
</Link>
<div className="flex items-center justify-center gap-5 md:hidden scale-85 origin-right">
<button aria-label="Open Search"
className="group relative w-5 h-5 transition-opacity duration-300 ease-in-out focus:outline-none">
<SearchIcon/>
</button>
<button
className="w-5 h-5 flex flex-col items-center justify-center gap-1 z-30"
aria-controls="main-menu"
aria-label="Open Menu"
onClick={toggleMobileMenu}
>
<MenuIcon/>
</button>
</div>
{isSearchOpen && (
<Search onClose={handleCloseSearch}/>
)}
<AnimatePresence>
{isMobileMenuOpen && (
<motion.div className="fixed inset-0 dark:bg-tofu-dark-bg/70 transition-bg duration-500">
<motion.div
className="-z-10 h-screen "
initial={{backdropFilter: 'blur(0px)'}}
animate={{
backdropFilter: 'blur(24px)',
transition: {
duration: 0.6,
ease: [0.83, 0, 0.17, 1],
},
}}
exit={{
backdropFilter: 'blur(0px)',
transition: {
duration: 0.6,
ease: [0.83, 0, 0.17, 1],
},
}}
style={{willChange: 'backdrop-filter'}}
>
<motion.div
initial={{opacity: 0}}
animate={{
opacity: 1,
transition: {
duration: 0.6,
ease: [0.83, 0, 0.17, 1],
},
}}
exit={{
opacity: 0,
transition: {
duration: 0.4,
ease: [0.83, 0, 0.17, 1],
},
}}
>
<div className="flex items-center justify-between px-6 py-4">
<Link href="/" aria-label="TofuUI Home"
className="transition duration-250 ease-in-out">
{logo}
</Link>
<button
className="w-5 h-5 flex flex-col items-center justify-center gap-1"
aria-label="Close Menu"
onClick={toggleMobileMenu}
>
<MenuIcon/>
</button>
</div>
<ul className="flex flex-col w-full max-w-container mx-auto pt-4 pb-6 text-lg"
id="main-menu">
{menuItems.map((item, index) => (
<li key={index} className="group last:pb-10">
<button
className="py-2 w-full flex items-center justify-between px-6 text-gray-800 dark:text-white"
aria-expanded={activeSubMenu === index}
onClick={() => setActiveSubMenu(activeSubMenu === index ? null : index)}
>
<span className="text-2xl">{item.label}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
className={` transition-all duration-300 ease-in-out ${
activeSubMenu === index ? 'rotate-180' : ''
}`}
width="16"
viewBox="0 0 16 9"
fill="none"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0.292893 0.292893C0.683418 -0.0976311 1.31658 -0.0976311 1.70711 0.292893L8 6.58579L14.2929 0.292894C14.6834 -0.0976305 15.3166 -0.0976304 15.7071 0.292894C16.0976 0.683418 16.0976 1.31658 15.7071 1.70711L8.70711 8.70711C8.31658 9.09763 7.68342 9.09763 7.29289 8.70711L0.292893 1.70711C-0.0976311 1.31658 -0.0976311 0.683417 0.292893 0.292893Z"
fill="currentColor"
/>
</svg>
</button>
{item.subMenus && (
<ul
className={`pt-2 overflow-hidden transition-all duration-500 ease-in-out ${
activeSubMenu === index ? 'max-h-[50rem] opacity-100' : 'max-h-0 opacity-0 invisible'
}`}
>
{item.subMenus.map((subMenu, subIndex) => (
<li key={subIndex} className="mb-7 mt-5">
<span
className="block px-6 text-lg text-tofu-black-ds dark:text-tofu-dark-header-ds mb-1">
{subMenu.label}
</span>
<ul className="pl-6">
{subMenu.items.map((item, itemIndex) => (
<li key={itemIndex} className="mb-1 mt-2">
<Link
href={item.url}
className="text-md transition duration-500 ease-in-out hover:text-gray-500 text-gray-600 dark:text-white"
>
{item.label}
</Link>
</li>
))}
</ul>
</li>
))}
</ul>
)}
</li>
))}
</ul>
</motion.div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<nav className="hidden md:block scale-85 origin-right">
<ul className="flex items-center gap-7">
{menuItems.map((item, index) => (
<motion.li
key={index}
className="flex items-center"
onHoverStart={() => handleSubMenuOpen(index)}
onHoverEnd={handleSubMenuClose}
>
<Link
href={item.url || '/'}
className="text-sm relative flex items-center hover:text-black dark:hover:text-white transition ease-in duration-500 focus:outline-none"
>
<span>{item.label}</span>
</Link>
{/* 二级菜单 */}
<AnimatePresence>
{item.subMenus && activeSubMenu === index && (
<motion.div
className="absolute top-[0rem] left-0 w-full"
onHoverEnd={() => setActiveSubMenu(null)}
initial="closed"
animate={activeSubMenu === index ? 'open' : 'closed'}
exit="closed"
variants={{
open: { opacity: 1, y: 0 },
closed: { opacity: 0, y: -20 },
}}
transition={{ duration: 0.3, ease: 'easeInOut' }}
>
<div className="flex justify-center pt-12 pb-16 mt-10 ml-12">
<motion.div
className="w-full mx-96 2xl:mx-96 xl:mx-60 md:mx-18 sm:mx-32 max-w-7xl py-3 grid grid-cols-1 md:grid-cols-3 gap-8 px-4 h-full"
key={activeSubMenu}
initial={prevActiveSubMenu !== null ? {
opacity: 0,
x: prevActiveSubMenu < activeSubMenu ? '100%' : '-100%'
} : {opacity: 0}}
animate={{opacity: 1, x: '0%'}}
exit={prevActiveSubMenu !== null ? {
opacity: 0,
x: prevActiveSubMenu > activeSubMenu ? '-100%' : '100%'
} : {opacity: 0}}
transition={{duration: 0.3, ease: 'easeInOut'}}
>
{item.subMenus.map((subMenu, subIndex) => (
<div key={subIndex} className="flex flex-col items-start">
<h3 className="text-sm font-semibold mb-3 dark:text-tofu-dark-header-ds ">{subMenu.label}</h3>
<ul className="space-y-3 mt-1">
{subMenu.items.map((item, itemIndex) => (
<li key={itemIndex}>
<Link
href={item.url}
className="text-xs dark:tofu-light transition ease-curve-a duration-250 focus-visible:rounded-s hover:text-copy-primary"
>
{item.label}
</Link>
</li>
))}
</ul>
</div>
))}
</motion.div>
</div>
<motion.div
className="transform-gpu absolute top-0 left-0 w-full h-screen pointer-events-none -z-10 bg-tofu-light-bg/80 dark:bg-tofu-dark-bg/50 "
initial={{opacity: 1}}
animate={{
opacity: 1,
transition: {
duration: 0.4,
ease: 'easeInOut',
},
}}
exit={{
opacity: 1,
transition: {
duration: 0.3,
ease: 'easeInOut',
},
}}
style={{
willChange: 'opacity',
WebkitBackdropFilter: 'blur(24px)',
backdropFilter: 'blur(24px)',
}}
/>
</motion.div>
)}
</AnimatePresence>
</motion.li>
))}
</ul>
</nav>
<button
className="group hidden md:block w-5 h-5 relative focus:outline-none scale-85 origin-right"
aria-label="Open Search"
onClick={() => setIsSearchOpen(true)}
>
<SearchIcon/>
</button>
</div>
</motion.header>
</AnimatePresence>
);
};
export default Header;导入组件
import Header from '@/components/ui/tofu/header';使用组件
import Header from '@/components/ui/tofu/header';
const logo = (
<svg className="w-6 h-auto" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M26.153 11.46a6.888..." fill="currentColor"/>
</svg>
);
const menuItems = [
{ label: 'Home', url: '/' },
{ label: 'About', url: '/about' },
{ label: 'Services', subMenus: [
{ label: 'Consulting', items: [{ label: 'Business', url: '/services/business' }] }
]},
];
const App = () => (
<Header logo={logo} menuItems={menuItems} />
);
export default App;示例
使用Header组件可以实现响应式导航栏,并支持多级下拉菜单。
Component header-styles-demo not found in registry.
参数说明
| 参数名 | 类型 | 默认值 | 描述 |
|---|---|---|---|
| logo | React.ReactNode | 必需 | 显示在导航栏左侧的Logo |
| onClose | () => void | 可选 | 导航栏关闭时的回调函数 |
| menuItems | Array<{ label: string; url?: string; subMenus?: Array<{ label: string; items: Array<{ label: string; url: string }> }> }> | 必需 | 定义导航栏中的菜单项,支持无限级下拉菜单 |
教学专区
创建多级下拉菜单
在Header组件中创建多级下拉菜单涉及到对menuItems属性的详细配置。下面的步骤和代码示例将逐一说明如何构建一个复杂的多级菜单结构。
定义顶层菜单
首先,定义顶层菜单项。这些是在导航栏中直接可见的项。
const menuItems = [
{
label: 'Products',
url: '/products',
},
{
label: 'Safety',
url: '/safety',
},
{
label: 'Company',
url: '/company',
},
];在这个示例中,Products、Safety和Company是顶层菜单项,每个菜单项都有一个标签和一个指向相关页面的URL。
添加二级菜单
对于需要二级菜单的顶层菜单项,添加一个subMenus属性。这个属性是一个数组,包含了所有二级菜单项。
const menuItems = [
{
label: 'Products',
url: '/products',
subMenus: [
{
label: 'ChatGPT',
items: [
{ label: 'For Everyone', url: '/chatgpt' },
{ label: 'For Teams', url: '/chatgpt/team' },
],
},
{
label: 'API',
items: [
{ label: 'Platform overview', url: '/api' },
],
},
],
},
];在Products下,我们有两个二级菜单项:ChatGPT和API。每个二级菜单再包含具体的项,如For Everyone和Platform overview。
配置更深层级的菜单项
如果需要,可以继续嵌套更多层级的菜单项。这通常适用于更复杂的导航结构。
const menuItems = [
{
label: 'Products',
subMenus: [
{
label: 'ChatGPT',
items: [
{ label: 'For Everyone', url: '/chatgpt' },
{
label: 'More Options',
items: [
{ label: 'Advanced', url: '/chatgpt/advanced' },
],
},
],
},
],
},
];