Trapdoor Menu
Press Menu and the page swings down on its hinge like a trapdoor, showing the menu underneath: links rise as the edge passes them, an image follows your pointer. Closing replays it backwards. No GSAP.
View codeA button that lets its links out one by one, nearest first, and calls them back in reverse. Its label rolls to "Close". No GSAP.
With your AI
Say it to your AI. It gets the exact code below, not its best guess, and knows where it goes.
Fold my header links into a Kodza Staggered Horizontal Menu.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<nav class="kodza-stagger-menu" aria-label="Main">
<ul class="kodza-stagger-menu__links">
<li><a class="kodza-stagger-menu__link" href="#">Work</a></li>
<li><a class="kodza-stagger-menu__link" href="#">Studio</a></li>
<li><a class="kodza-stagger-menu__link" href="#">Journal</a></li>
<li><a class="kodza-stagger-menu__link" href="#">Contact</a></li>
</ul>
<button class="kodza-stagger-menu__toggle" type="button" aria-expanded="false">
<span class="kodza-stagger-menu__label" aria-hidden="true"><span>Menu</span><span>Close</span></span>
<span class="kodza-stagger-menu__sr">Menu</span>
</button>
</nav>
<nav class="kodza-stagger-menu kodza-stagger-menu--right" aria-label="Docs">
<ul class="kodza-stagger-menu__links">
<li><a class="kodza-stagger-menu__link" href="#">Install</a></li>
<li><a class="kodza-stagger-menu__link" href="#">Customize</a></li>
<li><a class="kodza-stagger-menu__link" href="#">Ship</a></li>
<li><a class="kodza-stagger-menu__link" href="#">FAQ</a></li>
</ul>
<button class="kodza-stagger-menu__toggle" type="button" aria-expanded="false">
<span class="kodza-stagger-menu__label" aria-hidden="true"><span>Docs</span><span>Close</span></span>
<span class="kodza-stagger-menu__sr">Docs</span>
</button>
</nav>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Staggered Horizontal Menu
A button that lets its links out one by one, the nearest first, and calls them back in
reverse order when it closes; its label rolls from "Menu" to "Close". The links sit on the
left of the button, or on its right with --right. Pure CSS motion, a few lines of JS.
Everything is tuned with the --kz-stagger-* variables. */
.kodza-stagger-menu {
--kz-stagger-fg: #0a0a0a;
--kz-stagger-muted: rgba(10, 10, 10, 0.58);
--kz-stagger-ink: #0a0a0a;
--kz-stagger-on-ink: #ffffff;
--kz-stagger-step: 50ms;
--kz-stagger-travel: 14px;
--kz-stagger-duration: 0.45s;
--kz-stagger-ease: cubic-bezier(0.22, 1, 0.36, 1);
display: inline-flex;
align-items: center;
gap: 20px;
color: var(--kz-stagger-fg);
}
.kodza-stagger-menu--right {
flex-direction: row-reverse;
}
.kodza-stagger-menu__links {
display: flex;
gap: 20px;
margin: 0;
padding: 0;
list-style: none;
visibility: hidden;
transition: visibility 0s linear calc(var(--kz-stagger-duration) + var(--kz-stagger-n, 4) * var(--kz-stagger-step));
}
.kodza-stagger-menu.is-open .kodza-stagger-menu__links {
visibility: visible;
transition-delay: 0s;
}
/* Closed: each link waits beside the button. Opening: nearest first; closing: farthest first */
.kodza-stagger-menu__links li {
opacity: 0;
transform: translateX(var(--kz-stagger-travel));
transition:
opacity 0.25s ease,
transform var(--kz-stagger-duration) var(--kz-stagger-ease);
transition-delay: calc((var(--kz-stagger-n, 4) - 1 - var(--i, 0)) * var(--kz-stagger-step));
}
.kodza-stagger-menu--right .kodza-stagger-menu__links li {
transform: translateX(calc(var(--kz-stagger-travel) * -1));
}
.kodza-stagger-menu.is-open .kodza-stagger-menu__links li {
opacity: 1;
transform: none;
transition-delay: calc(var(--i, 0) * var(--kz-stagger-step));
}
.kodza-stagger-menu__link {
border-radius: 4px;
background: linear-gradient(currentColor, currentColor) 0 100% / 0 1px no-repeat;
color: var(--kz-stagger-muted);
font-size: 15px;
font-weight: 500;
text-decoration: none;
white-space: nowrap;
transition: color 0.25s ease, background-size 0.4s var(--kz-stagger-ease);
}
.kodza-stagger-menu__link:hover {
background-size: 100% 1px;
color: var(--kz-stagger-fg);
}
.kodza-stagger-menu__link:focus-visible,
.kodza-stagger-menu__toggle:focus-visible {
outline: 2px solid var(--kz-stagger-fg);
outline-offset: 3px;
}
/* The button: its label rolls from "Menu" to "Close" */
.kodza-stagger-menu__toggle {
position: relative;
display: inline-grid;
place-items: center;
height: 40px;
padding: 0 18px;
border: 0;
border-radius: 999px;
background: var(--kz-stagger-ink);
color: var(--kz-stagger-on-ink);
font: inherit;
font-size: 15px;
font-weight: 500;
cursor: pointer;
}
.kodza-stagger-menu__label {
display: grid;
height: 1.3em;
overflow: hidden;
line-height: 1.3;
}
.kodza-stagger-menu__label span {
grid-area: 1 / 1;
transition: transform var(--kz-stagger-duration) var(--kz-stagger-ease);
}
.kodza-stagger-menu__label span + span {
transform: translateY(110%);
}
.kodza-stagger-menu.is-open .kodza-stagger-menu__label span:first-child {
transform: translateY(-110%);
}
.kodza-stagger-menu.is-open .kodza-stagger-menu__label span + span {
transform: none;
}
.kodza-stagger-menu__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.kodza-stagger-menu__links,
.kodza-stagger-menu__links li,
.kodza-stagger-menu__label span,
.kodza-stagger-menu__link {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
}
}
/* Theming example — on a dark background:
.dark-section .kodza-stagger-menu {
--kz-stagger-fg: #f2f2f0;
--kz-stagger-muted: rgba(242, 242, 240, 0.6);
--kz-stagger-ink: #f2f2f0;
--kz-stagger-on-ink: #0a0a0a;
}
*/
/* kodza-guard 6699b9835364 — generated by Kodza, do not edit.
Theme guard: keeps a site's own CSS (WordPress themes, Bootstrap, CSS resets…) off this
component. :not(#_) is always true and weighs as much as an id, so these lines win over
theme rules made of classes and tags. Customise the component through its --kz-* variables. */
/* What the browser gives, where the component sets it only in some states: at the lowest weight, so its own rules below put it back */
:where(.kodza-stagger-menu__link):not(#_),
:where(.kodza-stagger-menu__toggle):not(#_) {
outline: revert;
outline-offset: revert;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-stagger-menu:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-height: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-stagger-menu__links:not(#_) {
border: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-stagger-menu__link:not(#_) {
border: revert;
margin: revert;
padding: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
box-shadow: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-family: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-stagger-menu__toggle:not(#_) {
margin: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
box-shadow: revert;
appearance: revert;
max-width: revert;
max-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
white-space: revert;
min-height: revert;
}
.kodza-stagger-menu__label:not(#_),
.kodza-stagger-menu__sr:not(#_),
.kodza-stagger-menu__label span:where(:not([class*="kodza-"])):not(#_) {
box-sizing: revert;
}
.kodza-stagger-menu__links li:where(:not([class*="kodza-"]):not(.kodza-stagger-menu__links [class*="kodza-"] *)):not(#_) {
border: revert;
margin: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-stagger-menu__link:not(#_):where(:not([hidden])),
.kodza-stagger-menu__links li:where(:not([class*="kodza-"]):not(.kodza-stagger-menu__links [class*="kodza-"] *)):not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-stagger-menu__link:not(#_):where([hidden]),
.kodza-stagger-menu__links li:where(:not([class*="kodza-"]):not(.kodza-stagger-menu__links [class*="kodza-"] *)):not(#_):where([hidden]) {
display: none;
}
/* The component's own rules, one step stronger: real buttons and fields, what it sets on tags themes style (links, lists, headings, images…), and the root width (a theme may narrow it, never widen it) */
.kodza-stagger-menu:not(#_) {
display: inline-flex;
color: var(--kz-stagger-fg);
}
.kodza-stagger-menu__links:not(#_) {
display: flex;
margin: 0;
padding: 0;
list-style: none;
transition: visibility 0s linear calc(var(--kz-stagger-duration) + var(--kz-stagger-n, 4) * var(--kz-stagger-step));
}
.kodza-stagger-menu.is-open .kodza-stagger-menu__links:not(#_) {
transition-delay: 0s;
}
.kodza-stagger-menu__links li:not(#_) {
transition: opacity 0.25s ease,
transform var(--kz-stagger-duration) var(--kz-stagger-ease);
transition-delay: calc((var(--kz-stagger-n, 4) - 1 - var(--i, 0)) * var(--kz-stagger-step));
}
.kodza-stagger-menu.is-open .kodza-stagger-menu__links li:not(#_) {
transition-delay: calc(var(--i, 0) * var(--kz-stagger-step));
}
.kodza-stagger-menu__link:not(#_) {
border-radius: 4px;
background: linear-gradient(currentColor, currentColor) 0 100% / 0 1px no-repeat;
color: var(--kz-stagger-muted);
font-size: 15px;
font-weight: 500;
text-decoration: none;
transition: color 0.25s ease, background-size 0.4s var(--kz-stagger-ease);
}
.kodza-stagger-menu__link:hover:not(#_) {
background-size: 100% 1px;
color: var(--kz-stagger-fg);
}
.kodza-stagger-menu__link:focus-visible:not(#_) {
outline: 2px solid var(--kz-stagger-fg);
outline-offset: 3px;
}
.kodza-stagger-menu__toggle:focus-visible:not(#_) {
outline: 2px solid var(--kz-stagger-fg);
outline-offset: 3px;
}
.kodza-stagger-menu__toggle:not(#_) {
position: relative;
display: inline-grid;
place-items: center;
height: 40px;
padding: 0 18px;
border: 0;
border-radius: 999px;
background: var(--kz-stagger-ink);
color: var(--kz-stagger-on-ink);
font: inherit;
font-size: 15px;
font-weight: 500;
cursor: pointer;
}
@media (prefers-reduced-motion: reduce) {
.kodza-stagger-menu__links:not(#_),
.kodza-stagger-menu__links li:not(#_),
.kodza-stagger-menu__link:not(#_) {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
}
}
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — Staggered Horizontal Menu
// Numbers the links from the button outwards (for the stagger), opens and closes the menu,
// and closes it on Escape or a click elsewhere. The motion itself is CSS.
(() => {
const SELECTOR = '.kodza-stagger-menu';
// One instance. Everything it starts is tied to `signal`, so kodza:destroy stops it all
function mount(root, signal) {
const toggle = root.querySelector('.kodza-stagger-menu__toggle');
const items = [...root.querySelectorAll('.kodza-stagger-menu__links li')];
if (!toggle || !items.length) return;
// The link next to the button is number 0
const right = root.classList.contains('kodza-stagger-menu--right');
items.forEach((item, i) => item.style.setProperty('--i', right ? i : items.length - 1 - i));
root.style.setProperty('--kz-stagger-n', items.length);
const set = (open) => {
root.classList.toggle('is-open', open);
toggle.setAttribute('aria-expanded', String(open));
};
toggle.addEventListener('click', () => set(!root.classList.contains('is-open')), { signal });
root.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && root.classList.contains('is-open')) {
set(false);
toggle.focus();
}
}, { signal });
document.addEventListener('pointerdown', (event) => {
if (!root.contains(event.target)) set(false);
}, { signal });
}
// Kodza lifecycle, the same in every component: `kodza:init` starts the instances inside
// event.detail.root (the whole page by default), `kodza:destroy` stops them and puts their
// markup back. For themes and apps that swap content without a page load.
const instances = new WeakMap();
const find = (scope = document) => [scope, ...scope.querySelectorAll(SELECTOR)].filter((el) => el.matches?.(SELECTOR));
const init = (scope) => find(scope).forEach((root) => {
if (root.classList.contains('is-ready')) return; // already running, maybe from another copy of this script
const controller = new AbortController();
instances.set(root, { controller, html: root.innerHTML, attrs: [...root.attributes].map((a) => [a.name, a.value]) });
mount(root, controller.signal);
root.classList.add('is-ready');
});
const destroy = (scope) => find(scope).forEach((root) => {
const instance = instances.get(root);
if (!instance) return;
instances.delete(root);
instance.controller.abort();
[...root.attributes].forEach((a) => root.removeAttribute(a.name));
instance.attrs.forEach(([name, value]) => root.setAttribute(name, value));
root.innerHTML = instance.html;
});
document.addEventListener('kodza:init', (event) => init(event.detail?.root));
document.addEventListener('kodza:destroy', (event) => destroy(event.detail?.root));
init();
// Loaded in <head>, the script runs before the markup exists: once more when it is parsed
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => init(), { once: true });
})();
'use client';
// Kodza — Staggered Horizontal Menu, for React. From https://kodza.dev/components/menu-horizontal-stagger-reverse/
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Next to this file: menu-horizontal-stagger-reverse.css and menu-horizontal-stagger-reverse.js, the component's CSS and JS from Kodza, as they are.
// Yours to edit: the markup is plain JSX below.
// The script changes this markup once React has rendered it (it adds layers, splits text, moves
// nodes), so React must not patch it afterwards. When a prop changes, the key gives React a fresh
// element: kodza:destroy stops the script on the old one, kodza:init starts it on the new one.
import { useRef, useEffect, type ComponentPropsWithoutRef, type ComponentRef, type CSSProperties } from 'react';
import './menu-horizontal-stagger-reverse.css';
type Variant = "right";
export type StaggeredHorizontalMenuProps = Omit<ComponentPropsWithoutRef<'nav'>, "children" | "variant" | "vars"> & {
/** Modifier class(es): .kodza-stagger-menu--right */
variant?: Variant | Variant[];
/** Settings, as CSS variables on the root */
vars?: {
/** default: #0a0a0a */
"--kz-stagger-fg"?: string | number;
/** default: rgba(10, 10, 10, 0.58) */
"--kz-stagger-muted"?: string | number;
/** default: #0a0a0a */
"--kz-stagger-ink"?: string | number;
/** default: #ffffff */
"--kz-stagger-on-ink"?: string | number;
/** default: 50ms */
"--kz-stagger-step"?: string | number;
/** default: 14px */
"--kz-stagger-travel"?: string | number;
/** default: 0.45s */
"--kz-stagger-duration"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-stagger-ease"?: string | number;
};
className?: string;
};
export function StaggeredHorizontalMenu({ variant, vars, className, style, ...rest }: StaggeredHorizontalMenuProps) {
const ref = useRef<ComponentRef<'nav'>>(null);
const classes = ["kodza-stagger-menu", ...([] as string[]).concat(variant ?? []).map((v) => `kodza-stagger-menu--${v}`), className].filter(Boolean).join(' ');
// Everything the markup is made of: when it changes, React gets a fresh element (see the top)
const key = JSON.stringify([classes, vars, style, rest]);
// Starts the script on this element once React has rendered it, stops it when it goes
useEffect(() => {
const root = ref.current;
let live = true;
// @ts-ignore: a plain script, no types to find
import('./menu-horizontal-stagger-reverse.js').then(() => {
if (live) document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root } }));
});
return () => {
live = false;
document.dispatchEvent(new CustomEvent('kodza:destroy', { detail: { root } }));
};
}, [key]);
return (
<nav aria-label="Main" key={key} {...rest} className={classes} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<ul className="kodza-stagger-menu__links">
<li>
<a className="kodza-stagger-menu__link" href="#">Work</a>
</li>
<li>
<a className="kodza-stagger-menu__link" href="#">Studio</a>
</li>
<li>
<a className="kodza-stagger-menu__link" href="#">Journal</a>
</li>
<li>
<a className="kodza-stagger-menu__link" href="#">Contact</a>
</li>
</ul>
<button className="kodza-stagger-menu__toggle" type="button" aria-expanded="false">
<span className="kodza-stagger-menu__label" aria-hidden="true">
<span>Menu</span>
<span>Close</span>
</span>
{' '}
<span className="kodza-stagger-menu__sr">Menu</span>
</button>
</nav>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.