# 底部标签栏 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' && (
<>
山野
{[[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' && (
<>
分类
>
)}
{tab === 'cart' && (
<>
购物车
{count === 0 && 购物车是空的
}
{GOODS.filter((g) => cart[g.id]).map((g) => (
-
{cart[g.id]}
))}
{count > 0 && (
)}
>
)}
{tab === 'me' && (
<>
{[[ShoppingBag, '我的订单'], [Ticket, '优惠券'], [Wallet, '钱包'], [User, '设置']].map(([I, t]) => {
const Ic = I as Icon;
return (
-
{t as string}
);
})}
>
)}
);
}
```