Navigation
GlassNavbar
A frosted top bar that firms up once the page scrolls. It fits its own width: links in a row when wide, a menu button with a drop-down panel when narrow.
Download
- React
- Tailwind CSS v4
- motion
components/ui/glass-navbar.tsx
"use client";
import { useId, useRef, useState, type MouseEvent, type ReactNode, type RefObject } from "react";
import {
AnimatePresence,
motion,
useMotionValueEvent,
useReducedMotion,
useScroll,
} from "motion/react";
export type GlassNavItem = { label: string; href: string };
type GlassNavbarProps = {
/** Logo or site name on the left. */
brand: ReactNode;
items: GlassNavItem[];
/** Button on the right, e.g. a sign-up link. */
action?: ReactNode;
/** href of the active link at start. Defaults to the first item. */
active?: string;
/** Called on click. Call e.preventDefault() to stop the browser following the link. */
onSelect?: (item: GlassNavItem, e: MouseEvent<HTMLAnchorElement>) => void;
/** Pixels scrolled before the glass turns solid. */
threshold?: number;
/** Scrolling box to watch. Leave out to watch the page. */
container?: RefObject<HTMLElement | null>;
/** Colour of the active underline. */
accent?: string;
/** Name read out by screen readers. */
label?: string;
className?: string;
};
/* GlassNavbar: a frosted top bar that firms up once the page scrolls. Fits its box: links on wide, a menu button on narrow. */
export function GlassNavbar({
brand,
items,
action,
active,
onSelect,
threshold = 24,
container,
accent = "#a78bfa",
label = "Main",
className = "text-sm text-white",
}: GlassNavbarProps) {
const reduce = useReducedMotion();
const uid = useId();
const toggle = useRef<HTMLButtonElement>(null);
const [scrolled, setScrolled] = useState(false);
const [open, setOpen] = useState(false);
const [current, setCurrent] = useState(active ?? items[0]?.href);
const { scrollY } = useScroll({ container });
useMotionValueEvent(scrollY, "change", (y) => setScrolled(y > threshold));
const pick = (item: GlassNavItem, e: MouseEvent<HTMLAnchorElement>) => {
setCurrent(item.href);
setOpen(false);
onSelect?.(item, e);
};
const ease = reduce ? { duration: 0 } : { duration: 0.3, ease: "easeOut" as const };
return (
<header
className={`@container w-full ${className}`}
onKeyDown={(e) => {
if (e.key === "Escape" && open) {
setOpen(false);
toggle.current?.focus();
}
}}
>
<motion.div
initial={false}
animate={{
backgroundColor: scrolled ? "rgba(18,15,34,0.78)" : "rgba(255,255,255,0.04)",
borderColor: scrolled ? "rgba(255,255,255,0.14)" : "rgba(255,255,255,0.06)",
boxShadow: scrolled ? "0 12px 32px -12px rgba(0,0,0,0.6)" : "0 0 0 0 rgba(0,0,0,0)",
}}
transition={ease}
className="relative flex items-center gap-4 rounded-2xl border px-4 py-3 backdrop-blur-xl backdrop-saturate-150"
>
<div className="shrink-0 font-semibold">{brand}</div>
<nav aria-label={label} className="ml-auto hidden @xl:block">
<ul className="flex items-center gap-1">
{items.map((item) => {
const on = item.href === current;
return (
<li key={item.href} className="relative">
<a
href={item.href}
aria-current={on ? "page" : undefined}
onClick={(e) => pick(item, e)}
className={`block rounded-lg px-3 py-1.5 outline-none transition-colors hover:text-white focus-visible:ring-2 focus-visible:ring-white/70 ${
on ? "text-white" : "text-white/65"
}`}
>
{item.label}
</a>
{on && (
<motion.span
layoutId={`${uid}-line`}
aria-hidden
className="absolute inset-x-3 -bottom-0.5 h-px"
style={{ background: accent }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 34 }}
/>
)}
</li>
);
})}
</ul>
</nav>
{action && <div className="hidden shrink-0 @xl:block">{action}</div>}
<button
ref={toggle}
type="button"
aria-expanded={open}
aria-controls={`${uid}-menu`}
aria-label={open ? "Close menu" : "Open menu"}
onClick={() => setOpen((v) => !v)}
className="relative ml-auto grid size-9 place-items-center rounded-lg outline-none hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-white/70 @xl:hidden"
>
<span
aria-hidden
className={`absolute h-px w-4 bg-current transition-transform duration-200 ${open ? "rotate-45" : "-translate-y-1"}`}
/>
<span
aria-hidden
className={`absolute h-px w-4 bg-current transition-transform duration-200 ${open ? "-rotate-45" : "translate-y-1"}`}
/>
</button>
</motion.div>
<AnimatePresence>
{open && (
<motion.div
id={`${uid}-menu`}
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={reduce ? { duration: 0 } : { duration: 0.2, ease: "easeOut" }}
className="mt-2 rounded-2xl border border-white/10 bg-[#120f22]/90 p-2 backdrop-blur-xl @xl:hidden"
>
<nav aria-label={label}>
<ul>
{items.map((item) => {
const on = item.href === current;
return (
<li key={item.href}>
<a
href={item.href}
aria-current={on ? "page" : undefined}
onClick={(e) => pick(item, e)}
className={`flex items-center justify-between rounded-xl px-3 py-2.5 outline-none hover:bg-white/5 focus-visible:ring-2 focus-visible:ring-white/70 ${
on ? "text-white" : "text-white/65"
}`}
>
{item.label}
{on && <span aria-hidden className="size-1.5 rounded-full" style={{ background: accent }} />}
</a>
</li>
);
})}
</ul>
</nav>
{action && <div className="mt-2 border-t border-white/10 px-1 pt-3 pb-1">{action}</div>}
</motion.div>
)}
</AnimatePresence>
</header>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/glass-navbar.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/glass-navbar-demo.tsx
"use client"; import { useRef } from "react"; import { GlassNavbar } from "@/components/ui/glass-navbar"; const ITEMS = [ { label: "Product", href: "#product" }, { label: "Pricing", href: "#pricing" }, { label: "Docs", href: "#docs" }, { label: "Blog", href: "#blog" }, ]; export default function GlassNavbarExample() { // The preview scrolls inside its own box. On a real page, leave out `container`. const box = useRef<HTMLDivElement>(null); return ( <div ref={box} className="relative h-full overflow-y-auto bg-[#0b0a14]"> <div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 h-72"> <div className="absolute top-6 left-[10%] size-40 rounded-full bg-[#a78bfa]/40 blur-3xl" /> <div className="absolute top-16 right-[10%] size-36 rounded-full bg-[#22d3ee]/30 blur-3xl" /> </div> <div className="sticky top-0 z-10 p-3"> <GlassNavbar brand={ <span className="flex items-center gap-2"> <span className="size-5 rounded-md bg-[#a78bfa]" /> Lumen </span> } items={ITEMS} action={ <a href="#start" onClick={(e) => e.preventDefault()} className="inline-block rounded-full bg-white px-4 py-1.5 text-sm font-medium text-[#0b0a14]" > Get started </a> } container={box} onSelect={(_, e) => e.preventDefault()} /> </div> <div className="relative px-6 pt-8 pb-10"> <p className="text-center text-xs text-white/40">Scroll inside this box: the glass firms up.</p> <div className="mx-auto mt-6 grid max-w-xl gap-3"> {Array.from({ length: 8 }, (_, i) => ( <div key={i} className="h-16 rounded-xl border border-white/5 bg-white/[0.03]" /> ))} </div> </div> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| brand | ReactNode | โ | Logo or site name on the left. |
| items | { label, href }[] | โ | The links. |
| action | ReactNode | โ | Button on the right, e.g. a sign-up link. |
| active | string | first href | href of the active link at start. |
| onSelect | (item, e) => void | โ | Click handler. Call e.preventDefault() to stay on the page. |
| threshold | number | 24 | Pixels scrolled before the glass turns solid. |
| container | RefObject<HTMLElement> | โ | Scrolling box to watch. Leave out to watch the page. |
| accent | string | "#a78bfa" | Active underline colour. |
| label | string | "Main" | Name read out by screen readers. |
| className | string | "text-sm text-white" | Text size and colour. Place it yourself (e.g. sticky top-0). |