sections
Site Navbar
A sticky responsive top navigation bar that transitions from fully transparent to a glass/blur backdrop with a hairline border as the user scrolls past a 24 px threshold — the surface shift is a smooth CSS transition on backdrop-filter + background-color + border-color. Layout uses CSS grid (brand wordmark left · nav links centred · primary CTA right). On mobile (below md) links and CTA collapse behind a hamburger toggle that opens a height-animated dropdown sheet via Motion AnimatePresence; the menu closes on link click, Escape (focus returns to the trigger button), or outside pointer-down. All interaction state is managed internally — no controller wrapper needed.
Scroll to watch the navbar settle onto its glass surface. The bar is transparent at the top and gains a blurred backdrop with a hairline border once you move past the first few rows of content.
Scroll to watch the navbar settle onto its glass surface. The bar is transparent at the top and gains a blurred backdrop with a hairline border once you move past the first few rows of content.
Scroll to watch the navbar settle onto its glass surface. The bar is transparent at the top and gains a blurred backdrop with a hairline border once you move past the first few rows of content.
Scroll to watch the navbar settle onto its glass surface. The bar is transparent at the top and gains a blurred backdrop with a hairline border once you move past the first few rows of content.
Scroll to watch the navbar settle onto its glass surface. The bar is transparent at the top and gains a blurred backdrop with a hairline border once you move past the first few rows of content.
Scroll to watch the navbar settle onto its glass surface. The bar is transparent at the top and gains a blurred backdrop with a hairline border once you move past the first few rows of content.
Scroll to watch the navbar settle onto its glass surface. The bar is transparent at the top and gains a blurred backdrop with a hairline border once you move past the first few rows of content.
Scroll to watch the navbar settle onto its glass surface. The bar is transparent at the top and gains a blurred backdrop with a hairline border once you move past the first few rows of content.
Scroll to watch the navbar settle onto its glass surface. The bar is transparent at the top and gains a blurred backdrop with a hairline border once you move past the first few rows of content.
Scroll to watch the navbar settle onto its glass surface. The bar is transparent at the top and gains a blurred backdrop with a hairline border once you move past the first few rows of content.
Scroll to watch the navbar settle onto its glass surface. The bar is transparent at the top and gains a blurred backdrop with a hairline border once you move past the first few rows of content.
Scroll to watch the navbar settle onto its glass surface. The bar is transparent at the top and gains a blurred backdrop with a hairline border once you move past the first few rows of content.
Scroll to watch the navbar settle onto its glass surface. The bar is transparent at the top and gains a blurred backdrop with a hairline border once you move past the first few rows of content.
Scroll to watch the navbar settle onto its glass surface. The bar is transparent at the top and gains a blurred backdrop with a hairline border once you move past the first few rows of content.
Install
1. Register the namespace (once per project):
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
npx shadcn add @bottega/site-navbarUsage
<SiteNavbar />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| brand | string | Bottega | Brand wordmark shown on the left. |
| links | SiteNavbarLink[] | [ { label: "Work", href: "#work" }, { label: "Components", href: "#components" }, { label: "Pricing", href: "#pricing" }, { label: "About", href: "#about" }, ] | Navigation links (desktop nav + mobile menu). |
| cta | SiteNavbarCta | { label: "Get started", href: "#" } | Primary CTA button on the right. |
Source
"use client";
import { useEffect, useRef, useState } from "react";
import { motion, AnimatePresence, useReducedMotion } from "motion/react";
import styles from "./site-navbar.module.css";
// Expo-out: snappy arrival — matches the other Bottega section choreography.
// These are Motion JS durations in SECONDS, not the --duration CSS token (~240ms).
const EASE_EXPO_OUT = [0.16, 1, 0.3, 1] as const;
const MENU_DURATION = 0.22; // seconds — brief, tactile mobile reveal
const MOBILE_MENU_ID = "site-navbar-mobile-menu";
export interface SiteNavbarLink {
label: string;
href: string;
}
export interface SiteNavbarCta {
label: string;
href: string;
}
export interface SiteNavbarProps {
/** Brand wordmark shown on the left. */
brand?: string;
/** Navigation links (desktop nav + mobile menu). */
links?: SiteNavbarLink[];
/** Primary CTA button on the right. */
cta?: SiteNavbarCta;
}
const DEFAULT_LINKS: SiteNavbarLink[] = [
{ label: "Work", href: "#work" },
{ label: "Components", href: "#components" },
{ label: "Pricing", href: "#pricing" },
{ label: "About", href: "#about" },
];
const DEFAULT_CTA: SiteNavbarCta = { label: "Get started", href: "#" };
function cx(...parts: Array<string | false | undefined>): string {
return parts.filter(Boolean).join(" ");
}
export function SiteNavbar({
brand = "Bottega",
links = DEFAULT_LINKS,
cta = DEFAULT_CTA,
}: SiteNavbarProps) {
const [scrolled, setScrolled] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
// Mount-gate + null-safe reduced-motion (Bottega contract §hydration-safe):
// SSR and first client render are identical (mounted=false → no animation).
// The animated layer only activates after hydration for non-reduced users.
const [mounted, setMounted] = useState(false);
const prefersReducedMotion = useReducedMotion();
const reduce = prefersReducedMotion === null || prefersReducedMotion; // null → reduced
const animate = mounted && !reduce;
const hamburgerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setMounted(true);
}, []);
// Glass backdrop driven by a zero-height top sentinel + IntersectionObserver.
// IO accounts for clipping by ANY scroll ancestor, so this fires whether the
// page scrolls the window OR the navbar sits inside a nested scroll container
// (a dashboard shell, the gallery preview) — a window-scroll listener misses
// the nested case and leaves the bar permanently transparent.
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const io = new IntersectionObserver(
([entry]) => setScrolled(!entry.isIntersecting),
{ threshold: 0 },
);
io.observe(el);
return () => io.disconnect();
}, []);
// Escape closes the mobile menu and returns focus to the trigger.
useEffect(() => {
if (!menuOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setMenuOpen(false);
hamburgerRef.current?.focus();
}
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [menuOpen]);
// Outside pointer-down closes the menu.
useEffect(() => {
if (!menuOpen) return;
const onPointer = (e: PointerEvent) => {
const target = e.target as Node;
const outsideMenu =
menuRef.current && !menuRef.current.contains(target);
const outsideTrigger =
hamburgerRef.current && !hamburgerRef.current.contains(target);
if (outsideMenu && outsideTrigger) setMenuOpen(false);
};
document.addEventListener("pointerdown", onPointer);
return () => document.removeEventListener("pointerdown", onPointer);
}, [menuOpen]);
const closeMenu = () => setMenuOpen(false);
return (
<>
{/* Zero-height scroll sentinel — once it leaves the viewport (page or nested
scroller) the sticky bar transitions to its glass surface. */}
<div ref={sentinelRef} aria-hidden className="h-0" />
<nav
className={cx(styles.navbar, scrolled && styles.scrolled)}
aria-label="Main navigation"
>
{/* Three-column grid: brand | centred links | right actions */}
<div className="mx-auto grid max-w-6xl grid-cols-[auto_1fr_auto] items-center gap-4 px-4 sm:px-6 lg:px-8 h-[60px]">
{/* Brand wordmark — left */}
<a
href="/"
className={cx(
"flex-shrink-0 text-base font-semibold tracking-tight text-foreground",
"rounded-[var(--radius)] px-1 py-0.5",
"transition-opacity duration-[var(--duration)] ease-[var(--ease)]",
"hover:opacity-70",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
aria-label={`${brand} — go to homepage`}
>
{brand}
</a>
{/* Desktop nav links — centred; hidden on mobile */}
<ul
className="hidden md:flex items-center justify-center gap-6 list-none m-0 p-0"
role="list"
>
{links.map((link) => (
<li key={link.label}>
<a
href={link.href}
className={cx(
"text-sm font-medium text-foreground",
"opacity-60 hover:opacity-100",
"rounded-[var(--radius)] px-1 py-0.5",
"transition-opacity duration-[var(--duration)] ease-[var(--ease)]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
>
{link.label}
</a>
</li>
))}
</ul>
{/* Right slot: CTA (desktop) + hamburger (mobile) */}
<div className="flex items-center justify-end gap-2">
{/* Primary CTA — desktop only */}
<a
href={cta.href}
className={cx(
"hidden md:inline-flex items-center justify-center",
"px-4 py-2 text-sm font-medium",
"bg-primary text-primary-foreground",
"rounded-[var(--radius)]",
"transition-[filter] duration-[var(--duration)] ease-[var(--ease)]",
"hover:brightness-90",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
>
{cta.label}
</a>
{/* Hamburger toggle — mobile only */}
<button
ref={hamburgerRef}
type="button"
aria-expanded={menuOpen}
aria-controls={menuOpen ? MOBILE_MENU_ID : undefined}
aria-label={menuOpen ? "Close navigation menu" : "Open navigation menu"}
onClick={() => setMenuOpen((prev) => !prev)}
className={cx(
"md:hidden flex items-center justify-center",
"w-9 h-9 rounded-[var(--radius)]",
"text-foreground",
"transition-colors duration-[var(--duration)] ease-[var(--ease)]",
"hover:bg-muted",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
>
<HamburgerIcon open={menuOpen} />
</button>
</div>
</div>
{/* Mobile dropdown — height-animated via Motion AnimatePresence */}
<AnimatePresence>
{menuOpen && (
<motion.div
ref={menuRef}
id={MOBILE_MENU_ID}
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{
// ponytail: duration: 0 gives instant toggle for reduced-motion users
duration: animate ? MENU_DURATION : 0,
ease: EASE_EXPO_OUT,
}}
className={cx("overflow-hidden md:hidden", styles.mobileMenu)}
>
<ul
className="flex flex-col gap-0.5 px-4 pb-4 pt-2 list-none m-0"
role="list"
>
{links.map((link) => (
<li key={link.label}>
<a
href={link.href}
onClick={closeMenu}
className={cx(
"block w-full px-3 py-2.5 text-sm font-medium text-foreground",
"opacity-70 hover:opacity-100",
"rounded-[var(--radius)]",
"transition-[background-color,opacity] duration-[var(--duration)] ease-[var(--ease)]",
"hover:bg-muted",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
>
{link.label}
</a>
</li>
))}
{/* CTA in mobile menu — separated by a hairline */}
<li className="mt-2 pt-2 border-t border-border">
<a
href={cta.href}
onClick={closeMenu}
className={cx(
"flex items-center justify-center w-full",
"px-4 py-2.5 text-sm font-medium",
"bg-primary text-primary-foreground",
"rounded-[var(--radius)]",
"transition-[filter] duration-[var(--duration)] ease-[var(--ease)]",
"hover:brightness-90",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
>
{cta.label}
</a>
</li>
</ul>
</motion.div>
)}
</AnimatePresence>
</nav>
</>
);
}
/** Static hamburger ↔ X SVG icon — switches on open state (no Motion needed here). */
function HamburgerIcon({ open }: { open: boolean }) {
return (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
aria-hidden="true"
focusable="false"
>
{open ? (
<>
<line
x1="4" y1="4" x2="16" y2="16"
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"
/>
<line
x1="16" y1="4" x2="4" y2="16"
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"
/>
</>
) : (
<>
<line
x1="3" y1="5" x2="17" y2="5"
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"
/>
<line
x1="3" y1="10" x2="17" y2="10"
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"
/>
<line
x1="3" y1="15" x2="17" y2="15"
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"
/>
</>
)}
</svg>
);
}
Dependencies
- motion
- @bottega/tokens