Navbar
Logo, links and a mobile menu.
Code
Copy into src/components/ph/ .
navbar.css
.ph-navbar__burger-line {
display: block;
width: 28px;
height: 4px;
background-color: currentColor;
transition:
transform 300ms ease,
opacity 300ms ease;
}
.ph-navbar__burger[aria-expanded="true"] .ph-navbar__burger-line:nth-child(1) {
transform: translateY(10px) rotate(45deg);
}
.ph-navbar__burger[aria-expanded="true"] .ph-navbar__burger-line:nth-child(2) {
opacity: 0;
}
.ph-navbar__burger[aria-expanded="true"] .ph-navbar__burger-line:nth-child(3) {
transform: translateY(-10px) rotate(-45deg);
}
.ph-navbar__menu {
visibility: hidden;
transform: translateY(-100%);
transition:
transform 700ms cubic-bezier(0.12, 0.85, 0.25, 1),
visibility 700ms;
}
.ph-navbar__menu[data-open] {
visibility: visible;
transform: translateY(0);
}
.ph-navbar__menu-link {
color: var(--ph-cream);
font-family: var(--ph-font-mono);
font-size: 3rem;
font-weight: 600;
letter-spacing: 0.025em;
text-transform: uppercase;
text-decoration: none;
transition: color 200ms;
}
.ph-navbar__menu-link:hover,
.ph-navbar__menu-link:focus-visible {
color: var(--ph-yellow);
}
.ph-navbar[data-menu-open] .ph-navbar__bar {
color: var(--ph-cream);
}
@media (prefers-reduced-motion: reduce) {
.ph-navbar__burger-line,
.ph-navbar__menu {
transition: none;
}
}
.ph-navbar[data-tone="dark"] .ph-navbar__bar {
color: var(--ph-cream);
}
Navbar.astro
---
import Glider from "./Glider.astro";
import NavLink from "./NavLink.astro";
import "./navbar.css";
type NavItem = {
label: string;
href: string;
};
type Props = {
links: NavItem[];
homeHref?: string;
tone?: "light" | "dark";
current?: string;
class?: string;
};
const { links, homeHref = "/", tone, current, class: className } = Astro.props;
const external = (href: string) => /^https?:\/\//.test(href);
---
<header data-ph-navbar data-tone={tone} class:list={["ph-navbar relative z-30", className]}>
<div class="ph-navbar__bar relative z-30 flex items-center justify-between px-6 py-5 md:px-8 md:py-6">
<a href={homeHref} aria-label="Purdue Hackers home" class="inline-flex text-current transition-opacity hover:opacity-70">
<Glider class="size-8" />
</a>
<nav class="hidden gap-8 md:flex">
{
links.map((l) => (
<NavLink href={l.href} aria-current={current === l.href ? "page" : undefined}>
{l.label}
</NavLink>
))
}
</nav>
<button
type="button"
data-ph-navbar-toggle
aria-expanded="false"
aria-label="Open menu"
class="ph-navbar__burger ml-auto flex size-10 cursor-pointer flex-col items-center justify-center gap-1.5 md:hidden"
>
<span class="ph-navbar__burger-line" aria-hidden="true"></span>
<span class="ph-navbar__burger-line" aria-hidden="true"></span>
<span class="ph-navbar__burger-line" aria-hidden="true"></span>
</button>
</div>
<div data-ph-navbar-menu class="ph-navbar__menu fixed inset-0 z-20 bg-ph-black md:hidden" aria-hidden="true">
<nav class="flex min-h-full flex-col justify-center gap-6 px-8 py-24">
{
links.map((l) => (
<a
href={l.href}
target={external(l.href) ? "_blank" : undefined}
rel={external(l.href) ? "noopener noreferrer" : undefined}
class="ph-navbar__menu-link"
>
{l.label}
</a>
))
}
</nav>
</div>
</header>
<script>
for (const bar of document.querySelectorAll<HTMLElement>("[data-ph-navbar]")) {
const toggle = bar.querySelector<HTMLButtonElement>("[data-ph-navbar-toggle]")!;
const menu = bar.querySelector<HTMLElement>("[data-ph-navbar-menu]")!;
const set = (open: boolean) => {
toggle.setAttribute("aria-expanded", String(open));
toggle.setAttribute("aria-label", open ? "Close menu" : "Open menu");
menu.toggleAttribute("data-open", open);
menu.setAttribute("aria-hidden", String(!open));
menu.inert = !open;
bar.toggleAttribute("data-menu-open", open);
document.documentElement.style.overflow = open ? "hidden" : "";
};
set(false);
toggle.addEventListener("click", () => set(toggle.getAttribute("aria-expanded") !== "true"));
menu.addEventListener("click", (e) => {
if ((e.target as HTMLElement).closest("a")) set(false);
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") set(false);
});
}
</script>
Copy into components/ph/ (or src/components/ph/).
navbar.css
.ph-navbar__burger-line {
display: block;
width: 28px;
height: 4px;
background-color: currentColor;
transition:
transform 300ms ease,
opacity 300ms ease;
}
.ph-navbar__burger[aria-expanded="true"] .ph-navbar__burger-line:nth-child(1) {
transform: translateY(10px) rotate(45deg);
}
.ph-navbar__burger[aria-expanded="true"] .ph-navbar__burger-line:nth-child(2) {
opacity: 0;
}
.ph-navbar__burger[aria-expanded="true"] .ph-navbar__burger-line:nth-child(3) {
transform: translateY(-10px) rotate(-45deg);
}
.ph-navbar__menu {
visibility: hidden;
transform: translateY(-100%);
transition:
transform 700ms cubic-bezier(0.12, 0.85, 0.25, 1),
visibility 700ms;
}
.ph-navbar__menu[data-open] {
visibility: visible;
transform: translateY(0);
}
.ph-navbar__menu-link {
color: var(--ph-cream);
font-family: var(--ph-font-mono);
font-size: 3rem;
font-weight: 600;
letter-spacing: 0.025em;
text-transform: uppercase;
text-decoration: none;
transition: color 200ms;
}
.ph-navbar__menu-link:hover,
.ph-navbar__menu-link:focus-visible {
color: var(--ph-yellow);
}
.ph-navbar[data-menu-open] .ph-navbar__bar {
color: var(--ph-cream);
}
@media (prefers-reduced-motion: reduce) {
.ph-navbar__burger-line,
.ph-navbar__menu {
transition: none;
}
}
.ph-navbar[data-tone="dark"] .ph-navbar__bar {
color: var(--ph-cream);
}
navbar.tsx
"use client";
import { useEffect, useState } from "react";
import { cn } from "./cn";
import { Glider } from "./glider";
import { NavLink } from "./nav-link";
import "./navbar.css";
type NavItem = {
label: string;
href: string;
};
type NavbarProps = {
links: NavItem[];
homeHref?: string;
tone?: "light" | "dark";
current?: string;
className?: string;
};
export function Navbar({ links, homeHref = "/", tone, current, className }: NavbarProps) {
const [open, setOpen] = useState(false);
const external = (href: string) => /^https?:\/\//.test(href);
useEffect(() => {
document.documentElement.style.overflow = open ? "hidden" : "";
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open]);
return (
<header data-tone={tone} data-menu-open={open ? "" : undefined} className={cn("ph-navbar relative z-30", className)}>
<div className="ph-navbar__bar relative z-30 flex items-center justify-between px-6 py-5 md:px-8 md:py-6">
<a href={homeHref} aria-label="Purdue Hackers home" className="inline-flex text-current transition-opacity hover:opacity-70">
<Glider className="size-8" />
</a>
<nav className="hidden gap-8 md:flex">
{links.map((l) => (
<NavLink key={l.href} href={l.href} aria-current={current === l.href ? "page" : undefined}>
{l.label}
</NavLink>
))}
</nav>
<button
type="button"
aria-expanded={open}
aria-label={open ? "Close menu" : "Open menu"}
onClick={() => setOpen((o) => !o)}
className="ph-navbar__burger ml-auto flex size-10 cursor-pointer flex-col items-center justify-center gap-1.5 md:hidden"
>
<span className="ph-navbar__burger-line" aria-hidden="true" />
<span className="ph-navbar__burger-line" aria-hidden="true" />
<span className="ph-navbar__burger-line" aria-hidden="true" />
</button>
</div>
<div data-open={open ? "" : undefined} aria-hidden={!open} inert={!open} className="ph-navbar__menu fixed inset-0 z-20 bg-ph-black md:hidden">
<nav className="flex min-h-full flex-col justify-center gap-6 px-8 py-24">
{links.map((l) => (
<a
key={l.href}
href={l.href}
target={external(l.href) ? "_blank" : undefined}
rel={external(l.href) ? "noopener noreferrer" : undefined}
onClick={() => setOpen(false)}
className="ph-navbar__menu-link"
>
{l.label}
</a>
))}
</nav>
</div>
</header>
);
}
cn.ts
export const cn = (...parts: (string | false | null | undefined)[]) => parts.filter(Boolean).join(" ");