block

Topbar

A responsive navigation bar block that sticks to the top of the page. Renders a full-width container with support for a logo, desktop navigation links, and a theme mode toggle. On smaller screens, the menu collapses into a slide-up drawer triggered by a menu button.

Default

Provide menuItems for the navigation links and place your logo in the logo slot.

Navigation

---
// GENERATED from packages/demos/topbar/default.demo.tsx — edit the source, not this file.
import Topbar from "@pindoba/astro-topbar";

const menuItems = [
  { label: "Home", href: "#" },
  { label: "Docs", href: "#" },
  {
    label: "Catalog",
    items: [
      { label: "Products", href: "#" },
      { label: "Collections", href: "#" },
    ],
  },
  { label: "Blog", href: "#" },
];
---

<Topbar menuItems={menuItems} menuActiveItem="Home" />

Custom

Use showAppearance to hide the built-in Appearance picker (theme mode, palette, density and corners) — appearanceProps configures both copies, and desktopAppearanceProps / mobileAppearanceProps one side each — menuActiveItem to highlight the current page, and passThrough to override styles on individual slots such as container for layout adjustments.

Without Theme Mode Select

MyApp

With Active Menu Item

Pindoba

Navigation

Custom Background

Pindoba

Navigation

---
// GENERATED from packages/demos/topbar/custom.demo.tsx — edit the source, not this file.
import Topbar from "@pindoba/astro-topbar";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";

const menuItems = [
  { label: "Home", href: "#" },
  { label: "Docs", href: "#" },
  { label: "Examples", href: "#" },
];
---

<div class={stack({ gap: "xl", direction: "column", width: "100%" })}>
  <div>
    <h3>Without Theme Mode Select</h3>
    <Topbar menuItems={menuItems} showAppearance={false}>
      <div
        slot="logo"
        class={css({
          fontSize: "lg",
          fontWeight: "bold",
          color: "primary.text",
        })}
      >
        MyApp
      </div>
    </Topbar>
  </div>

  <div>
    <h3>With Active Menu Item</h3>
    <Topbar menuItems={menuItems} menuActiveItem="Docs">
      <div slot="logo" class={css({ fontSize: "lg", fontWeight: "bold" })}>
        Pindoba
      </div>
    </Topbar>
  </div>

  <div>
    <h3>Custom Background</h3>
    <Topbar
      menuItems={menuItems}
      passThrough={{
        root: css.raw({ backgroundColor: "primary.surface.ground" }),
      }}
    >
      <div
        slot="logo"
        class={css({
          fontSize: "lg",
          fontWeight: "bold",
          color: "primary.text.contrast.bold",
        })}
      >
        Pindoba
      </div>
    </Topbar>
  </div>
</div>

Client-side routing

Everything in navigationProps reaches both Navigations — the desktop bar and the vertical one inside the mobile drawer — so routing works exactly as in Navigation’s router integration: pass currentHref for the active row and either onNavigate or (React/Vue) a linkComponent.

<Topbar
  menuItems={items}
  navigationProps={{
    currentHref: location.pathname,
    onNavigate: (href, event) => {
      event.preventDefault();
      router.navigate({ to: href });
    },
  }}
/>

The drawer closes itself after a navigation. A plain click on a link inside the mobile drawer closes it — whether your router handled the click client-side (it ended defaultPrevented) or the browser follows the link natively. The drawer closes only after the click has been dispatched, so your router’s <Link> / onNavigate always receives it first. Cmd/Ctrl/Shift/Alt and middle clicks, target="_blank", download and external links (the same isPlainNavigationClick rule Navigation uses), and submenu toggle buttons leave the drawer open. Focus is not sent back to the hamburger after a navigation — the router or the new page owns it; Escape and the close button still return focus to the trigger as usual.

Controlled drawer. Pass mobileOpen with onMobileOpenChange(open) to own the drawer’s state (the same open / onOpenChange pairing as Dialog). onMobileOpenChange also fires uncontrolled, and reports the automatic close after a navigation as false. In Svelte bind:mobileOpen works; in Vue, v-model:mobileOpen.

const [menuOpen, setMenuOpen] = useState(false);

<Topbar
  menuItems={items}
  mobileOpen={menuOpen}
  onMobileOpenChange={setMenuOpen}
/>;

Astro. There are no client-side props: listen for the bubbling pindoba:navigate event on the topbar to route (the drawer closes the same way), observe the drawer through the pindoba:topbar-mobile-change event (event.detail is { open, reason }, with reason: "navigate" for a close caused by a followed link), and open or close it with setTopbarMobileOpen:

import {
  setTopbarMobileOpen,
  TOPBAR_MOBILE_CHANGE_EVENT,
} from "@pindoba/astro-topbar/runtime";

const topbar = document.querySelector("[data-component='topbar']");
topbar.addEventListener(TOPBAR_MOBILE_CHANGE_EVENT, (event) => {
  console.log(event.detail.open, event.detail.reason);
});
setTopbarMobileOpen(topbar, false);

The demo runs a tiny in-memory router. Narrow the window, open the menu and pick an item: the route changes without a page load, the current item follows, and the drawer closes.

Navigation

Route: ?route=inbox · client-side navigations: 0

---
// GENERATED from packages/demos/topbar/router-links.demo.tsx — edit the source, not this file.
import Topbar from "@pindoba/astro-topbar";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";

const menuItems = [
  { label: "Inbox", href: "?route=inbox" },
  { label: "Drafts", href: "?route=drafts" },
  {
    label: "Settings",
    items: [
      { label: "Profile", href: "?route=settings/profile" },
      { label: "Billing", href: "?route=settings/billing" },
    ],
  },
];
const status = css({ fontSize: "sm", color: "neutral.text.muted" });

const path = "?route=inbox";
const navigations = 0;
---

<div class={stack({ gap: "md" })} data-ir-root="topbar/router-links">
  <Topbar
    menuItems={menuItems}
    navigationProps={{
      currentHref: path,
    }}
    data-ir="1"
  />
  <p class={status} data-router-status>
    Route:{" "}
    <code>
      <!--ir:2-->
      {path}
      <!--/ir-->
    </code>{" "}
    · client-side navigations: <!--ir:3-->
    {navigations}
    <!--/ir-->
  </p>
</div>

<script>
  import { hydrate } from "./_client/router-links.ts";

  document
    .querySelectorAll('[data-ir-root="topbar/router-links"]')
    .forEach((root) => hydrate([root]));
</script>
  • Components
  • Blocks