feat(ui): add responsive mobile menu with animated hamburger icon

- Add hamburger icon that morphs to X when menu is open
- Full-screen mobile menu overlay with backdrop blur
- Staggered fade-in animation for menu items
- Desktop navigation unchanged (hidden on mobile)
- Close menu via X button, backdrop click, or ESC key

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-30 00:51:08 +01:00
parent 3f1f90e015
commit de5dacfa3b
3 changed files with 260 additions and 56 deletions

View File

@@ -161,6 +161,46 @@ html {
transform-origin: left;
}
/* Hamburger Menu Icon */
.hamburger {
width: 20px;
height: 14px;
position: relative;
cursor: pointer;
}
.hamburger span {
display: block;
position: absolute;
width: 100%;
height: 2px;
background: var(--color-text-primary);
border-radius: var(--radius-full);
transition: all var(--duration-normal) var(--ease-out);
}
.hamburger span:nth-child(1) {
top: 0;
}
.hamburger span:nth-child(2) {
top: 50%;
transform: translateY(-50%);
}
.hamburger span:nth-child(3) {
bottom: 0;
}
/* Active state - transforms to X */
.hamburger.is-active span:nth-child(1) {
top: 50%;
transform: translateY(-50%) rotate(45deg);
}
.hamburger.is-active span:nth-child(2) {
opacity: 0;
transform: translateX(10px);
}
.hamburger.is-active span:nth-child(3) {
bottom: 50%;
transform: translateY(50%) rotate(-45deg);
}
/* Audio Player */
.audio-player {
background: var(--color-surface-1);

View File

@@ -1,46 +1,121 @@
<header class="fixed top-0 left-0 right-0 z-sticky bg-surface-0/80 backdrop-blur-md border-b border-border">
<nav class="container-wide flex items-center justify-between h-16">
{{/* Logo */}}
<div class="flex items-center gap-3 group">
<canvas
id="logo-canvas"
hx-preserve="true"
class="w-8 h-8 cursor-pointer"
width="32"
height="32"
aria-hidden="true"
@click="window.__pivoine?.visualizer?.nextVisualizer()"
title="Click to change visualizer"
></canvas>
<a
href="{{ "/" | relURL }}"
class="text-lg font-medium tracking-tight group-hover:text-accent transition-colors"
aria-label="{{ .Site.Title }} - Home"
<div x-data="{ menuOpen: false }" @keydown.escape.window="menuOpen = false">
<header class="fixed top-0 left-0 right-0 z-sticky bg-surface-0/80 backdrop-blur-md border-b border-border">
<nav class="container-wide flex items-center justify-between h-16">
{{/* Logo */}}
<div class="flex items-center gap-3 group">
<canvas
id="logo-canvas"
hx-preserve="true"
class="w-8 h-8 cursor-pointer"
width="32"
height="32"
aria-hidden="true"
@click="window.__pivoine?.visualizer?.nextVisualizer()"
title="Click to change visualizer"
></canvas>
<a
href="{{ "/" | relURL }}"
class="text-lg font-medium tracking-tight group-hover:text-accent transition-colors"
aria-label="{{ .Site.Title }} - Home"
>
VALKNAR'S
</a>
</div>
{{/* Desktop Navigation */}}
<ul
class="hidden md:flex items-center gap-8"
x-data="{ path: window.location.pathname }"
@htmx:after-settle.window="path = window.location.pathname"
>
VALKNAR'S
</a>
{{- range .Site.Menus.main }}
<li>
<a
href="{{ .URL }}"
class="text-sm transition-colors"
:class="path.startsWith('{{ .URL }}') ? 'text-text-primary border-b border-text-primary' : 'text-text-secondary hover:text-text-primary link-hover'"
>
{{ .Name }}
</a>
</li>
{{- end }}
</ul>
{{/* Mobile Hamburger Button */}}
<button
class="md:hidden relative w-8 h-8 flex items-center justify-center"
@click="menuOpen = !menuOpen"
:aria-expanded="menuOpen"
aria-label="Toggle menu"
>
<div class="hamburger" :class="{ 'is-active': menuOpen }">
<span></span>
<span></span>
<span></span>
</div>
</button>
</nav>
</header>
{{/* Mobile Menu Overlay - Full Screen */}}
<div
class="md:hidden fixed inset-0 z-[300]"
x-show="menuOpen"
x-cloak
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="menuOpen = false"
>
{{/* Backdrop */}}
<div class="absolute inset-0 bg-surface-0/98 backdrop-blur-xl"></div>
{{/* Close Button - Top right corner */}}
<div class="absolute top-0 right-0 h-16 container-wide flex items-center justify-end z-10">
<button
class="w-8 h-8 flex items-center justify-center"
@click.stop="menuOpen = false"
aria-label="Close menu"
>
<div class="hamburger is-active">
<span></span>
<span></span>
<span></span>
</div>
</button>
</div>
{{/* Navigation */}}
<ul
class="flex items-center gap-4 md:gap-8"
{{/* Menu Content */}}
<nav
class="relative h-full flex flex-col items-center justify-center"
x-data="{ path: window.location.pathname }"
@htmx:after-settle.window="path = window.location.pathname"
@click.stop
>
{{- range .Site.Menus.main }}
<li>
<a
href="{{ .URL }}"
class="text-sm transition-colors"
:class="path.startsWith('{{ .URL }}') ? 'text-text-primary border-b border-text-primary' : 'text-text-secondary hover:text-text-primary link-hover'"
<ul class="flex flex-col items-center gap-8">
{{- range $i, $item := .Site.Menus.main }}
<li
class="transform transition-all duration-300 ease-out"
:class="menuOpen ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'"
style="transition-delay: {{ mul $i 50 }}ms"
>
{{ .Name }}
</a>
</li>
{{- end }}
</ul>
</nav>
</header>
<a
href="{{ .URL }}"
class="text-2xl font-medium tracking-wide transition-colors"
:class="path.startsWith('{{ .URL }}') ? 'text-text-primary' : 'text-text-secondary hover:text-text-primary'"
@click="menuOpen = false"
>
{{ .Name }}
</a>
</li>
{{- end }}
</ul>
</nav>
</div>
</div>
{{/* Spacer for fixed header */}}
<div class="h-16" aria-hidden="true"></div>

View File

@@ -34,9 +34,12 @@
--leading-relaxed: 1.625;
--radius-sm: 0.25rem;
--radius-lg: 0.5rem;
--ease-in: cubic-bezier(0.4, 0, 1, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--animate-bounce: bounce 1s infinite;
--blur-md: 12px;
--blur-lg: 16px;
--blur-xl: 24px;
--default-transition-duration: 150ms;
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
--default-font-family: var(--font-sans);
@@ -238,6 +241,9 @@
.z-10 {
z-index: 10;
}
.z-\[300\] {
z-index: 300;
}
.z-player {
z-index: 400;
}
@@ -403,6 +409,14 @@
.flex-shrink-0 {
flex-shrink: 0;
}
.translate-y-0 {
--tw-translate-y: calc(var(--spacing) * 0);
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.translate-y-4 {
--tw-translate-y: calc(var(--spacing) * 4);
translate: var(--tw-translate-x) var(--tw-translate-y);
}
.scale-90 {
--tw-scale-x: 90%;
--tw-scale-y: 90%;
@@ -451,6 +465,9 @@
.gap-6 {
gap: calc(var(--spacing) * 6);
}
.gap-8 {
gap: calc(var(--spacing) * 8);
}
.truncate {
overflow: hidden;
text-overflow: ellipsis;
@@ -501,6 +518,12 @@
background-color: color-mix(in oklab, var(--color-surface-0) 80%, transparent);
}
}
.bg-surface-0\/98 {
background-color: var(--color-surface-0);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-surface-0) 98%, transparent);
}
}
.bg-surface-2 {
background-color: var(--color-surface-2);
}
@@ -649,6 +672,9 @@
.opacity-10 {
opacity: 10%;
}
.opacity-100 {
opacity: 100%;
}
.shadow {
--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
@@ -669,6 +695,11 @@
-webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
}
.backdrop-blur-xl {
--tw-backdrop-blur: blur(var(--blur-xl));
-webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
}
.transition {
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
@@ -694,6 +725,14 @@
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
transition-duration: var(--tw-duration, var(--default-transition-duration));
}
.duration-150 {
--tw-duration: 150ms;
transition-duration: 150ms;
}
.duration-200 {
--tw-duration: 200ms;
transition-duration: 200ms;
}
.duration-300 {
--tw-duration: 300ms;
transition-duration: 300ms;
@@ -702,6 +741,14 @@
--tw-duration: 500ms;
transition-duration: 500ms;
}
.ease-in {
--tw-ease: var(--ease-in);
transition-timing-function: var(--ease-in);
}
.ease-out {
--tw-ease: var(--ease-out);
transition-timing-function: var(--ease-out);
}
.group-hover\:translate-x-1 {
&:is(:where(.group):hover *) {
@media (hover: hover) {
@@ -900,6 +947,11 @@
display: flex;
}
}
.md\:hidden {
@media (width >= 48rem) {
display: none;
}
}
.md\:grid-cols-2 {
@media (width >= 48rem) {
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -915,11 +967,6 @@
flex-direction: row;
}
}
.md\:gap-8 {
@media (width >= 48rem) {
gap: calc(var(--spacing) * 8);
}
}
.md\:text-4xl {
@media (width >= 48rem) {
font-size: var(--text-4xl);
@@ -1057,6 +1104,43 @@ html {
transform: scaleX(1);
transform-origin: left;
}
.hamburger {
width: 20px;
height: 14px;
position: relative;
cursor: pointer;
}
.hamburger span {
display: block;
position: absolute;
width: 100%;
height: 2px;
background: var(--color-text-primary);
border-radius: var(--radius-full);
transition: all var(--duration-normal) var(--ease-out);
}
.hamburger span:nth-child(1) {
top: 0;
}
.hamburger span:nth-child(2) {
top: 50%;
transform: translateY(-50%);
}
.hamburger span:nth-child(3) {
bottom: 0;
}
.hamburger.is-active span:nth-child(1) {
top: 50%;
transform: translateY(-50%) rotate(45deg);
}
.hamburger.is-active span:nth-child(2) {
opacity: 0;
transform: translateX(10px);
}
.hamburger.is-active span:nth-child(3) {
bottom: 50%;
transform: translateY(50%) rotate(-45deg);
}
.audio-player {
background: var(--color-surface-1);
border-top: 1px solid var(--color-border);
@@ -1186,6 +1270,21 @@ html {
transition-duration: 0.01ms !important;
}
}
@property --tw-translate-x {
syntax: "*";
inherits: false;
initial-value: 0;
}
@property --tw-translate-y {
syntax: "*";
inherits: false;
initial-value: 0;
}
@property --tw-translate-z {
syntax: "*";
inherits: false;
initial-value: 0;
}
@property --tw-scale-x {
syntax: "*";
inherits: false;
@@ -1458,20 +1557,9 @@ html {
syntax: "*";
inherits: false;
}
@property --tw-translate-x {
@property --tw-ease {
syntax: "*";
inherits: false;
initial-value: 0;
}
@property --tw-translate-y {
syntax: "*";
inherits: false;
initial-value: 0;
}
@property --tw-translate-z {
syntax: "*";
inherits: false;
initial-value: 0;
}
@property --tw-content {
syntax: "*";
@@ -1491,6 +1579,9 @@ html {
@layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
*, ::before, ::after, ::backdrop {
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-translate-z: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-scale-z: 1;
@@ -1554,9 +1645,7 @@ html {
--tw-backdrop-saturate: initial;
--tw-backdrop-sepia: initial;
--tw-duration: initial;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-translate-z: 0;
--tw-ease: initial;
--tw-content: "";
}
}