Dismissible Announcement Bar
The strip above the navbar, with the three things it usually gets wrong fixed: a named region, a live role only when it earns one, and a dismissal that persists.
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.
components/navbar-simple.tsxnpx hoverlab add navbar-simple
Or over MCP, from your editor's agent — no account needed.
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.
Start a page with this section — add more, order them, and leave with the page source.
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
'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>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/navbar-simple.tsx and import it where you need the section:
import { NavbarSimple } from '@/components/navbar-simple'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.
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.
| Prop | Type | Default |
|---|---|---|
brand | string | 'Acme' |
links | NavbarLink[] | DEFAULT_LINKS |
activeLabelLabel of the current page, matched against `links[].label`. | string | 'Product' |
signInLabel | string | 'Sign in' |
signInHref | string | '#' |
ctaLabel | string | 'Get started' |
ctaHref | string | '#' |
className | string | '' |
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.
<!--
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>
What each framework gets across the whole catalog — effects convert properly; this rung is markup.
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.
Want the whole screen instead of this one section? Open a page and copy it entire.
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 NavigationCopying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.
The strip above the navbar, with the three things it usually gets wrong fixed: a named region, a live role only when it earns one, and a dismissal that persists.
Top-level items that open a described-link panel, opening on hover and focus, closing on escape, outside click and focus leaving the group — and collapsing to an accordion on mobile.
A slide-in site menu that behaves like a dialog: focus trap, focus restoration, body scroll lock and a transition that drops under prefers-reduced-motion.
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.