# 底部标签栏 Tab Bar > 手机 App 最下面那一排 3 到 5 个入口,选中项有一块会滑动的底色,可以带数量角标。 分类:导航 [全部组件和流程](https://www.dachuiai.com/llms.txt) · [导航选型规则](https://www.dachuiai.com/guides/navigation.md) ## 给 AI:用户把这个组件发给了你 1. 先看用户的项目:框架、页面类型、现在的风格、手机还是电脑为主。 2. 对照下面的「适合 / 不适合」判断合不合适。不合适就直接说,并从[导航选型规则](https://www.dachuiai.com/guides/navigation.md)或[全部组件](https://www.dachuiai.com/llms.txt)里另挑一个。 3. 合适就按「装进项目」装好,示例里的文字和图片换成用户自己的。 4. 告诉用户用了什么、放在哪个文件、以后想改快慢、颜色、大小改哪个参数。 ## 什么时候用 - 适合:手机为主的网页应用:要常驻的几个主入口(首页、分类、购物车、我的)。 - 不适合:电脑网页(用顶部导航);入口超过 5 个;一次性的活动落地页。 - 适合的网站:电商、SaaS、本地小店、内容站 - 放在:导航 - 手机上:好用:就是给手机用的,已经留出 iPhone 底部的安全区。 - 性能代价:低 - 常搭配:[汉堡菜单 Hamburger Menu](https://www.dachuiai.com/c/hamburger-menu.md)、[无限滚动 Infinite Scroll](https://www.dachuiai.com/c/infinite-scroll.md) ## 装进项目 - 写法:React 18 以上 + TypeScript + Tailwind CSS v4,一个文件就能用,不引用别的组件。 - 依赖:`npm i lucide-react` - 文件:原样存成 `tab-bar.tsx`(比如 `src/components/tab-bar.tsx`)。能跑命令就下载[原文](https://www.dachuiai.com/code/tab-bar.tsx),比手抄准:`curl -fsSL -o src/components/tab-bar.tsx https://www.dachuiai.com/code/tab-bar.tsx`(Windows 写 `curl.exe`)。下载完看一眼第一行是 `import`,不是网页。 - 导出:`TabBar` 是可以直接用的部分;最后的 `export default` 是演示示例,只作参考。 - 图片:示例用了本站的 [/tex/s5.jpg](https://www.dachuiai.com/tex/s5.jpg)、[/tex/s6.jpg](https://www.dachuiai.com/tex/s6.jpg)、[/tex/s3.jpg](https://www.dachuiai.com/tex/s3.jpg)、[/tex/s2.jpg](https://www.dachuiai.com/tex/s2.jpg)、[/tex/s1.jpg](https://www.dachuiai.com/tex/s1.jpg)。换成用户自己的图;只想先看效果,可以从本站同样的路径下载,放进项目的 public 目录。 - 注意:tabs 是入口数据(key、label、icon,可带 badge),active 和 onChange 由页面管。电脑宽屏上把它藏起来,换成顶部导航。 - 保留代码里 `prefers-reduced-motion` 的处理:用户开了「减弱动态效果」时内容照样完整显示。 - Next.js(有 app 文件夹):文件第一行加 `'use client';`。 - 用 JavaScript 不用 TypeScript:Vite 和 Next.js 都能直接用 .tsx 文件;也可以删掉类型标注,改名 .jsx。 - 没有 Tailwind:先装 Tailwind CSS v4(Vite 项目 `npm i tailwindcss @tailwindcss/vite`,vite 配置的 plugins 里加 `tailwindcss()`,入口引入的那个 CSS 文件(一般是 `src/index.css`)第一行写 `@import "tailwindcss";`),或者把类名改写成项目用的样式。 - 已经装了 Tailwind v3:别为了一个组件升级。类名大多通用,少数 v4 写法要换,比如焦点框 `outline-2` 在 v3 里要写成 `outline outline-2`。 - 不是 React(Vue、Svelte、小程序、纯 HTML):照着代码的做法改写,时长、曲线、参数保持一样。Wix、有赞这类建站平台装不进代码,告诉用户去平台自带的动效设置里找。 ## 参数 | 参数 | 默认值 | 作用 | | --- | --- | --- | | `tabs` | | 入口列表:每项 key、label、icon,可带 badge 角标 | | `active` | | 当前选中的那一项(key) | | `onChange` | | 切换时调用,参数是新选中的那一项 | ## 你可以说 > 手机版底部加一个 Tab Bar 底部标签栏:首页、分类、购物车、我的,选中项有一块会滑动的底色,购物车显示数量角标。 ## 完整代码 ```tsx import {useRef, useState, type ComponentType, type KeyboardEvent} from 'react'; import { Backpack, Bed, Bell, ChevronRight, Flame, House, Lamp, LayoutGrid, Minus, Plus, Search, ShoppingBag, Tent, Ticket, User, Wallet, } from 'lucide-react'; /* --------------------------------------------------------------- * TabBar 底部标签栏 * 手机 App 最下面那一排:放 3–5 个最重要的入口。 * 选中项上有一块会滑动的底色,购物车可以带数量角标。 * ------------------------------------------------------------- */ type Icon = ComponentType<{size?: number; strokeWidth?: number; className?: string}>; export type Tab = {key: string; label: string; icon: Icon; badge?: number}; export function TabBar({tabs, active, onChange}: {tabs: Tab[]; active: string; onChange: (key: string) => void}) { const refs = useRef<(HTMLButtonElement | null)[]>([]); const index = Math.max(0, tabs.findIndex((t) => t.key === active)); // ← → 在各项之间移动焦点,Home / End 跳到两头 const onKey = (e: KeyboardEvent, i: number) => { const n = tabs.length; const to = {ArrowRight: (i + 1) % n, ArrowLeft: (i - 1 + n) % n, Home: 0, End: n - 1}[e.key]; if (to == null) return; e.preventDefault(); refs.current[to]?.focus(); }; return ( ); } const TABBAR_CSS = ` .tb-pill { transition: transform .42s cubic-bezier(.34,1.42,.64,1); } .tb-pop { animation: tb-pop .38s cubic-bezier(.34,1.56,.64,1); } .tb-badge { animation: tb-pop .3s cubic-bezier(.34,1.56,.64,1); } .tb-screen { animation: tb-screen .26s cubic-bezier(.22,1,.36,1); } @keyframes tb-pop { 0% { transform: scale(.82); } } @keyframes tb-screen { from { opacity: 0; transform: translateY(8px); } } @media (prefers-reduced-motion: reduce) { .tb-pill { transition: none; } .tb-pop, .tb-badge, .tb-screen { animation: none; } } `; /* ---------------- 示例:「山野」App 的四个页面 ---------------- */ const GOODS = [ {id: 'tent', name: '轻量双人帐篷', price: 899, img: '/tex/s5.jpg', icon: Tent}, {id: 'bag', name: '徒步背包 40L', price: 459, img: '/tex/s6.jpg', icon: Backpack}, {id: 'bed', name: '羽绒睡袋', price: 699, img: '/tex/s3.jpg', icon: Bed}, {id: 'stove', name: '卡式炉', price: 239, img: '/tex/s2.jpg', icon: Flame}, {id: 'lamp', name: '露营灯', price: 159, img: '/tex/s1.jpg', icon: Lamp}, ]; const Thumb = ({g, size = 26, className = ''}: {g: (typeof GOODS)[number]; size?: number; className?: string}) => (
); const yuan = (n: number) => `¥${n.toLocaleString('zh-CN')}`; export default function TabBarDemo() { const [tab, setTab] = useState('home'); const [cart, setCart] = useState>({tent: 1, bed: 1}); const count = Object.values(cart).reduce((a, b) => a + b, 0); const add = (id: string, d: number) => setCart((c) => { const n = Math.max(0, (c[id] ?? 0) + d); const next = {...c, [id]: n}; if (!n) delete next[id]; return next; }); const tabs: Tab[] = [ {key: 'home', label: '首页', icon: House}, {key: 'cat', label: '分类', icon: LayoutGrid}, {key: 'cart', label: '购物车', icon: ShoppingBag, badge: count}, {key: 'me', label: '我的', icon: User}, ]; return (
{tab === 'home' && ( <>
山野
秋季露营节
满 500 减 80
{[[Tent, '帐篷'], [Bed, '睡袋'], [Backpack, '背包'], [Flame, '炉具']].map(([I, t]) => { const Ic = I as Icon; return ( ); })}

热门装备

{GOODS.slice(0, 4).map((g) => (
{g.name}
{yuan(g.price)}
))}
)} {tab === 'cat' && ( <>

分类

{GOODS.map((g) => ( {g.name.replace(/ .*/, '')} ))}
)} {tab === 'cart' && ( <>

购物车

{count === 0 &&

购物车是空的

}
    {GOODS.filter((g) => cart[g.id]).map((g) => (
  • {g.name}
    {yuan(g.price)}
    {cart[g.id]}
  • ))}
{count > 0 && ( )} )} {tab === 'me' && ( <>
山野会员
积分 2,680
    {[[ShoppingBag, '我的订单'], [Ticket, '优惠券'], [Wallet, '钱包'], [User, '设置']].map(([I, t]) => { const Ic = I as Icon; return (
  • {t as string}
  • ); })}
)}
); } ```