Skip to content
Pebble Pro is here at $49 one timeSee what’s included ↗
Pebble
ComponentsDocsMCPPricing
⌕ Find a component↗Log in
All components 673

Categories

Core inputs & forms46Buttons & actions33Navigation36Overlays & feedback49Data display33Charts & data viz40Cards & layout43Marketing blocks38App shells & pages37AI interfaces40Text & typography effects33Backgrounds & visual effects38Motion & transitions32Numbers & counters32Media34E-commerce333D & interactive36Themed sets40
Start with the essentials.Installation guide ↗Connect your agent ↗
← All componentsNavigation

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

Pebble
  • Pricing
  • Docs
  • Blog
ProductPricingDocs

Version 3.2

Write together without stepping on toes

Scroll this frame: the bar lifts into a floating pebble.

Editors online

1,284

Median sync

38 ms

Uptime

99.98%

Shared cursors

See who is editing which block, live.

Nov 3

Offline drafts

Drafts sync the moment you reconnect.

Oct 14

A faster editor

Typing stays under one frame at 40k words.

Sep 22
Pebble
ProductPricingDocsBlog

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.json

Props

PropTypeDefaultDescription
condensedboolean—Navbar: force the floating condensed bar. Leave unset to follow scroll.
condenseAtnumber24Navbar: scroll distance in px before the bar condenses.
scrollContainerRefObject<HTMLElement>—Navbar: element to watch for scroll instead of the window.
defaultMobileOpenbooleanfalseNavbar: mobile disclosure open on first render.
activebooleanfalseNavbarLink / NavbarMenu / NavbarMobileLink: current route, ember dot and aria-current.
labelReactNode—NavbarMenu: trigger text. NavbarLinks: name of the nav landmark.
icon / title / description / isNewReactNode / string / string / boolean—NavbarMenuLink: a mega-menu row with an optional ember New marker.
shortcutstring"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>
  );
}
Pebble

Small details.
Better interfaces.

React components to explore, make your own, and build on.

Library

  • Components
  • Installation
  • Pricing

Resources

  • For agents
  • About
  • Support

Legal

  • License
  • Terms
  • Privacy
  • Final sale

Built by Pebble

A product of Praxys Solutions LLC ↗

Pebble Pro is live

$49 one time, lifetime updates ↗