A compound React navbar with a centered logo, scroll glass, and a full-width mobile sheet. Built with Motion and Tailwind CSS.
Scroll to shrink the bar. Resize to see the full-width mobile menu.
Join waitlistLorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
We will send a notification when the next enrollment period opens.
"use client";
import { cn } from "@/lib/utils";
import { StartupNavbar } from "./startup-navbar";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<body>
<StartupNavbar.Root className="fixed inset-x-0 top-0 z-50">
<StartupNavbar.List>
<StartupNavbar.Link href="/platform">Platform</StartupNavbar.Link>
<StartupNavbar.Link href="/companies">For companies</StartupNavbar.Link>
<StartupNavbar.Link href="/about">About</StartupNavbar.Link>
</StartupNavbar.List>
<StartupNavbar.Logo aria-label="Home" href="/">
<span className="font-semibold text-sm tracking-tight">springr</span>
</StartupNavbar.Logo>
<StartupNavbar.ActionBox>
<a
className={cn(
"rounded-xl px-2.5 py-2.5 font-medium text-black/70 text-sm",
"transition-colors duration-200 hover:text-black"
)}
href="/login"
>
Log in
</a>
<a
className={cn(
"inline-flex items-center rounded-xl border-2 border-[#f6f6f6]",
"bg-white/30 px-3 py-2 font-medium text-sm hover:bg-neutral-950 hover:text-white"
)}
href="/waitlist"
>
Join waitlist
</a>
</StartupNavbar.ActionBox>
</StartupNavbar.Root>
<main id="main">{children}</main>
</body>
);
}Install the following dependencies:
npm install motion react-use-measurebun add motion react-use-measuredeno add motion react-use-measurepnpm add motion react-use-measureyarn add motion react-use-measureCopy and paste the following code into your project.
"use client";
import {
motion,
useMotionValueEvent,
useReducedMotion,
useScroll,
} from "motion/react";
import {
Children,
createContext,
isValidElement,
type RefObject,
useCallback,
useContext,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import useMeasure from "react-use-measure";
import { cn } from "@/lib/utils";
const MobileMediaQuery = "(max-width: 991px)";
const CollapsedMaxWidthPx = 896;
const NavEase = [0.525, 0, 0, 1] as const;
const NavDurationS = 0.45;
const LinkRevealEase = [0.22, 1, 0.36, 1] as const;
const LinkRevealDurationS = 0.25;
const LinkRevealStaggerS = 0.04;
const FocusableSelector =
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])';
interface StartupNavbarContextValue {
isMobile: boolean;
isOpen: boolean;
menuId: string;
toggleMenu: () => void;
toggleRef: RefObject<HTMLButtonElement | null>;
}
const StartupNavbarContext = createContext<StartupNavbarContextValue | null>(
null
);
const LinkIndexContext = createContext(0);
const useStartupNavbar = () => {
const context = useContext(StartupNavbarContext);
if (!context) {
throw new Error("StartupNavbar.* must be used inside <StartupNavbar.Root>");
}
return context;
};
function useIsMobile() {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const media = window.matchMedia(MobileMediaQuery);
const onChange = () => {
setIsMobile(media.matches);
};
onChange();
media.addEventListener("change", onChange);
return () => media.removeEventListener("change", onChange);
}, []);
return isMobile;
}
function useScrolledPast(
scrollContainerRef?: RefObject<HTMLElement | null>,
threshold = 0
) {
const { scrollY } = useScroll({ container: scrollContainerRef });
const [isScrolled, setIsScrolled] = useState(false);
useMotionValueEvent(scrollY, "change", (latest) => {
setIsScrolled(latest > threshold);
});
return isScrolled;
}
function getCompoundChild(children: React.ReactNode, type: object) {
let match: React.ReactElement | null = null;
Children.forEach(children, (child) => {
if (isValidElement(child) && child.type === type) {
match = child;
}
});
return match;
}
function MenuToggle() {
const { isOpen, menuId, toggleMenu, toggleRef } = useStartupNavbar();
const shouldReduceMotion = useReducedMotion();
const lineTransition = shouldReduceMotion
? { duration: 0 }
: { duration: NavDurationS, ease: NavEase };
return (
<button
aria-controls={menuId}
aria-expanded={isOpen}
aria-label={isOpen ? "Close menu" : "Open menu"}
className={cn(
"relative z-10 flex size-8 flex-col items-center justify-center gap-1 p-2",
"text-neutral-950 min-[992px]:hidden",
"focus-visible:outline-2 focus-visible:outline-neutral-950 focus-visible:outline-offset-[3px]"
)}
onClick={toggleMenu}
ref={toggleRef}
type="button"
>
<motion.span
animate={{ rotate: isOpen ? 135 : 0, y: isOpen ? 3 : 0 }}
className="h-0.5 w-full origin-center bg-current"
initial={false}
transition={lineTransition}
/>
<motion.span
animate={{ rotate: isOpen ? -135 : 0, y: isOpen ? -3 : 0 }}
className="h-0.5 w-full origin-center bg-current"
initial={false}
transition={lineTransition}
/>
</button>
);
}
function List({ className, children, ...props }: React.ComponentProps<"ul">) {
const { menuId } = useStartupNavbar();
return (
<ul
className={cn(
"flex list-none p-0",
"max-[991px]:flex-col",
"min-[992px]:flex-row min-[992px]:items-center",
className
)}
id={menuId}
{...props}
>
{Children.map(children, (child, index) => (
<LinkIndexContext.Provider key={index} value={index}>
{child}
</LinkIndexContext.Provider>
))}
</ul>
);
}
List.displayName = "StartupNavbar.List";
function Link({ className, ...props }: React.ComponentProps<"a">) {
const { isMobile, isOpen } = useStartupNavbar();
const index = useContext(LinkIndexContext);
const shouldReduceMotion = useReducedMotion();
const closedLinkMotion = shouldReduceMotion
? { opacity: 0 }
: { filter: "blur(4px)", opacity: 0, y: -4 };
const openLinkMotion = { filter: "blur(0px)", opacity: 1, y: 0 };
return (
<li className="flex">
<motion.div
animate={isMobile && !isOpen ? closedLinkMotion : openLinkMotion}
className="w-full"
initial={false}
transition={
shouldReduceMotion
? { duration: 0 }
: {
delay: isOpen ? index * LinkRevealStaggerS : 0,
duration: LinkRevealDurationS,
ease: LinkRevealEase,
}
}
>
<a
className={cn(
"inline-flex items-center whitespace-nowrap rounded-xl px-2.5 py-2.5",
"font-medium text-black/70 text-sm transition-colors duration-200",
"hover:text-black",
"focus-visible:outline-2 focus-visible:outline-neutral-950 focus-visible:outline-offset-[3px]",
className
)}
{...props}
/>
</motion.div>
</li>
);
}
Link.displayName = "StartupNavbar.Link";
function Logo({ className, ...props }: React.ComponentProps<"a">) {
return (
<a
className={cn(
"z-10 flex items-center justify-center justify-self-center",
"rounded-2xl bg-white px-4",
"max-[991px]:col-start-2",
"min-[992px]:col-start-2 min-[992px]:row-start-1 min-[992px]:h-full",
"focus-visible:outline-2 focus-visible:outline-neutral-950 focus-visible:outline-offset-[3px]",
className
)}
{...props}
/>
);
}
Logo.displayName = "StartupNavbar.Logo";
function ActionBox({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"z-10 flex items-center justify-end gap-3 justify-self-end",
"max-[991px]:col-start-3 max-[991px]:row-start-1",
"min-[992px]:col-start-3 min-[992px]:row-start-1",
className
)}
{...props}
/>
);
}
ActionBox.displayName = "StartupNavbar.ActionBox";
function Root({
children,
className,
scrollContainerRef,
...props
}: React.ComponentProps<"header"> & {
scrollContainerRef?: RefObject<HTMLElement | null>;
}) {
const menuId = useId();
const headerRef = useRef<HTMLElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);
const [trackRef, trackBounds] = useMeasure();
const [canAnimateWidth, setCanAnimateWidth] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const isMobile = useIsMobile();
const isScrolled = useScrolledPast(scrollContainerRef);
const shouldReduceMotion = useReducedMotion();
const list = getCompoundChild(children, List);
const logo = getCompoundChild(children, Logo);
const actionBox = getCompoundChild(children, ActionBox);
const navTransition = shouldReduceMotion
? { duration: 0 }
: { duration: NavDurationS, ease: NavEase };
const toggleMenu = useCallback(() => {
setIsOpen((open) => !open);
}, []);
const closeMenu = useCallback((restoreFocus = true) => {
setIsOpen(false);
if (restoreFocus) {
toggleRef.current?.focus();
}
}, []);
useEffect(() => {
setMounted(true);
}, []);
const hasTrackWidth = trackBounds.width > 0;
useLayoutEffect(() => {
if (hasTrackWidth && !canAnimateWidth) {
setCanAnimateWidth(true);
}
}, [canAnimateWidth, hasTrackWidth]);
useEffect(() => {
if (!isMobile) {
setIsOpen(false);
}
}, [isMobile]);
useEffect(() => {
if (!(isOpen && isMobile && !scrollContainerRef)) {
return;
}
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previousOverflow;
};
}, [isOpen, isMobile, scrollContainerRef]);
useEffect(() => {
if (!(isOpen && isMobile)) {
return;
}
const root = headerRef.current;
if (!root) {
return;
}
const focusables = () =>
[...root.querySelectorAll<HTMLElement>(FocusableSelector)].filter(
(element) => !element.closest("[inert]")
);
const firstLink = root.querySelector<HTMLElement>(
`#${CSS.escape(menuId)} a`
);
firstLink?.focus();
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
closeMenu();
return;
}
if (event.key !== "Tab") {
return;
}
const elements = focusables();
if (elements.length === 0) {
return;
}
const [first] = elements;
const last = elements.at(-1);
if (!(first && last)) {
return;
}
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
return;
}
if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
const onLinkClick = (event: MouseEvent) => {
const { target } = event;
if (
target instanceof Element &&
target.closest(`#${CSS.escape(menuId)} a`)
) {
closeMenu(false);
}
};
document.addEventListener("keydown", onKeyDown);
root.addEventListener("click", onLinkClick);
return () => {
document.removeEventListener("keydown", onKeyDown);
root.removeEventListener("click", onLinkClick);
};
}, [closeMenu, isMobile, isOpen, menuId]);
const value = useMemo<StartupNavbarContextValue>(
() => ({
isMobile,
isOpen,
menuId,
toggleMenu,
toggleRef,
}),
[isMobile, isOpen, menuId, toggleMenu]
);
return (
<StartupNavbarContext.Provider value={value}>
<header
className={cn("w-full py-3 max-[991px]:px-3", className)}
data-menu-status={isOpen ? "open" : "closed"}
data-scrolled={isScrolled ? "true" : undefined}
ref={headerRef}
{...props}
>
<a
className={cn(
"absolute top-0 left-4 z-20 rounded-md bg-neutral-950 px-3 py-2 text-sm text-white",
"-translate-y-[calc(100%+1rem)] focus:translate-y-3",
"focus-visible:outline-2 focus-visible:outline-neutral-950 focus-visible:outline-offset-2"
)}
href="#main"
>
Skip to main content
</a>
<div className="w-full" ref={trackRef}>
<motion.div
animate={
hasTrackWidth
? {
width:
mounted && isScrolled && !isMobile
? Math.min(CollapsedMaxWidthPx, trackBounds.width)
: trackBounds.width,
}
: undefined
}
className={cn("mx-auto", !hasTrackWidth && "w-full")}
initial={false}
transition={
canAnimateWidth && !shouldReduceMotion
? navTransition
: { duration: 0 }
}
>
<div
className={cn(
"mx-auto flex min-h-9 w-full rounded-2xl px-4 py-2",
"max-[991px]:bg-white",
"min-[992px]:transition-[background-color,padding,backdrop-filter] min-[992px]:duration-[450ms]",
"min-[992px]:ease-[cubic-bezier(0.525,0,0,1)]",
isScrolled &&
!isMobile &&
"min-[992px]:bg-white/30 min-[992px]:px-2 min-[992px]:backdrop-blur-[10px]"
)}
>
<nav
aria-label="Primary navigation"
className={cn(
"flex w-full flex-col gap-x-4",
"min-[992px]:grid min-[992px]:grid-cols-3 min-[992px]:items-center"
)}
>
<div className="grid grid-cols-[auto_1fr_auto] items-center gap-x-4 min-[992px]:contents">
<MenuToggle />
{logo}
{actionBox}
</div>
<div
className={cn(
"max-[991px]:grid max-[991px]:min-h-0",
isOpen
? "max-[991px]:grid-rows-[1fr]"
: "max-[991px]:grid-rows-[0fr]",
"max-[991px]:transition-[grid-template-rows] max-[991px]:duration-300",
"max-[991px]:ease-[cubic-bezier(0.22,1,0.36,1)] max-[991px]:motion-reduce:transition-none",
"min-[992px]:col-start-1 min-[992px]:row-start-1"
)}
inert={isMobile && !isOpen ? true : undefined}
>
<div className="min-h-0 max-[991px]:overflow-hidden min-[992px]:overflow-visible">
<div
aria-hidden={isMobile && !isOpen}
className="max-[991px]:p-[10px] min-[992px]:p-0"
>
{list}
</div>
</div>
</div>
</nav>
</div>
</motion.div>
</div>
</header>
</StartupNavbarContext.Provider>
);
}
Root.displayName = "StartupNavbar.Root";
const StartupNavbar = {
ActionBox,
Link,
List,
Logo,
Root,
};
export { StartupNavbar };
The root is not positioned. Pin it with className — fixed inset-x-0 top-0 z-50 on a page, or absolute inset-x-0 top-0 inside a relative frame.
If the scroller is not the window, pass scrollContainerRef so the glass shrink still tracks scroll.
return (
<body>
<StartupNavbar.Root className="fixed inset-x-0 top-0 z-50">
<StartupNavbar.List>
<StartupNavbar.Link href="/platform">Platform</StartupNavbar.Link>
<StartupNavbar.Link href="/about">About</StartupNavbar.Link>
</StartupNavbar.List>
<StartupNavbar.Logo aria-label="Home" href="/">
<YourLogo />
</StartupNavbar.Logo>
<StartupNavbar.ActionBox>
<a href="/login">Log in</a>
<a href="/waitlist">Join waitlist</a>
</StartupNavbar.ActionBox>
</StartupNavbar.Root>
<main id="main">{children}</main>
</body>
);#main.Escape closes the mobile menu and restores focus; Tab is trapped while the menu is open.aria-expanded and aria-controls. The closed mobile panel is inert; logo and actions stay focusable.prefers-reduced-motion disables the shrink, sheet, and burger animations.