A compact floating navbar for React with an expanding account menu. Animated, responsive and built with Tailwind CSS.
export default function Layout({ children }) {
return (
<html>
<body>
<CompactNav
user={ isLoggedIn ? {
email: "samlong@springr.dev",
fullName: "Sam Longname",
imageUrl: "https://i.pravatar.cc/300",
} : null}
/>
</body>
</html>
);
}Install the following dependencies:
npm install motion lucide-react react-use-measurebun add motion lucide-react react-use-measuredeno add motion lucide-react react-use-measurepnpm add motion lucide-react react-use-measureyarn add motion lucide-react react-use-measureCopy and paste the following code into your project.
"use client";
import { LogOut, MessageCircleIcon, User2Icon, XIcon } from "lucide-react";
import {
AnimatePresence,
MotionConfig,
motion,
useReducedMotion,
} from "motion/react";
import Link from "next/link";
import {
type RefObject,
useCallback,
useEffect,
useId,
useRef,
useState,
} from "react";
import useMeasure from "react-use-measure";
import { SpringrLogo } from "@/components/ui/logo";
import { cn } from "@/lib/utils";
interface CompactNavUser {
email: string;
fullName: string;
imageUrl?: string;
}
interface CompactNavLink {
href: string;
label: string;
}
function getAccountTriggerLabel(menuOpen: boolean, email: string): string {
if (menuOpen) {
return "Close account menu";
}
return `Account menu for ${email}`;
}
function getInitials(email: string) {
const localPart = email.split("@")[0]?.trim() ?? "";
if (localPart.length >= 2) {
return localPart.slice(0, 2).toUpperCase();
}
if (localPart.length === 1) {
return localPart.toUpperCase();
}
return "?";
}
function CompactNavBrand({
isLoggedIn,
menuOpen,
links,
}: {
isLoggedIn: boolean;
menuOpen: boolean;
links: CompactNavLink[];
}) {
return (
<div className="flex min-w-0 flex-1 items-center">
<SpringrLogo className="text-accent-depth" />
<motion.div
animate={
isLoggedIn && menuOpen
? { filter: "blur(4px)", opacity: 1, pointerEvents: "none" }
: { filter: "blur(0px)", opacity: 1 }
}
className="flex min-w-0 items-center"
initial={false}
>
<ul className="ml-2 flex h-full items-end gap-3 md:ml-2">
{links.map((link, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: no id provided
<li key={`${link.href}-${index}`}>
<Link
className="font-medium font-serif text-muted-foreground text-sm tracking-wide transition-colors duration-200 hover:text-foreground"
href={link.href}
>
{link.label}
</Link>
</li>
))}
</ul>
</motion.div>
</div>
);
}
function CompactNavUserMeta({
user,
shouldReduceMotion,
}: {
user: CompactNavUser;
shouldReduceMotion: boolean;
}) {
return (
<div className="flex max-w-56 flex-col items-end gap-0.5">
<motion.div
animate={{ filter: "blur(0px)", opacity: 1, x: 0 }}
className="flex items-center gap-2"
exit={{ filter: "blur(4px)", opacity: 0, x: 50 }}
initial={{ filter: "blur(4px)", opacity: 0, x: 50 }}
key="user-meta"
transition={
shouldReduceMotion
? { duration: 0 }
: { bounce: 0.1, duration: 0.4, type: "spring" }
}
>
<p className="truncate text-foreground text-xs" title={user.fullName}>
{user.fullName}
</p>
</motion.div>
<motion.p
animate={{ filter: "blur(0px)", opacity: 1, x: 0 }}
className="flex items-center gap-1 text-muted-foreground text-xs"
exit={{ filter: "blur(4px)", opacity: 0, x: 50 }}
initial={{ filter: "blur(4px)", opacity: 0, x: 50 }}
key="user-meta-provider"
transition={
shouldReduceMotion
? { duration: 0 }
: { bounce: 0.1, delay: 0.02, duration: 0.4, type: "spring" }
}
>
{user.email}
</motion.p>
</div>
);
}
function CompactNavRightSlot({
menuOpen,
user,
shouldReduceMotion,
}: {
menuOpen: boolean;
user: CompactNavUser;
shouldReduceMotion: boolean;
}) {
return (
<div className="relative hidden min-h-9 min-w-0 items-center justify-end md:flex">
<AnimatePresence initial={false}>
{menuOpen ? (
<div className="z-10" key="user-meta">
<CompactNavUserMeta
shouldReduceMotion={shouldReduceMotion}
user={user}
/>
</div>
) : null}
</AnimatePresence>
</div>
);
}
function CompactNavAccountTrigger({
menuOpen,
user,
menuId,
triggerRef,
onToggle,
}: {
menuOpen: boolean;
user: CompactNavUser;
menuId: string;
triggerRef: RefObject<HTMLButtonElement | null>;
onToggle: () => void;
}) {
return (
<button
aria-controls={menuOpen ? menuId : undefined}
aria-expanded={menuOpen}
aria-haspopup="menu"
aria-label={getAccountTriggerLabel(menuOpen, user.email)}
className={cn(
"group relative z-20 flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-md",
"bg-accent text-accent-foreground text-xs",
"transition-[transform,box-shadow] duration-150",
"focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2",
"active:scale-[0.98]"
)}
onClick={onToggle}
ref={triggerRef}
type="button"
>
<span className="absolute inset-0 flex items-center justify-center text-white opacity-100">
{user.imageUrl ? (
<span
aria-hidden
className="absolute inset-0 bg-center bg-cover"
style={{ backgroundImage: `url(${user.imageUrl})` }}
/>
) : (
getInitials(user.email)
)}
</span>
<span
aria-hidden={!menuOpen}
className={cn(
"absolute inset-0 flex items-center justify-center bg-foreground/50 text-background",
menuOpen
? "opacity-0 transition-opacity duration-150 group-hover:opacity-100"
: "pointer-events-none opacity-0"
)}
>
<XIcon aria-hidden className="size-3.5" />
</span>
</button>
);
}
function CompactNavMenuFooter({ onLogOut }: { onLogOut?: () => void }) {
return (
<>
<button
className="flex items-center gap-2 rounded-sm p-1.5 text-foreground text-sm transition-colors duration-150 hover:bg-secondary focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2"
role="menuitem"
type="button"
>
<MessageCircleIcon
aria-hidden
className="size-3.5 text-muted-foreground"
/>
<span>Give a feedback</span>
</button>
<button
className="flex items-center gap-2 rounded-sm p-1.5 text-error text-sm transition-colors duration-150 hover:bg-secondary focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2"
onClick={onLogOut}
role="menuitem"
type="button"
>
<LogOut aria-hidden className="size-3.5" />
Log out
</button>
</>
);
}
const NAV_LINKS: CompactNavLink[] = [
{ href: "#", label: "Docs" },
{ href: "#", label: "Help" },
{ href: "#", label: "Contacts" },
];
function CompactNav({
className,
links = NAV_LINKS,
user = null,
onLogOut,
}: {
className?: string;
links?: CompactNavLink[];
user?: CompactNavUser | null;
onLogOut?: () => void;
}) {
const [menuOpen, setMenuOpen] = useState(false);
const [isMounted, setIsMounted] = useState(false);
const shellRef = useRef<HTMLElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const menuId = useId();
const shouldReduceMotion = useReducedMotion() === true;
const [measureRef, bounds] = useMeasure();
const isLoggedIn = user !== null;
const closeMenu = useCallback(() => {
setMenuOpen(false);
}, []);
const toggleMenu = useCallback(() => {
setMenuOpen((value) => !value);
}, []);
useEffect(() => {
setIsMounted(true);
}, []);
useEffect(() => {
if (!menuOpen) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closeMenu();
triggerRef.current?.focus();
}
};
const handlePointerDown = (event: MouseEvent) => {
const target = event.target as Node;
if (shellRef.current?.contains(target)) {
return;
}
closeMenu();
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("mousedown", handlePointerDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("mousedown", handlePointerDown);
};
}, [closeMenu, menuOpen]);
const expandedHeight = menuOpen ? bounds.height : 0;
if (!isMounted) {
return null;
}
return (
<MotionConfig
transition={
shouldReduceMotion
? { duration: 0 }
: { bounce: 0.25, duration: 0.4, type: "spring" }
}
>
<motion.header
className={cn(
"pointer-events-none fixed inset-x-0 top-10 z-30 flex justify-center px-2",
className
)}
>
<motion.nav
aria-label="Documentation"
className={cn(
"pointer-events-auto w-80 overflow-hidden rounded-lg border backdrop-blur-lg",
"border-border bg-background/70"
)}
initial={false}
ref={shellRef}
>
<div className="flex items-center gap-2 px-3 py-2">
<CompactNavBrand
isLoggedIn={isLoggedIn}
links={links}
menuOpen={menuOpen}
/>
<div className="flex min-w-0 shrink-0 items-center justify-end gap-2">
{isLoggedIn ? (
<>
<CompactNavRightSlot
menuOpen={menuOpen}
shouldReduceMotion={shouldReduceMotion}
user={user}
/>
<CompactNavAccountTrigger
menuId={menuId}
menuOpen={menuOpen}
onToggle={toggleMenu}
triggerRef={triggerRef}
user={user}
/>
</>
) : (
<Link
aria-label="Login"
className="grid size-9 place-items-center rounded-md bg-secondary text-muted-foreground"
href="#"
>
<User2Icon aria-hidden className="size-4" />
</Link>
)}
</div>
</div>
{isLoggedIn ? (
<motion.div
animate={{ height: expandedHeight }}
className="overflow-hidden"
initial={false}
>
<motion.div
animate={{ opacity: menuOpen ? 1 : 0 }}
aria-hidden={!menuOpen}
id={menuId}
inert={!menuOpen}
initial={false}
ref={measureRef}
role="menu"
>
<div className="mx-3 h-px bg-border sm:mx-4" />
<div className="flex items-center justify-between gap-3 px-3 py-2.5 sm:px-4">
<CompactNavMenuFooter
onLogOut={() => {
closeMenu();
onLogOut?.();
}}
/>
</div>
</motion.div>
</motion.div>
) : null}
</motion.nav>
</motion.header>
</MotionConfig>
);
}
export type { CompactNavLink, CompactNavUser };
export { CompactNav };