Navigation
Tawny Navbar
Scroll-aware site chrome with brand mark, desktop links, and mobile menu.
components/navbar
Tawny
DarkBrowse designs
Source
Copy the files below into your project.
apps/web/components/navbar.tsx
'use client'
import {
useState,
useEffect,
useLayoutEffect,
useRef,
type RefObject,
} from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { Menu, X } from 'lucide-react'
import { Logo } from '@/components/logo'
import { ThemeToggle } from '@/components/theme-toggle'
import { cn } from '@tawny/ui/lib/utils'
const navLinks = [
{ label: 'Designs', href: '/designs' },
{ label: 'Components', href: '/components' },
{ label: 'Changelog', href: '/changelog' },
]
/** Gap kept between side chrome and the centered nav band. */
const NAV_SIDE_GAP = 16
function isNavActive(pathname: string, href: string) {
return pathname === href || pathname.startsWith(`${href}/`)
}
/**
* Reserve equal side gutters from the wider chrome cluster so the nav band
* stays mathematically centered:
* side = max(left, right) + gap
* navWidth = total − 2 × side
*/
function useCenteredNavSidePad(
barRef: RefObject<HTMLElement | null>,
leftRef: RefObject<HTMLElement | null>,
rightRef: RefObject<HTMLElement | null>,
) {
const [sidePad, setSidePad] = useState(0)
useLayoutEffect(() => {
const bar = barRef.current
const left = leftRef.current
const right = rightRef.current
if (!bar || !left || !right) return
const update = () => {
const barRect = bar.getBoundingClientRect()
const leftRect = left.getBoundingClientRect()
const rightRect = right.getBoundingClientRect()
// Desktop right cluster is `display: none` below md — zero boxes would
// blow out the gutter math, so wait until both clusters are laid out.
if (leftRect.width === 0 || rightRect.width === 0) return
// Distance from bar edge to the inner edge of each cluster.
const leftExtent = leftRect.right - barRect.left
const rightExtent = barRect.right - rightRect.left
setSidePad(Math.max(leftExtent, rightExtent) + NAV_SIDE_GAP)
}
update()
const ro = new ResizeObserver(update)
ro.observe(bar)
ro.observe(left)
ro.observe(right)
window.addEventListener('resize', update)
return () => {
ro.disconnect()
window.removeEventListener('resize', update)
}
}, [barRef, leftRef, rightRef])
return sidePad
}
export function Navbar() {
const [scrolled, setScrolled] = useState(false)
const [mobileOpen, setMobileOpen] = useState(false)
const pathname = usePathname()
const barRef = useRef<HTMLDivElement>(null)
const leftRef = useRef<HTMLAnchorElement>(null)
const rightRef = useRef<HTMLDivElement>(null)
const sidePad = useCenteredNavSidePad(barRef, leftRef, rightRef)
useEffect(() => {
const handleScroll = () => setScrolled(window.scrollY > 16)
window.addEventListener('scroll', handleScroll, { passive: true })
return () => window.removeEventListener('scroll', handleScroll)
}, [])
// Close mobile menu on route change
useEffect(() => setMobileOpen(false), [pathname])
return (
<header
className={cn(
'fixed top-0 left-0 right-0 z-50 transition-all duration-300',
scrolled
? 'bg-background/80 backdrop-blur-xl border-b border-border shadow-sm'
: // Mobile always gets a subtle scrim so the logo/links stay readable over the hero;
// desktop stays fully transparent until scroll.
'bg-background/35 backdrop-blur-md border-b border-border/60 md:bg-transparent md:backdrop-blur-none md:border-transparent'
)}
>
<div
ref={barRef}
className="relative mx-auto flex h-14 max-w-340 items-center px-6"
>
{/* Logo */}
<Link
ref={leftRef}
href="/"
className="relative z-10 flex items-center gap-2.5 group"
aria-label="Tawny home"
>
<Logo className="size-8 rounded-md group-hover:opacity-80 transition-opacity" />
<span className="font-serif text-xl italic tracking-tight text-foreground">Tawny</span>
</Link>
{/* Desktop nav — band width = total − 2 × max(left, right) */}
<nav
className="pointer-events-none absolute inset-y-0 hidden items-center justify-center md:flex"
style={{ left: sidePad, right: sidePad }}
aria-label="Main navigation"
>
<div className="pointer-events-auto flex min-w-0 items-center gap-1">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className={cn(
'px-3 py-1.5 rounded-md text-sm transition-colors',
isNavActive(pathname, link.href)
? 'text-foreground bg-muted'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/60'
)}
>
{link.label}
</Link>
))}
</div>
</nav>
{/* CTA + theme */}
<div
ref={rightRef}
className="relative z-10 ml-auto hidden items-center gap-2 md:flex"
>
<ThemeToggle />
<Link
href="/designs"
className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-full bg-foreground text-background text-sm font-medium hover:opacity-85 transition-opacity"
>
Browse designs
</Link>
</div>
{/* Mobile controls */}
<div className="relative z-10 ml-auto flex items-center gap-1 md:hidden">
<ThemeToggle />
<button
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
aria-label={mobileOpen ? 'Close menu' : 'Open menu'}
aria-expanded={mobileOpen}
onClick={() => setMobileOpen((v) => !v)}
>
{mobileOpen ? <X size={18} /> : <Menu size={18} />}
</button>
</div>
</div>
{/* Mobile menu */}
{mobileOpen && (
<div className="md:hidden bg-background/95 backdrop-blur-xl border-b border-border px-6 pb-5 pt-2 flex flex-col gap-1">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className={cn(
'px-3 py-2 rounded-md text-sm transition-colors',
isNavActive(pathname, link.href)
? 'text-foreground bg-muted'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/60'
)}
>
{link.label}
</Link>
))}
<div className="mt-2 pt-3 border-t border-border">
<Link
href="/designs"
className="flex items-center justify-center px-4 py-2 rounded-full bg-foreground text-background text-sm font-medium hover:opacity-85 transition-opacity"
>
Browse designs
</Link>
</div>
</div>
)}
</header>
)
}apps/web/components/logo.tsx
import { cn } from '@tawny/ui/lib/utils'
/**
* Tawny brand mark — a double crescent inside a rounded field.
*
* Uses the `tawny` / `tawny-foreground` theme tokens so the mark tracks
* the same warm accent used elsewhere on the site (e.g. the hero wordmark dot).
*/
export function Logo({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 260 260"
fill="none"
aria-hidden="true"
className={cn('shrink-0', className)}
>
<rect x="30" y="30" width="200" height="200" rx="44" className="fill-tawny" />
<path
d="M130 75 A55 55 0 0 1 130 185 A70 70 0 0 0 130 75 Z"
className="fill-tawny-foreground"
/>
<path
d="M130 75 A55 55 0 0 0 130 185 A70 70 0 0 1 130 75 Z"
className="fill-tawny-foreground"
opacity="0.4"
/>
</svg>
)
}