Skip to content
Marketing block

Responsive Navbar

Brand, links and CTAs with a mobile panel that unmounts when closed — so its links are never tabbable while invisible — plus escape-to-close and focus return.

206 lineslucide-reactAdded 8 Aug 2026Updated 23 Aug 2026
  • navbar
  • nav
  • header
  • menu
  • responsive

What's included

  • components/navbar-simple.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add navbar-simple

Or over MCP, from your editor's agent — no account needed.

License

Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.

Was this useful?

Start a page with this section — add more, order them, and leave with the page source.

Preview

Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.

Source

components/navbar-simple.tsx
'use client'

/**
 * <NavbarSimple> — brand, links, CTAs, and a mobile menu.
 *
 * The navbar most marketing sites actually need. Everything here that looks
 * like decoration is doing a job:
 *
 *  - The mobile panel is toggled by a `<button>` carrying `aria-expanded`
 *    and `aria-controls`, not a checkbox hack. The hack is smaller but it
 *    announces as a checkbox, and "menu, checkbox, not checked" is not what
 *    a screen-reader user needs to hear.
 *  - Escape closes the panel and returns focus to the trigger. Without the
 *    return, a keyboard user who dismisses the menu is dropped at the top
 *    of the document with nothing focused.
 *  - The panel is unmounted when closed rather than hidden with `hidden` or
 *    opacity, so its links are not reachable by tab while invisible — the
 *    classic "tab into nothing" bug in every rolled-by-hand navbar.
 *  - `aria-current="page"` marks the active link. Colour alone does not
 *    survive a screen reader or a colourblind visitor.
 *
 * The bar is sticky and translucent. `backdrop-blur` over `bg-background/70`
 * keeps content legible as it scrolls under, which a solid bar does by
 * hiding it and a fully transparent one fails to do at all.
 */

import * as React from 'react'
import { Menu, X } from 'lucide-react'

export interface NavbarLink {
  label: string
  href: string
}

export interface NavbarSimpleProps {
  brand?: string
  links?: NavbarLink[]
  /** Label of the current page, matched against `links[].label`. */
  activeLabel?: string
  signInLabel?: string
  signInHref?: string
  ctaLabel?: string
  ctaHref?: string
  className?: string
}

const DEFAULT_LINKS: NavbarLink[] = [
  { label: 'Product', href: '#' },
  { label: 'Pricing', href: '#' },
  { label: 'Docs', href: '#' },
  { label: 'Blog', href: '#' },
]

export function NavbarSimple({
  brand = 'Acme',
  links = DEFAULT_LINKS,
  activeLabel = 'Product',
  signInLabel = 'Sign in',
  signInHref = '#',
  ctaLabel = 'Get started',
  ctaHref = '#',
  className = '',
}: NavbarSimpleProps) {
  // Per-instance ids. A literal id in a reusable component is a
  // collision waiting for the second copy on the page — and a <label>
  // then resolves to whichever input rendered first.
  const uid = React.useId()
  const [open, setOpen] = React.useState(false)
  const triggerRef = React.useRef<HTMLButtonElement>(null)

  // Escape closes and hands focus back. Bound only while open, so the
  // listener does not sit on the document for the life of the page.
  React.useEffect(() => {
    if (!open) return
    function onKeyDown(event: KeyboardEvent) {
      if (event.key === 'Escape') {
        setOpen(false)
        triggerRef.current?.focus()
      }
    }
    document.addEventListener('keydown', onKeyDown)
    return () => document.removeEventListener('keydown', onKeyDown)
  }, [open])

  return (
    <header
      className={`sticky top-0 z-40 border-b border-border/40 bg-background/70 backdrop-blur-xl ${className}`}
    >
      <div className="mx-auto flex min-h-16 w-full max-w-7xl flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-2 sm:px-6 lg:px-8">
        {/* Brand */}
        <a href="#" className="flex shrink-0 items-center gap-2.5">
          <span
            aria-hidden
            className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-primary to-emerald-600 text-sm font-black text-primary-foreground shadow-lg shadow-primary/25"
          >
            {brand.slice(0, 1)}
          </span>
          <span className="text-base font-bold tracking-tight">{brand}</span>
        </a>

        {/* Desktop links */}
        <nav aria-label="Main" className="hidden lg:block">
          <ul className="flex items-center gap-1">
            {links.map((link) => {
              const active = link.label === activeLabel
              return (
                <li key={link.label}>
                  <a
                    href={link.href}
                    aria-current={active ? 'page' : undefined}
                    className={`rounded-lg px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
                      active
                        ? 'text-foreground'
                        : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'
                    }`}
                  >
                    {link.label}
                  </a>
                </li>
              )
            })}
          </ul>
        </nav>

        {/* Desktop actions */}
        <div className="hidden items-center gap-2 lg:flex">
          <a
            href={signInHref}
            className="rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
          >
            {signInLabel}
          </a>
          <a
            href={ctaHref}
            className="shrink-0 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground shadow-sm transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
          >
            {ctaLabel}
          </a>
        </div>

        {/* Mobile trigger */}
        <button
          ref={triggerRef}
          type="button"
          onClick={() => setOpen((v) => !v)}
          aria-expanded={open}
          aria-controls={`${uid}-navbar-mobile-panel`}
          aria-label={open ? 'Close menu' : 'Open menu'}
          className="flex h-10 w-10 items-center justify-center rounded-lg border border-border/60 transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring lg:hidden"
        >
          {open ? (
            <X aria-hidden className="h-5 w-5" />
          ) : (
            <Menu aria-hidden className="h-5 w-5" />
          )}
        </button>
      </div>

      {/* Mobile panel */}
      {open ? (
        <div
          id={`${uid}-navbar-mobile-panel`}
          className="border-t border-border/40 bg-background/95 backdrop-blur-xl lg:hidden"
        >
          <nav aria-label="Main" className="mx-auto w-full max-w-7xl px-4 py-4 sm:px-6">
            <ul className="space-y-1">
              {links.map((link) => {
                const active = link.label === activeLabel
                return (
                  <li key={link.label}>
                    <a
                      href={link.href}
                      aria-current={active ? 'page' : undefined}
                      onClick={() => setOpen(false)}
                      className={`block rounded-lg px-3 py-2.5 text-sm font-medium transition-colors ${
                        active ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/50'
                      }`}
                    >
                      {link.label}
                    </a>
                  </li>
                )
              })}
            </ul>

            <div className="mt-4 flex flex-col gap-2 border-t border-border/60 pt-4">
              <a
                href={signInHref}
                className="rounded-lg border border-border/60 px-4 py-2.5 text-center text-sm font-semibold transition-colors hover:bg-muted"
              >
                {signInLabel}
              </a>
              <a
                href={ctaHref}
                className="rounded-lg bg-primary px-4 py-2.5 text-center text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90"
              >
                {ctaLabel}
              </a>
            </div>
          </nav>
        </div>
      ) : null}
    </header>
  )
}

Before you paste

  • Styling is Tailwind utility classes on semantic tokens (bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.
  • Install: npm i lucide-react
  • Every prop has a default, so it renders standalone before you wire it up.

Where it goes

Drop it at components/navbar-simple.tsx and import it where you need the section:

import { NavbarSimple } from '@/components/navbar-simple'

Customize

6 of this block’s props are simple enough to drive from here. Change them and the block below re-renders — it is the same component whose source is above, not a mock of it. Everything else it accepts is in the table underneath.

Props

Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.

PropTypeDefault
brandstring'Acme'
linksNavbarLink[]DEFAULT_LINKS
activeLabelLabel of the current page, matched against `links[].label`.string'Product'
signInLabelstring'Sign in'
signInHrefstring'#'
ctaLabelstring'Get started'
ctaHrefstring'#'
classNamestring''

Not using React?

The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.

This block is interactive. The markup below is its initial state with the event handlers stripped — you will need to re-wire the behaviour in your framework.

navbar-simple.html
<!--
  Responsive Navbar — markup from the Hoverlab catalog.

  This is the block rendered once to HTML and wrapped as a component
  file. It is not a port of the React source: the Tailwind classes carry
  the design, which is the part that took the work, and they are the same
  in every framework.

  This block is interactive in React and the handlers are NOT here.
  Buttons, toggles and menus render in their initial state and do
  nothing until you wire them up.
-->
<header class="sticky top-0 z-40 border-b border-border/40 bg-background/70 backdrop-blur-xl ">
  <div class="mx-auto flex min-h-16 w-full max-w-7xl flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-2 sm:px-6 lg:px-8">
    <a href="#" class="flex shrink-0 items-center gap-2.5">
      <span aria-hidden="true" class="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-primary to-emerald-600 text-sm font-black text-primary-foreground shadow-lg shadow-primary/25">A</span>
      <span class="text-base font-bold tracking-tight">Acme</span>
    </a>
    <nav aria-label="Main" class="hidden lg:block">
      <ul class="flex items-center gap-1">
        <li>
          <a href="#" aria-current="page" class="rounded-lg px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring text-foreground">Product</a>
        </li>
        <li>
          <a href="#" class="rounded-lg px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring text-muted-foreground hover:bg-muted/50 hover:text-foreground">Pricing</a>
        </li>
        <li>
          <a href="#" class="rounded-lg px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring text-muted-foreground hover:bg-muted/50 hover:text-foreground">Docs</a>
        </li>
        <li>
          <a href="#" class="rounded-lg px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring text-muted-foreground hover:bg-muted/50 hover:text-foreground">Blog</a>
        </li>
      </ul>
    </nav>
    <div class="hidden items-center gap-2 lg:flex">
      <a href="#" class="rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">Sign in</a>
      <a href="#" class="shrink-0 rounded-lg bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground shadow-sm transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">Get started</a>
    </div>
    <button type="button" aria-expanded="false" aria-controls="_R_0_-navbar-mobile-panel" aria-label="Open menu" class="flex h-10 w-10 items-center justify-center rounded-lg border border-border/60 transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring lg:hidden">
      <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-menu h-5 w-5" aria-hidden="true">
        <path d="M4 12h16"></path>
        <path d="M4 18h16"></path>
        <path d="M4 6h16"></path>
      </svg>
    </button>
  </div>
</header>
  • This is rendered HTML, not a translation of the React source. The Tailwind classes carry the design and work in any framework.
  • It is one frame: the component in its initial state, with no props applied beyond the defaults.
  • This block is interactive in React — toggles, menus or form state. None of that survives here; the markup is the closed/default state and the handlers are gone. Re-wire them in your own framework.
  • Requires Tailwind, and the design tokens the classes reference (bg-card, text-muted-foreground, and so on). The template ZIPs ship a globals.css that defines them.

What each framework gets across the whole catalog — effects convert properly; this rung is markup.

For AI

The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.

See the prompt

Used in these pages

Want the whole screen instead of this one section? Open a page and copy it entire.

4 more blocks in Navigation

All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.

Browse Navigation

Shipping one commercially

Copying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.

  • A commercial licence for everything in the catalog
  • Unlimited bundle exports, in Vue, Svelte and Tailwind
  • One payment — no subscription, nothing to renew
Pro — $79 once

More Navigation blocks

View category
Open the full page for this block

Signed-in Application Bar

The bar above an app rather than a site: workspace and environment named in the open, a bell that counts in words, and an impersonation strip that cannot be dismissed.

Navigation318 lines1 dep