Hidden Layer Navigation
Hidden Layer navigation: the menu sits behind the main content and is revealed by sliding the content aside. GSAP-driven push pattern with hamburger ↔ X toggle, framed overlay with border bars and corner masks, ESC + overlay click to close, staggered menu reveal, and focus restoration on dismiss.
NavigationReactGSAPTailwind CSS
CSSTailwind
Manual
Create a file and paste the following code into it.
src/components/hidden-layer-nav.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
"use client";
import {
useCallback,
useEffect,
useId,
useRef,
useState,
type ReactNode,
type Ref,
} from "react";
import { gsap } from "gsap";
import { CustomEase } from "gsap/CustomEase";
const ENERGY_EASE = "hidden-layer-energy";
interface NavLink {
label: string;
href: string;
/** Marks the link as the current page for visual + a11y emphasis [Optional] */
isCurrent?: boolean;
}
interface HiddenLayerNavProps {
/** Primary navigation items rendered at large size in the panel [Optional] */
primary?: NavLink[];
/** Social links shown in the panel's left footer column [Optional] */
socials?: NavLink[];
/** Secondary links shown in the panel's right footer column [Optional] */
quickLinks?: NavLink[];
/** Brand wordmark or logo node rendered in the header next to the toggle [Optional] */
brand?: ReactNode;
/** Main content rendered behind the header; slides aside when the panel opens [Optional] */
children?: ReactNode;
}
const DEFAULT_PRIMARY: NavLink[] = [
{ label: "Home", href: "#home", isCurrent: true },
{ label: "Projects", href: "#projects" },
{ label: "About", href: "#about" },
{ label: "Services", href: "#services" },
{ label: "News", href: "#news" },
{ label: "Contact", href: "#contact" },
];
const DEFAULT_SOCIALS: NavLink[] = [
{ label: "Instagram", href: "#" },
{ label: "LinkedIn", href: "#" },
{ label: "X/Twitter", href: "#" },
];
const DEFAULT_QUICK_LINKS: NavLink[] = [
{ label: "Privacy Policy ↗", href: "#" },
{ label: "Terms & Conditions ↗", href: "#" },
];
const ENTER_DURATION = 0.7;
const EXIT_DURATION = 0.4;
const TOGGLE_COLOR_CLOSED = "#ffffff";
const TOGGLE_COLOR_OPEN = "#0a0a0d";
/**
* Hidden Layer navigation. The menu sits behind the main content
* and is revealed by sliding the main content (and a visual overlay) to the
* left — a "push" pattern rather than a traditional drawer that overlaps.
* A single GSAP timeline coordinates the slide, the overlay frame (dark wash,
* top/bottom border bars, corner masks), the hamburger ↔ X morph, and the
* Menu ↔ Close label crossfade. timeScale is increased on close so the exit
* feels snappier than the more leisurely open.
* @param {NavLink[]} primary - Primary nav items rendered at large size [Optional]
* @param {NavLink[]} socials - Social links in the panel footer [Optional]
* @param {NavLink[]} quickLinks - Secondary links in the panel footer [Optional]
* @param {ReactNode} brand - Brand wordmark or logo in the header [Optional]
* @param {ReactNode} children - Main content shifted aside when the panel opens [Optional]
*/
function HiddenLayerNav({
primary = DEFAULT_PRIMARY,
socials = DEFAULT_SOCIALS,
quickLinks = DEFAULT_QUICK_LINKS,
brand,
children,
}: HiddenLayerNavProps) {
const [isOpen, setIsOpen] = useState(false);
const [panelWidth, setPanelWidth] = useState(360);
const containerRef = useRef<HTMLDivElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLElement>(null);
const timelineRef = useRef<gsap.core.Timeline | null>(null);
const panelId = useId();
const labelId = useId();
// Centralized close so backdrop-click and ESC behave identically: both
// dismiss the panel AND restore focus to the toggle that opened it.
const close = useCallback(() => {
setIsOpen(false);
toggleRef.current?.focus();
}, []);
// Measure against the container, not the viewport, so the demo behaves
// correctly inside an iframe of arbitrary width.
useEffect(() => {
const measure = () => {
const w = containerRef.current?.offsetWidth ?? window.innerWidth;
setPanelWidth(
w < 768 ? w * 0.85 : Math.min(480, Math.max(360, w * 0.4)),
);
};
measure();
window.addEventListener("resize", measure);
return () => window.removeEventListener("resize", measure);
}, []);
useEffect(() => {
if (!isOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [isOpen, close]);
// Focus trap while the panel is open.
useEffect(() => {
if (!isOpen) return;
const panel = panelRef.current;
if (!panel) return;
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), input:not([disabled]),' +
' textarea:not([disabled]), select:not([disabled]),' +
' [tabindex]:not([tabindex="-1"])';
const collect = () =>
Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
const initial = collect();
initial[0]?.focus();
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
const focusable = collect();
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement;
if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [isOpen]);
// Build the GSAP timeline. Rebuilds when panelWidth changes (viewport resize).
// gsap.context() scopes selectors to containerRef and provides a single revert()
// call that cleans up every tween, set, and inline style on unmount. Plugin +
// custom ease are registered here (not at module scope) so the SSR pass doesn't
// trigger side effects that desync the React tree from the client.
useEffect(() => {
if (!containerRef.current) return;
gsap.registerPlugin(CustomEase);
if (!gsap.parseEase(ENERGY_EASE)) {
CustomEase.create(ENERGY_EASE, "M0,0 C0.32,0.72 0,1 1,1");
}
const ctx = gsap.context(() => {
gsap.set(".hln-overlay", {
visibility: "hidden",
pointerEvents: "none",
x: 0,
});
gsap.set(".hln-main", { x: 0 });
gsap.set(".hln-dark", { autoAlpha: 0 });
gsap.set(".hln-brand", { autoAlpha: 0 });
gsap.set(".hln-corner", { scale: 0 });
gsap.set(".hln-border-row-top", { yPercent: -100 });
gsap.set(".hln-border-row-bottom", { yPercent: 100 });
gsap.set(".hln-label-menu", { yPercent: 0 });
gsap.set(".hln-label-close", { yPercent: 100 });
gsap.set(".hln-bar-1", { y: 0, rotation: 0 });
gsap.set(".hln-bar-2", { y: 0, rotation: 0 });
gsap.set(".hln-toggle-btn", { color: TOGGLE_COLOR_CLOSED });
gsap.set(".hln-primary-item", { autoAlpha: 0, xPercent: 25 });
gsap.set(".hln-secondary-line", { autoAlpha: 0, yPercent: 100 });
gsap.set(".hln-bottom-border", {
scaleX: 0,
transformOrigin: "left center",
});
const tl = gsap.timeline({
paused: true,
defaults: { ease: ENERGY_EASE, duration: ENTER_DURATION },
});
tl.set(
".hln-overlay",
{ visibility: "visible", pointerEvents: "auto" },
0,
)
.to([".hln-main", ".hln-overlay"], { x: -panelWidth }, 0)
.to(".hln-dark", { autoAlpha: 1, duration: 0.5 }, 0)
.to(".hln-brand", { autoAlpha: 1, duration: 0.5 }, 0.15)
.to(".hln-corner", { scale: 1, duration: 0.5 }, 0)
.to(".hln-border-row-top", { yPercent: 0, duration: 0.5 }, 0)
.to(".hln-border-row-bottom", { yPercent: 0, duration: 0.5 }, 0)
.to(".hln-label-menu", { yPercent: -100, duration: 0.4 }, 0)
.to(".hln-label-close", { yPercent: 0, duration: 0.4 }, 0)
.to(".hln-toggle-btn", { color: TOGGLE_COLOR_OPEN, duration: 0.4 }, 0)
.to(
".hln-bar-1",
{ y: 5.5, rotation: 45, duration: 0.35, ease: "back.out(1.4)" },
0.05,
)
.to(
".hln-bar-2",
{ y: -5.5, rotation: -45, duration: 0.35, ease: "back.out(1.4)" },
0.05,
)
.to(
".hln-primary-item",
{
autoAlpha: 1,
xPercent: 0,
duration: ENTER_DURATION,
stagger: 0.05,
},
0,
)
.to(
".hln-secondary-line",
{
autoAlpha: 1,
yPercent: 0,
duration: 0.5,
stagger: 0.03,
ease: "power3.out",
},
0.3,
)
.to(".hln-bottom-border", { scaleX: 1, duration: 0.5 }, "<");
timelineRef.current = tl;
}, containerRef);
return () => {
ctx.revert();
timelineRef.current = null;
};
}, [panelWidth]);
// Play / reverse the timeline based on isOpen. Reverse is sped up so the
// close motion feels snappier than the open.
useEffect(() => {
const tl = timelineRef.current;
if (!tl) return;
if (isOpen) {
tl.timeScale(1).play();
} else {
tl.timeScale(ENTER_DURATION / EXIT_DURATION).reverse();
}
}, [isOpen]);
return (
<div
ref={containerRef}
className="relative h-full w-full overflow-hidden bg-white text-[#0a0a0d]"
>
<aside
ref={panelRef}
id={panelId}
role="dialog"
aria-modal="true"
aria-labelledby={labelId}
// React 19 accepts inert as a regular boolean attribute; when the
// panel is closed it removes everything inside from the tab order
// and the accessibility tree (aria-hidden alone leaves elements
// tabbable, which broke keyboard navigation when the panel was
// off-screen).
inert={!isOpen}
style={{ width: panelWidth }}
className="hln-panel absolute inset-y-0 right-0 z-[1] flex flex-col gap-8 overflow-y-auto bg-white px-6 pb-6 pt-24 md:px-9 md:pb-9 md:pt-28"
>
<PrimaryMenu items={primary} />
<div className="mt-auto relative flex w-full items-start gap-8 pt-6">
<span
aria-hidden
className="hln-bottom-border absolute inset-x-0 top-0 h-px bg-current opacity-15"
/>
<FooterColumn title="Socials" items={socials} />
<FooterColumn title="Quick Links" items={quickLinks} />
</div>
</aside>
<div className="hln-main absolute inset-0 z-[20]">
<div className="relative h-full w-full overflow-hidden">{children}</div>
</div>
{/* inset right -1px hides a subpixel seam where the overlay meets the panel. */}
<button
type="button"
onClick={close}
aria-label="Close menu overlay"
className="hln-overlay absolute inset-y-0 left-0 right-[-1px] z-[30] block cursor-pointer overflow-clip"
>
<span aria-hidden className="hln-dark absolute inset-0 bg-black/30" />
<span
aria-hidden
className="hln-borders absolute inset-0 flex flex-col justify-between"
>
<span className="hln-border-row-top flex flex-col items-end">
<span className="block h-4 w-full bg-white" />
<span
className="hln-corner block h-8 w-8 origin-top-right"
style={{
backgroundImage:
"radial-gradient(circle farthest-side at 0% 100%," +
" rgba(255,255,255,0) 99%, #ffffff)",
}}
/>
</span>
<span className="hln-border-row-bottom flex flex-col items-end">
<span
className="hln-corner block h-8 w-8 origin-bottom-right"
style={{
backgroundImage:
"radial-gradient(circle farthest-side at 0% 0%," +
" rgba(255,255,255,0) 99%, #ffffff)",
}}
/>
<span className="block h-4 w-full bg-white" />
</span>
</span>
</button>
{/* pointer-events-none so the empty header strip doesn't absorb clicks
under the toggle; toggle and brand re-enable on themselves. */}
<header className="pointer-events-none absolute inset-x-0 top-0 z-[40] flex items-center justify-between px-6 py-5 md:px-10 md:py-7">
<span
id={labelId}
className={
brand
? "hln-brand pointer-events-auto mr-auto flex items-center"
: "sr-only"
}
>
{brand ?? "Site navigation"}
</span>
<ToggleButton
ref={toggleRef}
isOpen={isOpen}
panelId={panelId}
onToggle={() => (isOpen ? close() : setIsOpen(true))}
/>
</header>
</div>
);
}
interface ToggleButtonProps {
isOpen: boolean;
onToggle: () => void;
panelId: string;
ref?: Ref<HTMLButtonElement>;
}
/**
* Hamburger ↔ X toggle with rotating bars and a Menu ↔ Close label crossfade.
* The bars/labels carry classes that the parent timeline drives via gsap.context.
* @param {boolean} isOpen - Whether the panel is currently open [Required]
* @param {() => void} onToggle - Click handler that flips panel state [Required]
* @param {string} panelId - The id of the panel this button controls [Required]
* @param {Ref<HTMLButtonElement>} ref - Optional ref for restoring focus on close [Optional]
*/
function ToggleButton({ isOpen, onToggle, panelId, ref }: ToggleButtonProps) {
return (
<button
ref={ref}
type="button"
onClick={onToggle}
aria-expanded={isOpen}
aria-controls={panelId}
aria-label={isOpen ? "Close menu" : "Open menu"}
className="hln-toggle-btn pointer-events-auto ml-auto flex items-center gap-3 rounded-md px-2 py-1 text-2xl font-semibold active:scale-[0.97]"
>
<span className="relative inline-block h-9 w-20 overflow-hidden text-2xl">
<span className="hln-label-menu absolute inset-0 flex items-center justify-end">
Menu
</span>
<span className="hln-label-close absolute inset-0 flex items-center justify-end">
Close
</span>
</span>
<span className="relative flex h-9 w-9 flex-col items-center justify-center gap-2">
<span className="hln-bar-1 block h-[3px] w-9 origin-center rounded-full bg-current" />
<span className="hln-bar-2 block h-[3px] w-9 origin-center rounded-full bg-current" />
</span>
</button>
);
}
/** Primary menu list — each item gets a class the parent timeline staggers. */
function PrimaryMenu({ items }: { items: NavLink[] }) {
return (
<ul className="flex flex-col gap-2">
{items.map((item) => (
<li key={item.label} className="hln-primary-item">
<a
href={item.href}
aria-current={item.isCurrent ? "page" : undefined}
className={
"block rounded-md px-5 py-2 text-[4rem] font-medium leading-[0.95] tracking-tight transition-colors " +
(item.isCurrent
? "bg-[#F85931] text-white"
: "text-[#0a0a0d] hover:bg-black/5")
}
>
{item.label}
</a>
</li>
))}
</ul>
);
}
/** Footer column — title + links each carry the secondary-line class for the timeline. */
function FooterColumn({ title, items }: { title: string; items: NavLink[] }) {
return (
<div className="flex-1">
<div className="hln-secondary-line mb-3 text-sm text-black/50">
{title}
</div>
<ul className="flex flex-col gap-2">
{items.map((item) => (
<li key={item.label} className="hln-secondary-line">
<a
href={item.href}
className="text-base text-[#0a0a0d] transition-colors hover:text-[#F85931]"
>
{item.label}
</a>
</li>
))}
</ul>
</div>
);
}
/** Hidden Layer navigation demo — full-viewport wrapper with leather-texture hero. */
export default function HiddenLayerNavDemo() {
return (
<div className="h-dvh w-full overflow-hidden">
<HiddenLayerNav brand={SELECT_LOGO}>
<SampleContent />
</HiddenLayerNav>
</div>
);
}
const HERO_IMAGE_URL =
"https://images.unsplash.com/photo-1716295177956-420a647c83ac?w=2400&q=80&auto=format&fit=crop";
function SampleContent() {
return (
<div className="relative h-full w-full overflow-hidden bg-[#1a0d05]">
<div
aria-hidden
className="absolute inset-0 bg-cover bg-center"
style={{
backgroundImage:
"linear-gradient(115deg, rgba(20,10,5,0.35) 0%, rgba(40,20,10,0.45) 60%, rgba(20,10,5,0.65) 100%), url('" +
HERO_IMAGE_URL +
"')",
}}
/>
<div className="relative z-10 flex h-full w-full items-center px-12 md:px-20">
<h1 className="text-5xl font-medium tracking-tight text-white drop-shadow-[0_2px_24px_rgba(0,0,0,0.5)] sm:text-6xl md:text-[8rem] md:leading-[0.95]">
Quiet
<br />
structure
</h1>
</div>
</div>
);
}
const SELECT_LOGO = (
<svg
width="48"
height="48"
viewBox="0 0 423 423"
fill="none"
aria-label="Select Codes"
>
<path d="M213.736 65.7742L380.756 163.314V259.078C380.756 261.224 379.61 263.207 377.751 264.278L210.698 360.5L44.9872 264.281C43.138 263.207 42 261.231 42 259.092V163.314L207.666 65.7848C209.538 64.6827 211.86 64.6786 213.736 65.7742Z" fill="#FBB628" />
<path d="M42 163.314L210.698 261.267M42 163.314L207.666 65.7848C209.538 64.6827 211.86 64.6786 213.736 65.7742L380.756 163.314M42 163.314V259.092C42 261.231 43.138 263.207 44.9872 264.281L210.698 360.5M210.698 261.267L380.756 163.314M210.698 261.267V360.5M380.756 163.314V259.078C380.756 261.224 379.61 263.207 377.751 264.278L210.698 360.5" stroke="black" strokeWidth="6" strokeLinejoin="round" strokeLinecap="round" />
<path d="M214.696 92.1044L313 149.514V167.413C313 169.958 311.641 172.309 309.435 173.579L211.092 230.225L113.543 173.583C111.35 172.31 110 169.965 110 167.429V149.514L207.496 92.117C209.717 90.8097 212.471 90.8049 214.696 92.1044Z" fill="#FD7E41" />
<path d="M110 149.514L211.092 208.213M110 149.514L207.496 92.117C209.717 90.8097 212.471 90.8049 214.696 92.1044L313 149.514M110 149.514V167.429C110 169.965 111.35 172.31 113.543 173.583L211.092 230.225M211.092 208.213L313 149.514M211.092 208.213V230.225M313 149.514V167.413C313 169.958 311.641 172.309 309.435 173.579L211.092 230.225" stroke="black" strokeWidth="7.1166" strokeLinejoin="round" strokeLinecap="round" />
<path d="M156.626 134.212L177.466 146.293V150.762L156.626 162.678L135.952 150.762V146.293L156.626 134.212Z" fill="#FF6B35" />
<path d="M135.952 146.293L156.626 158.21M135.952 146.293L156.626 134.212L177.466 146.293M135.952 146.293V150.762L156.626 162.678M156.626 158.21L177.466 146.293M156.626 158.21V162.678M177.466 146.293V150.762L156.626 162.678" stroke="black" strokeWidth="2.3722" strokeLinejoin="round" strokeLinecap="round" />
<path d="M197.082 111.418L218.518 123.5V127.969L184.31 147.259L163.046 135.343V130.874L197.082 111.418Z" fill="white" />
<path d="M163.046 130.874L184.31 142.79M163.046 130.874L197.082 111.418L218.518 123.5M163.046 130.874V135.343L184.31 147.259M184.31 142.79L218.518 123.5M184.31 142.79V147.259M218.518 123.5V127.969L184.31 147.259" stroke="black" strokeWidth="2.3722" strokeLinejoin="round" strokeLinecap="round" />
<path d="M225.673 127L246.514 139.082V143.55L225.673 155.466L205 143.55V139.082L225.673 127Z" fill="white" />
<path d="M205 139.082L225.673 150.998M205 139.082L225.673 127L246.514 139.082M205 139.082V143.55L225.673 155.466M225.673 150.998L246.514 139.082M225.673 150.998V155.466M246.514 139.082V143.55L225.673 155.466" stroke="black" strokeWidth="2.3722" strokeLinejoin="round" strokeLinecap="round" />
<line x1="230.327" y1="250.699" x2="210.327" y2="288.699" stroke="black" strokeWidth="3" strokeLinecap="round" />
<path d="M247 239L209.74 309.793" stroke="black" strokeWidth="3" strokeLinecap="round" />
<path d="M380.26 223L351.383 277.865" stroke="black" strokeWidth="3" strokeLinecap="round" />
<path d="M381.904 243L367 271.317" stroke="black" strokeWidth="3" strokeLinecap="round" />
<path d="M264.26 230L211.165 330.881" stroke="black" strokeWidth="3" strokeLinecap="round" />
<path d="M379.89 197L330.986 289.916" stroke="black" strokeWidth="3" strokeLinecap="round" />
<path d="M283.834 218L209.315 359.587" stroke="black" strokeWidth="3" strokeLinecap="round" />
<path d="M302.327 207.832L228.47 348.16M321.747 196.69L247.89 337.018M341.151 186.475L267.294 326.803M362.95 173.881L289.093 314.21M381.5 166.758L309.5 303" stroke="black" strokeWidth="3" strokeLinecap="round" />
<path d="M197.036 143L218.472 155.082V159.55L184.264 178.84L163 166.924V162.456L197.036 143Z" fill="white" />
<path d="M163 162.456L184.264 174.372M163 162.456L197.036 143L218.472 155.082M163 162.456V166.924L184.264 178.84M184.264 174.372L218.472 155.082M184.264 174.372V178.84M218.472 155.082V159.55L184.264 178.84" stroke="black" strokeWidth="2.3722" strokeLinejoin="round" strokeLinecap="round" />
</svg>
);
Update the import paths to match your project setup.