Component
BottomNav
Thanh điều hướng capsule nổi ở dưới md, với 3 đến 5 đích cấp cao nhất. Bề mặt glass nằm trên safe area phía dưới, cách một khoảng nhỏ. Nhãn hiện theo mặc định và có thể ẩn khỏi mắt bằng showLabels. Nó tách khỏi Sidebar: app có cả hai thì tự render item của mình trong từng cái. Viewport của trang cần viewport-fit=cover.
Xem trước
import { useState, type MouseEvent } from "react"
import { IconHome, IconHomeFilled, IconMail, IconMailFilled, IconSearch, IconSearchFilled } from "@tabler/icons-react"
import { BottomNav, BottomNavItem } from "@/components/ui/bottom-nav"
const [destination, setDestination] = useState("#home")
const selectDestination = (event: MouseEvent<HTMLAnchorElement>) => { event.preventDefault(); setDestination(event.currentTarget.getAttribute("href") ?? "#home") }
<div data-slot="bottom-nav-preview" className="mx-auto flex h-80 w-[393px] shrink-0 flex-col justify-between overflow-hidden rounded-container border border-border bg-background-surface">
<div className="flex flex-col gap-xs p-lg">
<p className="text-sm font-medium">Không gian làm việc của bạn</p>
<p className="text-xs text-text-secondary">Chọn một điểm đến bên dưới.</p>
</div>
<BottomNav label="Chính" value={destination} className="relative md:block">
<BottomNavItem href="#home" label="Trang chủ" icon={<IconHome size={20} />} activeIcon={<IconHomeFilled size={20} />} onClick={selectDestination} />
<BottomNavItem href="#search" label="Tìm kiếm" icon={<IconSearch size={20} />} activeIcon={<IconSearchFilled size={20} />} onClick={selectDestination} />
<BottomNavItem href="#inbox" label="Hộp thư đến" icon={<IconMail size={20} />} activeIcon={<IconMailFilled size={20} />} count={3} onClick={selectDestination} />
</BottomNav>
</div>Cách dùng
Khi nào dùng
- Hiện một danh sách ngắn các đích cấp cao nhất ở dưới màn hình nhỏ.
Khi nào không dùng
- Đặt các action như Tạo hoặc Chia sẻ vào điều hướng. Dùng Button.
- Hiện cùng thanh đó cạnh một Sidebar đang thấy.
Ví dụ
Ba đích
import { useState, type MouseEvent } from "react"
import { IconHome, IconHomeFilled, IconMail, IconMailFilled, IconSearch, IconSearchFilled } from "@tabler/icons-react"
import { BottomNav, BottomNavItem } from "@/components/ui/bottom-nav"
const [destination, setDestination] = useState("#home")
const selectDestination = (event: MouseEvent<HTMLAnchorElement>) => { event.preventDefault(); setDestination(event.currentTarget.getAttribute("href") ?? "#home") }
<div data-slot="bottom-nav-preview" className="mx-auto flex h-80 w-[393px] shrink-0 flex-col justify-between overflow-hidden rounded-container border border-border bg-background-surface">
<div className="flex flex-col gap-xs p-lg">
<p className="text-sm font-medium">Không gian làm việc của bạn</p>
<p className="text-xs text-text-secondary">Chọn một điểm đến bên dưới.</p>
</div>
<BottomNav label="Chính" value={destination} className="relative md:block">
<BottomNavItem href="#home" onClick={selectDestination} icon={<IconHome size={20} />} activeIcon={<IconHomeFilled size={20} />} label="Trang chủ" active />
<BottomNavItem href="#search" onClick={selectDestination} icon={<IconSearch size={20} />} activeIcon={<IconSearchFilled size={20} />} label="Tìm kiếm" />
<BottomNavItem href="#inbox" onClick={selectDestination} icon={<IconMail size={20} />} activeIcon={<IconMailFilled size={20} />} label="Hộp thư đến" count={3} />
</BottomNav>
</div>Năm đích
import { useState, type MouseEvent } from "react"
import { IconHome, IconHomeFilled, IconLayoutGrid, IconLayoutGridFilled, IconMail, IconMailFilled, IconSearch, IconSearchFilled, IconUser, IconUserFilled } from "@tabler/icons-react"
import { BottomNav, BottomNavItem } from "@/components/ui/bottom-nav"
const [destination, setDestination] = useState("#home")
const selectDestination = (event: MouseEvent<HTMLAnchorElement>) => { event.preventDefault(); setDestination(event.currentTarget.getAttribute("href") ?? "#home") }
<div data-slot="bottom-nav-preview" className="mx-auto flex h-80 w-[393px] shrink-0 flex-col justify-between overflow-hidden rounded-container border border-border bg-background-surface">
<div className="flex flex-col gap-xs p-lg">
<p className="text-sm font-medium">Không gian làm việc của bạn</p>
<p className="text-xs text-text-secondary">Chọn một điểm đến bên dưới.</p>
</div>
<BottomNav label="Chính" value={destination} className="relative md:block">
<BottomNavItem href="#home" onClick={selectDestination} icon={<IconHome size={20} />} activeIcon={<IconHomeFilled size={20} />} label="Trang chủ" active />
<BottomNavItem href="#search" onClick={selectDestination} icon={<IconSearch size={20} />} activeIcon={<IconSearchFilled size={20} />} label="Tìm kiếm" />
<BottomNavItem href="#inbox" onClick={selectDestination} icon={<IconMail size={20} />} activeIcon={<IconMailFilled size={20} />} label="Hộp thư đến" count={3} />
<BottomNavItem href="#projects" onClick={selectDestination} icon={<IconLayoutGrid size={20} />} activeIcon={<IconLayoutGridFilled size={20} />} label="Dự án" />
<BottomNavItem href="#profile" onClick={selectDestination} icon={<IconUser size={20} />} activeIcon={<IconUserFilled size={20} />} label="Hồ sơ" />
</BottomNav>
</div>Route hiện tại
import { useState, type MouseEvent } from "react"
import { IconHome, IconHomeFilled, IconLayoutGrid, IconLayoutGridFilled, IconMail, IconMailFilled, IconSearch, IconSearchFilled } from "@tabler/icons-react"
import { BottomNav, BottomNavItem } from "@/components/ui/bottom-nav"
const [destination, setDestination] = useState("#home")
const selectDestination = (event: MouseEvent<HTMLAnchorElement>) => { event.preventDefault(); setDestination(event.currentTarget.getAttribute("href") ?? "#home") }
<div data-slot="bottom-nav-preview" className="mx-auto flex h-80 w-[393px] shrink-0 flex-col justify-between overflow-hidden rounded-container border border-border bg-background-surface">
<div className="flex flex-col gap-xs p-lg">
<p className="text-sm font-medium">Không gian làm việc của bạn</p>
<p className="text-xs text-text-secondary">Chọn một điểm đến bên dưới.</p>
</div>
<BottomNav label="Chính" value={destination} className="relative md:block">
<BottomNavItem href="#home" onClick={selectDestination} icon={<IconHome size={20} />} activeIcon={<IconHomeFilled size={20} />} label="Trang chủ" active />
<BottomNavItem href="#search" onClick={selectDestination} icon={<IconSearch size={20} />} activeIcon={<IconSearchFilled size={20} />} label="Tìm kiếm" />
<BottomNavItem href="#inbox" onClick={selectDestination} icon={<IconMail size={20} />} activeIcon={<IconMailFilled size={20} />} label="Hộp thư đến" count={3} />
<BottomNavItem href="#projects" onClick={selectDestination} icon={<IconLayoutGrid size={20} />} activeIcon={<IconLayoutGridFilled size={20} />} label="Dự án" />
</BottomNav>
</div>Phần tử link tùy chỉnh
import { useState, type MouseEvent } from "react"
import { IconHome, IconHomeFilled, IconMail, IconMailFilled, IconSearch, IconSearchFilled } from "@tabler/icons-react"
import { BottomNav, BottomNavItem } from "@/components/ui/bottom-nav"
const [destination, setDestination] = useState("#home")
const selectDestination = (event: MouseEvent<HTMLAnchorElement>) => { event.preventDefault(); setDestination(event.currentTarget.getAttribute("href") ?? "#home") }
<div data-slot="bottom-nav-preview" className="mx-auto flex h-80 w-[393px] shrink-0 flex-col justify-between overflow-hidden rounded-container border border-border bg-background-surface">
<div className="flex flex-col gap-xs p-lg">
<p className="text-sm font-medium">Không gian làm việc của bạn</p>
<p className="text-xs text-text-secondary">Chọn một điểm đến bên dưới.</p>
</div>
<BottomNav label="Chính" value={destination} className="relative md:block">
<BottomNavItem href="#home" onClick={selectDestination} icon={<IconHome size={20} />} activeIcon={<IconHomeFilled size={20} />} label="Trang chủ" active asChild>
<a href="#home">Trang chủ</a>
</BottomNavItem>
<BottomNavItem href="#search" onClick={selectDestination} icon={<IconSearch size={20} />} activeIcon={<IconSearchFilled size={20} />} label="Tìm kiếm" asChild>
<a href="#search">Tìm kiếm</a>
</BottomNavItem>
<BottomNavItem href="#inbox" onClick={selectDestination} icon={<IconMail size={20} />} activeIcon={<IconMailFilled size={20} />} label="Hộp thư đến" asChild>
<a href="#inbox">Hộp thư đến</a>
</BottomNavItem>
</BottomNav>
</div>Chỉ icon
Ẩn nhãn nhìn thấy nhưng vẫn giữ tên đích cho công nghệ hỗ trợ.
import { useState, type MouseEvent } from "react"
import { IconHome, IconHomeFilled, IconMail, IconMailFilled, IconSearch, IconSearchFilled } from "@tabler/icons-react"
import { BottomNav, BottomNavItem } from "@/components/ui/bottom-nav"
const [destination, setDestination] = useState("#home")
const selectDestination = (event: MouseEvent<HTMLAnchorElement>) => { event.preventDefault(); setDestination(event.currentTarget.getAttribute("href") ?? "#home") }
<div data-slot="bottom-nav-preview" className="mx-auto flex h-80 w-[393px] shrink-0 flex-col justify-between overflow-hidden rounded-container border border-border bg-background-surface">
<div className="flex flex-col gap-xs p-lg">
<p className="text-sm font-medium">Không gian làm việc của bạn</p>
<p className="text-xs text-text-secondary">Chọn một điểm đến bên dưới.</p>
</div>
<BottomNav label="Chính" value={destination} showLabels={false} className="relative md:block">
<BottomNavItem href="#home" onClick={selectDestination} icon={<IconHome size={20} />} activeIcon={<IconHomeFilled size={20} />} label="Trang chủ" active />
<BottomNavItem href="#search" onClick={selectDestination} icon={<IconSearch size={20} />} activeIcon={<IconSearchFilled size={20} />} label="Tìm kiếm" />
<BottomNavItem href="#inbox" onClick={selectDestination} icon={<IconMail size={20} />} activeIcon={<IconMailFilled size={20} />} label="Hộp thư đến" count={3} />
</BottomNav>
</div>Nên và không nên
Nên
- Dùng 3 đến 5 đích với nhãn ngắn.
- Đệm nội dung trang để thanh không bao giờ che hàng cuối.
- Trong các demo nhúng này, relative md:block giữ thanh bên trong ô của nó và vẫn thấy trên desktop. Bỏ các class demo đó cho thanh mobile fixed trong một ứng dụng.
- Dùng route hiện tại làm value, hoặc đặt active trên một item. Component không sở hữu trạng thái điều hướng.
- Giữ nhãn hiện với các đích chưa quen. Chỉ đặt showLabels={false} khi icon đã quen; luôn cung cấp label của từng item.
- Chừa chỗ dưới nội dung trang cho chiều cao capsule, khoảng hở phía dưới và inset của safe-area.
- Cung cấp activeIcon filled cho mọi đích. Mỗi ví dụ nhúng dùng lựa chọn cục bộ riêng và một canvas mobile 393px; cuộn canvas theo chiều ngang khi ô tài liệu hẹp hơn.
Không nên
- Đừng đặt action (Tạo, Chia sẻ) vào thanh; dùng một Button.
- Đừng hiện nó cạnh một Sidebar đang mở.
Props
| Tên | Kiểu | Giá trị | Mặc định | Mô tả |
|---|---|---|---|---|
| label | string | - | Main | Trên BottomNav: tên truy cập của landmark điều hướng. Trên BottomNavItem: chữ đích bắt buộc, hiện trừ khi showLabels là false. |
| countLabel | (label: string, count: number) => string | - | - | Đặt tên cho item đang hiện count, thay tên truy cập của nó: (label, count) => `{label}, {count} new` theo mặc định. Tiếng Việt: `{label}, {count} mục mới`. Tùy chọn. |
| value | string | - | - | Trên BottomNav: href của đích đang active, thường do router của ứng dụng cung cấp. Được ưu tiên hơn prop active của item. BottomNav không đổi nó khi nhấn. |
| href bắt buộc | string | - | - | Trên BottomNavItem: URL đích và id để chọn. Bắt buộc kể cả khi có asChild. |
| icon bắt buộc | node | - | - | Trên BottomNavItem: icon thường, 20px. |
| activeIcon bắt buộc | node | - | - | Trên BottomNavItem: icon filled cho trạng thái active. Cung cấp variant filled khớp với icon thường, ở 20px. |
| active | boolean | - | false | Trên BottomNavItem: đánh dấu đích này là hiện tại khi BottomNav không có value. Việc chọn vẫn dùng điều hướng link bình thường. |
| count | number | - | - | Trên BottomNavItem: count lớn hơn không thêm một badge và đặt tên count qua countLabel. Count bằng không và count âm bị ẩn. |
| asChild | boolean | - | false | Trên BottomNavItem: render hành vi link đích lên child duy nhất của nó. |
| onClick | React.MouseEventHandler<HTMLAnchorElement> | - | - | Trên BottomNavItem: handler click của link native. Dùng điều hướng của ứng dụng hoặc preventDefault() cho bản demo tại chỗ. |
| showLabels | boolean | - | true | Trên BottomNav: hiện nhãn đích dưới icon. Đặt false để ẩn nhãn khỏi mắt nhưng vẫn giữ tên cho mọi link với công nghệ hỗ trợ. |
Slot
| Tên | Bắt buộc | Mô tả |
|---|---|---|
| children | có | 3 đến 5 BottomNavItem với href, icon, activeIcon và label, cộng count và active tùy chọn. |
Cấu tạo
| Phần | Mô tả |
|---|---|
| root | Lớp bọc định vị fixed, trong suốt, ẩn từ md trở lên, với padding safe-area theo chiều ngang và phía dưới. Khoảng trống quanh capsule không chặn sự kiện pointer. |
| item | Một link đích rộng bằng nhau: icon 20px trên nhãn font-size-xs, với pill chọn bo tròn và cao ít nhất size-element-xl. |
| icon | Icon outline 20px, được thay bằng activeIcon filled bắt buộc khi được chọn. |
| label | Chữ đích, ẩn khỏi mắt khi showLabels là false. Nhãn hiện mà dài thì bị cắt cụt. |
| badge | Badge đếm danger solid tùy chọn trên icon, với text-on-danger. |
| list | Capsule nổi căn giữa trên material-thin, với chiều rộng panel tối đa, góc rounded-full, padding bên trong và đổ bóng. |
| indicator | Một pill bo tròn trượt giữa các đích cùng rộng trong duration-medium. Nó đổi kích thước theo thanh; khi giảm chuyển động, việc chọn đổi mà không di chuyển. |
Khả năng truy cập
Role: navigation
| Phím | Hành động |
|---|---|
| Tab | Chuyển giữa các đích (link), theo thứ tự. |
| Enter | Đi theo link. |
- Một landmark nav được đặt tên bởi label. Item là link, không phải tab: không có role tab và không roving bằng phím mũi tên.
- Item đang active có aria-current="page" và dùng icon Filled (DESIGN 9.1).
- Nhãn hiện theo mặc định. Với showLabels=false, nhãn vẫn có cho công nghệ hỗ trợ qua chữ ẩn khỏi mắt.
- Badge đếm thêm số của nó vào tên truy cập, qua countLabel.
Cài đặt
npx werewolf-ui add bottom-navChạy npx werewolf-ui init một lần trong app trước khi thêm component. Init cài token, thêm CSS import và luật cho agent. Add cài component này cùng các dependency và giữ nguyên theme của bạn. Xem Cài đặt để biết yêu cầu và cách cài qua registry.