1st-pouf
uibottom-navsource: ok · hit
BottomNav
npx shadcn add "https://1st-pouf.worksonmy.dev/r/bottom-nav.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightui-primitives@radix-ui/react-dialog
Registry dependencies
https://1st-pouf.worksonmy.dev/r/base.jsonhttps://1st-pouf.worksonmy.dev/r/button.jsonhttps://1st-pouf.worksonmy.dev/r/icon.jsonhttps://1st-pouf.worksonmy.dev/r/layout.jsonhttps://1st-pouf.worksonmy.dev/r/nav-link.jsonhttps://1st-pouf.worksonmy.dev/r/text.jsonhttps://1st-pouf.worksonmy.dev/r/tone.json
Files1 · source cached 10/9/2026, 2:40:02 PM
registry/pouf/BottomNav.tsx
import clsx from 'clsx'
import { buttonClasses } from './Button'
import * as RDialog from '@radix-ui/react-dialog'
import { useState } from 'react'
import { Icon, renderIcon, type IconLike } from './Icon'
import { Row } from './layout'
import { Heading, Text } from './text'
import { toneClass, type Tone } from './tone'
import { isActivePath, type LinkComponent } from './NavLink'
const Anchor: LinkComponent = (props) => <a {...props} />
export interface NavItem {
href: string
label: string
icon: IconLike
tone: Tone
}
/** Note what is NOT here: `toneClass(item.tone)`. The tab deliberately ignores
* its item's tone, so every active tab is the same purple. item.tone still
* drives the sidebar and the Menu sheet, where only one link is lit at a time
* and a per-screen colour is an accent rather than a carousel. */
function Tab({
item,
active,
link: Link = Anchor,
}: {
item: NavItem
active: boolean
link?: LinkComponent
}) {
return (
<Link
href={item.href}
className={clsx('pouf-tab', active && 'pouf-tab--active')}
aria-current={active ? 'page' : undefined}
>
{renderIcon(item.icon, 'md')}
<span className="pouf-tab__label">{item.label}</span>
</Link>
)
}
export interface NavGroup {
title: string
items: NavItem[]
}
interface Props {
/** The tabs shown directly in the bar. Keep this short — everything else
* lives behind Menu. */
primary: NavItem[]
/** Every destination, grouped, for the Menu sheet. Grouped rather than flat
* for the same reason as the sidebar: an undifferentiated list of nine hides
* which screens you'd open daily and which you'd touch once. */
groups: NavGroup[]
/** The app's current pathname — pouf has no router; the host app does. */
currentPath: string
/** Swap in your router's Link (must forward href/className/children). */
link?: LinkComponent
}
/** Mobile bottom navigation.
*
* Bottom rather than top because it is the only part of a phone screen a thumb
* reaches without regripping — and this is an app someone opens one-handed,
* mid-walk, to check one thing.
*
* Only a few destinations fit honestly; the rest go behind Menu rather than
* being crushed into unreadable 8px labels. Menu is a real tab (not a hamburger
* in a corner) so the overflow is thumb-reachable too, and it lights up when the
* current page isn't one of the primary tabs — otherwise navigating to a
* secondary page would leave every tab dark and nobody able to tell where they
* are.
*/
export function BottomNav({ primary, groups, currentPath, link: Link = Anchor }: Props) {
const [open, setOpen] = useState(false)
const onPrimary = primary.some((i) => isActivePath(i.href, currentPath))
return (
<nav className="pouf-bottomnav" aria-label="Main">
{primary.map((item) => (
<Tab key={item.href} item={item} active={isActivePath(item.href, currentPath)} link={Link} />
))}
<RDialog.Root open={open} onOpenChange={setOpen}>
<RDialog.Trigger asChild>
<button
type="button"
className={clsx('pouf-tab', !onPrimary && 'pouf-tab--active')}
aria-current={!onPrimary ? 'page' : undefined}
>
<Icon name="menu" size="md" />
<span className="pouf-tab__label">Menu</span>
</button>
</RDialog.Trigger>
<RDialog.Portal>
<RDialog.Overlay className="pouf-overlay" />
<RDialog.Content className="pouf-sheet" aria-describedby={undefined}>
<div className="pouf-sheet__handle" />
<RDialog.Title asChild>
<div>
<Heading level={3}>All screens</Heading>
</div>
</RDialog.Title>
{/* Grouped by spacing, deliberately unlabelled. The headings read as
clutter at this size, and the clusters do the work on their own —
proximity groups without adding a word of chrome. */}
<div className="pouf-sheet__body">
{/* onClick lives here (rather than on each Link) because Link is
swappable down to the bare LinkComponent signature, which has
no onClick slot — delegating to this wrapper closes the sheet
on navigate without widening that type. */}
<div className="pouf-nav" onClick={() => setOpen(false)}>
{groups.map((g) => (
<div className="pouf-navgroup" key={g.title}>
{g.items.map((item) => {
const active = isActivePath(item.href, currentPath)
return (
<Link
key={item.href}
href={item.href}
className={clsx(
'pouf-navlink',
active && 'pouf-navlink--active',
active && toneClass(item.tone),
)}
aria-current={active ? 'page' : undefined}
>
{renderIcon(item.icon, 'md')}
{item.label}
</Link>
)
})}
</div>
))}
</div>
</div>
<RDialog.Close asChild>
<button type="button" className={buttonClasses({ size: 'sm', variant: 'quiet', block: true })}>
<Row gap={2} wrap={false}>
<Icon name="close" size="sm" />
<Text size="sm">Close</Text>
</Row>
</button>
</RDialog.Close>
</RDialog.Content>
</RDialog.Portal>
</RDialog.Root>
</nav>
)
}