Navbar
Top navigation with a mega menu, sliding hover chip, ember current-route dot, search hint and a bar that lifts into a floating pebble on scroll.
Preview
Narrow frame
Layout follows the bar's own width, so below 36 rem the links fold into a disclosure menu.
Install
Add the editable source with the shadcn CLI. Your project also needs the shared theme tokens this component uses. Setup and requirements. The Pebble installer is not published yet.
npx shadcn@latest add https://pebbleui.com/r/navbar.jsonProps
| Prop | Type | Default | Description |
|---|---|---|---|
| condensed | boolean | — | Navbar: force the floating condensed bar. Leave unset to follow scroll. |
| condenseAt | number | 24 | Navbar: scroll distance in px before the bar condenses. |
| scrollContainer | RefObject<HTMLElement> | — | Navbar: element to watch for scroll instead of the window. |
| defaultMobileOpen | boolean | false | Navbar: mobile disclosure open on first render. |
| active | boolean | false | NavbarLink / NavbarMenu / NavbarMobileLink: current route, ember dot and aria-current. |
| label | ReactNode | — | NavbarMenu: trigger text. NavbarLinks: name of the nav landmark. |
| icon / title / description / isNew | ReactNode / string / string / boolean | — | NavbarMenuLink: a mega-menu row with an optional ember New marker. |
| shortcut | string | "K" | NavbarSearch: letter shown with ⌘ as the hint (display only). |
Build notes
- **Condense.** A passive scroll listener on `scrollContainer` or `window` sets `data-condensed` when past `condenseAt`. The header adds `px-3 pt-2`; the inner bar transitions `height 56→48`, `max-width → 46rem`, radius, and swaps its bottom border for the raised recipe on `bg-card/85` + `backdrop-blur-md` (320 ms, `ease-move`). Sticky `top-0` keeps it in view. - **Hover chip.** Context holds `hovered` (an item `useId`); each top-level item sets it on `pointerenter`/`focus`, the list clears it on `pointerleave` and when focus leaves. The hovered item renders a `motion.span` with a shared `layoutId` (`absolute inset-0 -z-10` in an `isolate` item), so the chip glides between items with the 420/32/0.8 spring. Radix's own hover/open fills are overridden to transparent. - **Mega panel.** `NavigationMenuContent` inside the shared resizing viewport from `navigation-menu`. Grid `1fr 1fr 10rem`; link icons sit in small raised wells; the featured card is a recessed muted well with a drawn mini changelog (no images). - **Responsive by container.** The header is `@container/navbar`; links show at `@xl/navbar`, the toggle and panel hide there. That makes the narrow layout work inside any frame. - **Disclosure.** Absolute under the bar; `grid-rows-[0fr]→[1fr]` + opacity (200 ms enter curve), `inert` when closed. The toggle's two bars translate and rotate into an X. <!-- PEBBLE-LICENSE -->
View source
registry/nav/navbar/navbar.tsx
"use client";
/* PEBBLE-LICENSE
Built by Pebble. Component: navbar.
First-party license: MIT
The following first-party terms apply only to original Pebble material.
MIT License
Copyright (c) 2026 Praxys Solutions LLC (Pebble)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
END PEBBLE-LICENSE */
import * as React from "react";
import { MotionConfig, motion } from "motion/react";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
import { Kbd, KbdGroup } from "@/registry/text/kbd/kbd";
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
} from "@/registry/nav/navigation-menu/navigation-menu";
// Pebble: at rest the bar is part of the page (no fill, one hairline). Past
// `condenseAt` it lifts into a floating pebble: narrower, shorter, frosted
// card with ring, shadow and top light. One hover chip slides between the
// top-level items (`layoutId`); the current route wears a small ember dot.
// Layout switches on the bar's own width (container queries), so it collapses
// to the mobile disclosure inside any frame, not only on small screens.
const spring = { type: "spring", stiffness: 420, damping: 32, mass: 0.8 } as const;
type NavbarContextValue = {
condensed: boolean;
mobileOpen: boolean;
setMobileOpen: (open: boolean) => void;
panelId: string;
hovered: string | null;
setHovered: (id: string | null) => void;
chipId: string;
};
const NavbarContext = React.createContext<NavbarContextValue | null>(null);
function useNavbar() {
const context = React.useContext(NavbarContext);
if (!context) throw new Error("Navbar parts must be used within a Navbar.");
return context;
}
export type NavbarProps = React.ComponentProps<"header"> & {
/** Force the condensed state. Leave undefined to follow scroll. */
condensed?: boolean;
/** Scroll distance in px after which the bar condenses. */
condenseAt?: number;
/** Scroll container to watch instead of the window. */
scrollContainer?: React.RefObject<HTMLElement | null>;
/** Mobile menu open on first render (uncontrolled). */
defaultMobileOpen?: boolean;
};
export function Navbar({
condensed: condensedProp,
condenseAt = 24,
scrollContainer,
defaultMobileOpen = false,
className,
children,
...props
}: NavbarProps) {
const [scrolled, setScrolled] = React.useState(false);
const [mobileOpen, setMobileOpen] = React.useState(defaultMobileOpen);
const [hovered, setHovered] = React.useState<string | null>(null);
const panelId = React.useId();
const chipId = React.useId();
React.useEffect(() => {
if (condensedProp !== undefined) return;
const el = scrollContainer?.current ?? null;
const target: HTMLElement | Window = el ?? window;
const read = () => setScrolled((el ? el.scrollTop : window.scrollY) > condenseAt);
read();
target.addEventListener("scroll", read, { passive: true });
return () => target.removeEventListener("scroll", read);
}, [condensedProp, condenseAt, scrollContainer]);
const condensed = condensedProp ?? scrolled;
const context = React.useMemo(
() => ({ condensed, mobileOpen, setMobileOpen, panelId, hovered, setHovered, chipId }),
[condensed, mobileOpen, panelId, hovered, chipId],
);
return (
<NavbarContext.Provider value={context}>
<MotionConfig reducedMotion="user">
<header
data-slot="navbar"
data-condensed={condensed || undefined}
className={cn(
"group/navbar @container/navbar sticky top-0 z-40 w-full px-0 transition-[padding] duration-(--motion-slow) ease-move motion-reduce:transition-none",
"data-condensed:px-3 data-condensed:pt-2",
className,
)}
{...props}
>
<div
data-slot="navbar-bar"
className={cn(
"mx-auto flex h-14 w-full items-center gap-2 border-b border-border px-4",
"transition-[height,max-width,border-radius,background-color,box-shadow,border-color,padding] duration-(--motion-slow) ease-move motion-reduce:transition-none",
"bg-background/0",
"group-data-condensed/navbar:h-12 group-data-condensed/navbar:max-w-[min(100%,46rem)] group-data-condensed/navbar:rounded-xl group-data-condensed/navbar:border-transparent group-data-condensed/navbar:px-2.5",
"group-data-condensed/navbar:bg-card/85 group-data-condensed/navbar:shadow-raised-md group-data-condensed/navbar:ring-1 group-data-condensed/navbar:inset-shadow-highlight group-data-condensed/navbar:ring-hairline group-data-condensed/navbar:backdrop-blur-md",
)}
>
{children}
</div>
</header>
</MotionConfig>
</NavbarContext.Provider>
);
}
export function NavbarBrand({
asChild = false,
className,
...props
}: React.ComponentProps<"a"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "a";
return (
<Comp
data-slot="navbar-brand"
className={cn(
"flex shrink-0 items-center gap-2 rounded-md text-sm font-semibold tracking-[-0.01em] outline-none focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
{...props}
/>
);
}
/** Desktop navigation: a Radix navigation menu with the sliding hover chip. */
export function NavbarLinks({
className,
children,
label = "Main",
}: {
className?: string;
children: React.ReactNode;
label?: string;
}) {
const { setHovered } = useNavbar();
return (
<NavigationMenu
aria-label={label}
data-slot="navbar-links"
className={cn("hidden @xl/navbar:flex", className)}
onPointerLeave={() => setHovered(null)}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node)) setHovered(null);
}}
>
<NavigationMenuList className="gap-0.5">{children}</NavigationMenuList>
</NavigationMenu>
);
}
function HoverChip({ id }: { id: string }) {
const { hovered, chipId } = useNavbar();
if (hovered !== id) return null;
return (
<motion.span
aria-hidden
layoutId={chipId}
transition={spring}
data-slot="navbar-hover-chip"
className="absolute inset-0 -z-10 rounded-md bg-muted group-data-condensed/navbar:bg-foreground/[0.06]"
/>
);
}
function ActiveDot() {
return (
<span
aria-hidden
data-slot="navbar-active-dot"
className="absolute bottom-0.5 left-1/2 size-1 -translate-x-1/2 rounded-full bg-accent"
/>
);
}
const itemStyle = cn(
"relative isolate inline-flex h-8 items-center gap-1 rounded-md px-3 text-[13px] font-medium text-muted-foreground outline-none",
"transition-colors duration-(--motion-fast) ease-enter motion-reduce:transition-none",
"hover:bg-transparent hover:text-foreground focus:bg-transparent focus-visible:ring-2 focus-visible:ring-ring",
"data-[state=open]:bg-transparent data-[state=open]:text-foreground",
"aria-[current=page]:text-foreground",
);
export type NavbarLinkProps = React.ComponentProps<"a"> & {
/** Marks the current route: ember dot and aria-current="page". */
active?: boolean;
};
export function NavbarLink({ active = false, className, children, ...props }: NavbarLinkProps) {
const { setHovered } = useNavbar();
const id = React.useId();
return (
<NavigationMenuItem onPointerEnter={() => setHovered(id)} onFocus={() => setHovered(id)}>
<NavigationMenuLink asChild active={active}>
<a
data-slot="navbar-link"
aria-current={active ? "page" : undefined}
className={cn(itemStyle, "flex-row p-0 px-3 data-[active]:bg-transparent", className)}
{...props}
>
<HoverChip id={id} />
{children}
{active && <ActiveDot />}
</a>
</NavigationMenuLink>
</NavigationMenuItem>
);
}
export type NavbarMenuProps = {
/** Trigger text. */
label: React.ReactNode;
/** Marks the section of the current route. */
active?: boolean;
/** Panel content (NavbarMenuPanel). */
children: React.ReactNode;
};
export function NavbarMenu({ label, active = false, children }: NavbarMenuProps) {
const { setHovered } = useNavbar();
const id = React.useId();
return (
<NavigationMenuItem onPointerEnter={() => setHovered(id)} onFocus={() => setHovered(id)}>
<NavigationMenuTrigger
data-slot="navbar-menu-trigger"
className={cn(itemStyle, "h-8 text-[13px]")}
>
<HoverChip id={id} />
{label}
{active && <ActiveDot />}
</NavigationMenuTrigger>
<NavigationMenuContent>{children}</NavigationMenuContent>
</NavigationMenuItem>
);
}
export function NavbarMenuPanel({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="navbar-menu-panel"
className={cn(
"grid w-[min(32rem,calc(100vw-2rem))] grid-cols-[1fr_1fr_10rem] gap-1 p-1",
className,
)}
{...props}
/>
);
}
export function NavbarMenuGroup({
title,
className,
children,
...props
}: React.ComponentProps<"div"> & { title: string }) {
const id = React.useId();
return (
<div
role="group"
aria-labelledby={id}
data-slot="navbar-menu-group"
className={cn("flex flex-col gap-0.5", className)}
{...props}
>
<p id={id} className="px-2.5 pt-1.5 pb-1 text-xs font-medium text-muted-foreground">
{title}
</p>
<ul className="flex flex-col gap-0.5">{children}</ul>
</div>
);
}
export type NavbarMenuLinkProps = React.ComponentProps<"a"> & {
icon?: React.ReactNode;
title: string;
description?: string;
/** Small ember "New" marker. */
isNew?: boolean;
};
export function NavbarMenuLink({
icon,
title,
description,
isNew = false,
className,
...props
}: NavbarMenuLinkProps) {
return (
<li>
<NavigationMenuLink asChild>
<a
data-slot="navbar-menu-link"
className={cn("group/menu-link flex-row items-start gap-2.5 p-2", className)}
{...props}
>
{icon && (
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-card text-foreground shadow-raised-xs ring-1 inset-shadow-highlight ring-hairline dark:bg-muted [&_svg]:size-4 [&_svg]:text-foreground">
{icon}
</span>
)}
<span className="flex min-w-0 flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-[13px] leading-tight font-medium text-foreground">
{title}
{isNew && (
<span className="rounded-full bg-accent/12 px-1.5 text-[10px] leading-4 font-medium text-accent-strong ring-1 ring-accent/30">
New
</span>
)}
</span>
{description && (
<span className="text-xs leading-snug text-muted-foreground">{description}</span>
)}
</span>
</a>
</NavigationMenuLink>
</li>
);
}
export function NavbarMenuFeatured({ className, ...props }: React.ComponentProps<"a">) {
return (
<NavigationMenuLink asChild>
<a
data-slot="navbar-menu-featured"
className={cn(
"relative flex h-full flex-col justify-end gap-1 overflow-hidden rounded-lg bg-muted/70 p-3 ring-1 inset-shadow-xs ring-hairline",
"hover:bg-muted focus:bg-muted",
className,
)}
{...props}
/>
</NavigationMenuLink>
);
}
export function NavbarActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="navbar-actions"
className={cn("ml-auto flex items-center gap-1", className)}
{...props}
/>
);
}
export type NavbarSearchProps = React.ComponentProps<"button"> & {
/** Shortcut letter shown with ⌘ (display only; wire the key yourself). */
shortcut?: string;
};
export function NavbarSearch({
shortcut = "K",
className,
children = "Search",
...props
}: NavbarSearchProps) {
return (
<button
type="button"
data-slot="navbar-search"
aria-keyshortcuts={shortcut ? `Meta+${shortcut} Control+${shortcut}` : undefined}
className={cn(
"inline-flex h-8 shrink-0 cursor-pointer items-center gap-2 rounded-md bg-muted/70 pr-1.5 pl-2.5 text-[13px] whitespace-nowrap text-muted-foreground ring-1 inset-shadow-xs ring-hairline outline-none",
"transition-colors duration-(--motion-fast) hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
"max-@md/navbar:size-8 max-@md/navbar:justify-center max-@md/navbar:p-0",
className,
)}
{...props}
>
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
aria-hidden
className="size-3.5 shrink-0"
>
<circle cx="7" cy="7" r="4.25" />
<path d="m10.25 10.25 3 3" />
</svg>
<span className="max-@md/navbar:sr-only">{children}</span>
{shortcut && (
<KbdGroup className="max-@md/navbar:hidden">
<Kbd>⌘</Kbd>
<Kbd>{shortcut}</Kbd>
</KbdGroup>
)}
</button>
);
}
export function NavbarMobileToggle({ className, ...props }: React.ComponentProps<"button">) {
const { mobileOpen, setMobileOpen, panelId } = useNavbar();
return (
<button
type="button"
data-slot="navbar-mobile-toggle"
aria-expanded={mobileOpen}
aria-controls={panelId}
aria-label={mobileOpen ? "Close menu" : "Open menu"}
onClick={() => setMobileOpen(!mobileOpen)}
className={cn(
"inline-flex size-8 cursor-pointer items-center justify-center rounded-md text-foreground outline-none @xl/navbar:hidden",
"transition-[background-color,transform] duration-(--motion-fast) hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.94] motion-reduce:active:scale-100",
className,
)}
{...props}
>
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
aria-hidden
className="size-4"
>
{/* Two bars that cross into an X. */}
<path
d="M3 5.5h10"
className={cn(
"origin-center transition-transform duration-(--motion-base) ease-move motion-reduce:transition-none",
mobileOpen && "translate-y-[2.5px] rotate-45",
)}
/>
<path
d="M3 10.5h10"
className={cn(
"origin-center transition-transform duration-(--motion-base) ease-move motion-reduce:transition-none",
mobileOpen && "-translate-y-[2.5px] -rotate-45",
)}
/>
</svg>
</button>
);
}
/**
* The mobile disclosure. Place it as the last child of `Navbar`; it drops
* below the bar and only shows while the bar is narrow.
*/
export function NavbarMobilePanel({
className,
children,
label = "Main",
}: {
className?: string;
children: React.ReactNode;
label?: string;
}) {
const { mobileOpen, panelId } = useNavbar();
return (
<div
id={panelId}
data-slot="navbar-mobile-panel"
data-state={mobileOpen ? "open" : "closed"}
className={cn(
"absolute inset-x-2 top-[calc(100%+0.375rem)] grid @xl/navbar:hidden",
"transition-[grid-template-rows,opacity] duration-(--motion-base) ease-enter motion-reduce:transition-none",
mobileOpen
? "grid-rows-[1fr] opacity-100"
: "pointer-events-none grid-rows-[0fr] opacity-0",
)}
inert={!mobileOpen}
>
<nav
aria-label={label}
className={cn(
"overflow-hidden rounded-xl bg-card shadow-raised-lg ring-1 inset-shadow-highlight ring-hairline",
className,
)}
>
<div className="flex flex-col gap-0.5 p-1.5">{children}</div>
</nav>
</div>
);
}
export function NavbarMobileLink({
active = false,
className,
children,
...props
}: React.ComponentProps<"a"> & { active?: boolean }) {
return (
<a
data-slot="navbar-mobile-link"
aria-current={active ? "page" : undefined}
className={cn(
"flex h-10 items-center gap-2.5 rounded-md px-3 text-sm text-muted-foreground outline-none",
"transition-colors duration-(--motion-fast) hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
"aria-[current=page]:font-medium aria-[current=page]:text-foreground [&_svg]:size-4",
className,
)}
{...props}
>
{children}
{active && <span aria-hidden className="ml-auto size-1.5 rounded-full bg-accent" />}
</a>
);
}
