Navbar

Self-contained navbar layouts with scroll behavior, variant backgrounds, and mobile menu integration. Pick one and edit directly.

Classic

Floating pill with scroll-hide behavior. Logo left, links and CTA right. Transitions from variant background to blurred on scroll.

bonk/ui

API Reference

PropTypeDefault
variant"solid" | "outlined" | "ghost" | "minimal""solid"
themeTheme

Basic Usage

import { Navbar } from "@/extensions/layout";

<Navbar />

Source

src/extensions/layout/Navbar.tsx
"use client";

import React, { useState, useEffect } from "react";
import { usePathname } from "next/navigation";
import {
  NavMenu,
  NavMenuLink,
  Button,
  IconButton,
  Link,
  Logo,
  Container,
} from "@/ui";
import { MenuIcon } from "@/ui/icons";
import { NAV_LINKS, NAV_CTA } from "@/constants";
import type { Theme } from "@/types";
import { cn } from "@/utils";
import { MobileMenu } from "./MobileMenu";

interface NavbarProps {
  variant?: "solid" | "outlined" | "ghost" | "minimal";
  theme?: Theme;
}

function getAtTopBg(variant: NavbarProps["variant"]) {
  switch (variant) {
    case "outlined":
      return "border border-root-contrast/20 bg-transparent backdrop-blur-sm";
    case "ghost":
      return "border border-transparent bg-divider backdrop-blur-sm";
    case "minimal":
      return "border border-transparent bg-transparent";
    case "solid":
    default:
      return "border border-divider bg-root/90 shadow-sm backdrop-blur-md";
  }
}

const SCROLLED_BG =
  "border border-transparent bg-root/60 backdrop-blur-lg";

export function Navbar({ variant = "solid", theme }: NavbarProps) {
  const pathname = usePathname();
  const [mobileOpen, setMobileOpen] = useState(false);
  const [visible, setVisible] = useState(true);
  const [atTop, setAtTop] = useState(true);

  useEffect(() => {
    let lastY = window.scrollY;
    const onScroll = () => {
      const currentY = window.scrollY;
      setAtTop(currentY < 78);
      setVisible(currentY < 10 || currentY < lastY);
      lastY = currentY;
    };
    window.addEventListener("scroll", onScroll, { passive: true });
    return () => window.removeEventListener("scroll", onScroll);
  }, []);

  return (
    <>
      <nav
        aria-label="Main navigation"
        className={cn(
          "fixed inset-x-0 top-3 z-40 transition-all duration-300",
          visible
            ? "pointer-events-auto translate-y-0 opacity-100"
            : "pointer-events-none -translate-y-2 opacity-0",
          atTop && theme,
        )}
      >
        <Container>
          <div
            className={cn(
              "rounded-2xl px-4 py-3 transition-all duration-300",
              atTop ? getAtTopBg(variant) : SCROLLED_BG,
            )}
          >
            <div className="flex items-center justify-between">
              <Link href="/">
                <Logo />
              </Link>
              <div className="flex items-center gap-8">
                <NavMenu spacing="lg">
                  {NAV_LINKS.map((link) => {
                    const exactMatch = pathname === link.href;
                    const otherExactMatch = NAV_LINKS.some(
                      (other) =>
                        other.href !== link.href && pathname === other.href,
                    );
                    const href = link.href as string;
                    const prefixMatch =
                      !otherExactMatch &&
                      pathname.startsWith(href) &&
                      href !== "/";
                    const isActive = exactMatch || prefixMatch;

                    return (
                      <NavMenuLink
                        key={link.href}
                        href={link.href}
                        isActive={isActive}
                        variant="underline"
                      >
                        {link.label}
                      </NavMenuLink>
                    );
                  })}
                </NavMenu>
                <div className="hidden md:inline-flex">
                  <Button
                    href={NAV_CTA.href}
                    variant="solid"
                    color="root"
                    size="sm"
                  >
                    {NAV_CTA.label}
                  </Button>
                </div>
                <div className="md:hidden">
                  <IconButton
                    variant="ghost"
                    color="root"
                    size="sm"
                    onClick={() => setMobileOpen(true)}
                    aria-label="Open menu"
                  >
                    <MenuIcon />
                  </IconButton>
                </div>
              </div>
            </div>
          </div>
        </Container>
      </nav>

      <MobileMenu open={mobileOpen} onClose={() => setMobileOpen(false)} />
    </>
  );
}

Centered

Floating pill with scroll-hide behavior. Logo left, links absolutely centered, CTA right. Same variant system as Navbar.

bonk/ui

Elevated + Ghost

bonk/ui

API Reference

PropTypeDefault
variant"solid" | "outlined" | "ghost" | "minimal""solid"
themeTheme

Basic Usage

import { NavbarCentered } from "@/extensions/layout";

<NavbarCentered />

Source

src/extensions/layout/NavbarCentered.tsx
"use client";

import React, { useState, useEffect } from "react";
import { usePathname } from "next/navigation";
import {
  NavMenu,
  NavMenuLink,
  Button,
  IconButton,
  Link,
  Logo,
  Container,
} from "@/ui";
import { MenuIcon } from "@/ui/icons";
import { NAV_LINKS, NAV_CTA } from "@/constants";
import type { Theme } from "@/types";
import { cn } from "@/utils";
import { MobileMenu } from "./MobileMenu";

interface NavbarCenteredProps {
  variant?: "solid" | "outlined" | "ghost" | "minimal";
  theme?: Theme;
}

function getAtTopBg(variant: NavbarCenteredProps["variant"]) {
  switch (variant) {
    case "outlined":
      return "border border-root-contrast/20 bg-transparent backdrop-blur-sm";
    case "ghost":
      return "border border-transparent bg-divider backdrop-blur-sm";
    case "minimal":
      return "border border-transparent bg-transparent";
    case "solid":
    default:
      return "border border-divider bg-root/90 shadow-sm backdrop-blur-md";
  }
}

const SCROLLED_BG =
  "border border-transparent bg-root/60 backdrop-blur-lg";

export function NavbarCentered({
  variant = "solid",
  theme,
}: NavbarCenteredProps) {
  const pathname = usePathname();
  const [mobileOpen, setMobileOpen] = useState(false);
  const [visible, setVisible] = useState(true);
  const [atTop, setAtTop] = useState(true);

  useEffect(() => {
    let lastY = window.scrollY;
    const onScroll = () => {
      const currentY = window.scrollY;
      setAtTop(currentY < 78);
      setVisible(currentY < 10 || currentY < lastY);
      lastY = currentY;
    };
    window.addEventListener("scroll", onScroll, { passive: true });
    return () => window.removeEventListener("scroll", onScroll);
  }, []);

  return (
    <>
      <nav
        aria-label="Main navigation"
        className={cn(
          "fixed inset-x-0 top-3 z-40 transition-all duration-300",
          visible
            ? "pointer-events-auto translate-y-0 opacity-100"
            : "pointer-events-none -translate-y-2 opacity-0",
          atTop && theme,
        )}
      >
        <Container>
          <div
            className={cn(
              "rounded-2xl px-4 py-3 transition-all duration-300",
              atTop ? getAtTopBg(variant) : SCROLLED_BG,
            )}
          >
            <div className="relative flex items-center justify-between">
              <Link href="/">
                <Logo />
              </Link>
              <NavMenu
                className="absolute left-1/2 -translate-x-1/2"
                spacing="lg"
              >
                {NAV_LINKS.map((link) => {
                  const exactMatch = pathname === link.href;
                  const otherExactMatch = NAV_LINKS.some(
                    (other) =>
                      other.href !== link.href && pathname === other.href,
                  );
                  const href = link.href as string;
                  const prefixMatch =
                    !otherExactMatch &&
                    pathname.startsWith(href) &&
                    href !== "/";
                  const isActive = exactMatch || prefixMatch;

                  return (
                    <NavMenuLink
                      key={link.href}
                      href={link.href}
                      isActive={isActive}
                      variant="underline"
                    >
                      {link.label}
                    </NavMenuLink>
                  );
                })}
              </NavMenu>
              <div className="hidden md:inline-flex">
                <Button
                  href={NAV_CTA.href}
                  variant="solid"
                  color="root"
                  size="sm"
                >
                  {NAV_CTA.label}
                </Button>
              </div>
              <div className="md:hidden">
                <IconButton
                  variant="ghost"
                  color="root"
                  size="sm"
                  onClick={() => setMobileOpen(true)}
                  aria-label="Open menu"
                >
                  <MenuIcon />
                </IconButton>
              </div>
            </div>
          </div>
        </Container>
      </nav>

      <MobileMenu open={mobileOpen} onClose={() => setMobileOpen(false)} />
    </>
  );
}

Static

Full-width bar with no scroll behavior. Fixed or relative positioning via prop. Best for apps and dashboards.

bonk/ui

API Reference

PropTypeDefault
variant"solid" | "outlined" | "ghost" | "minimal""solid"
position"fixed" | "relative""fixed"
themeTheme

Basic Usage

import { NavbarStatic } from "@/extensions/layout";

<NavbarStatic />

Source

src/extensions/layout/NavbarStatic.tsx
"use client";

import { useState } from "react";
import { usePathname } from "next/navigation";
import {
  NavMenu,
  NavMenuLink,
  Button,
  IconButton,
  Link,
  Logo,
  Container,
} from "@/ui";
import { MenuIcon } from "@/ui/icons";
import { NAV_LINKS, NAV_CTA } from "@/constants";
import type { Theme } from "@/types";
import { cn } from "@/utils";
import { MobileMenu } from "./MobileMenu";

interface NavbarStaticProps {
  variant?: "solid" | "outlined" | "ghost" | "minimal";
  position?: "fixed" | "relative";
  theme?: Theme;
}

function getBg(variant: NavbarStaticProps["variant"]) {
  switch (variant) {
    case "outlined":
      return "border-b border-root-contrast/20 bg-transparent backdrop-blur-sm";
    case "ghost":
      return "border-b border-transparent bg-divider backdrop-blur-sm";
    case "minimal":
      return "border-b border-transparent bg-transparent";
    case "solid":
    default:
      return "border-b border-divider bg-root/90 shadow-sm backdrop-blur-md";
  }
}

export function NavbarStatic({
  variant = "solid",
  position = "fixed",
  theme,
}: NavbarStaticProps) {
  const pathname = usePathname();
  const [mobileOpen, setMobileOpen] = useState(false);

  const isFixed = position === "fixed";

  return (
    <>
      <nav
        aria-label="Main navigation"
        className={cn(
          "inset-x-0 top-0 z-40",
          isFixed ? "fixed" : "relative",
          getBg(variant),
          theme,
        )}
      >
        <Container className="py-3">
          <div className="flex items-center justify-between">
            <Link href="/">
              <Logo />
            </Link>
            <div className="flex items-center gap-8">
              <NavMenu spacing="lg">
                {NAV_LINKS.map((link) => {
                  const exactMatch = pathname === link.href;
                  const otherExactMatch = NAV_LINKS.some(
                    (other) =>
                      other.href !== link.href && pathname === other.href,
                  );
                  const href = link.href as string;
                  const prefixMatch =
                    !otherExactMatch &&
                    pathname.startsWith(href) &&
                    href !== "/";
                  const isActive = exactMatch || prefixMatch;

                  return (
                    <NavMenuLink
                      key={link.href}
                      href={link.href}
                      isActive={isActive}
                      variant="underline"
                    >
                      {link.label}
                    </NavMenuLink>
                  );
                })}
              </NavMenu>
              <div className="hidden md:inline-flex">
                <Button
                  href={NAV_CTA.href}
                  variant="solid"
                  color="root"
                  size="sm"
                >
                  {NAV_CTA.label}
                </Button>
              </div>
              <div className="md:hidden">
                <IconButton
                  variant="ghost"
                  color="root"
                  size="sm"
                  onClick={() => setMobileOpen(true)}
                  aria-label="Open menu"
                >
                  <MenuIcon />
                </IconButton>
              </div>
            </div>
          </div>
        </Container>
      </nav>

      <MobileMenu open={mobileOpen} onClose={() => setMobileOpen(false)} />
    </>
  );
}

Usage Notes

  • All three navbars pull links from NAV_LINKS and the CTA from NAV_CTA in @/constants.
  • Navbar and NavbarCentered are fixed floating pills that hide on scroll-down and reappear on scroll-up.
  • NavbarStatic has no scroll behavior — use position="relative" for an in-flow bar that needs no pt-navbar clearance.
  • Pass variant="minimal" and theme="dark" to float transparently over a dark hero section.
  • Each navbar imports MobileMenu directly — swap it for MobileMenuLayered by editing the navbar file.