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 menu that unrolls from its button, with a highlight that slides from item to item. Actions or choices, arrows and first letters.
With your AI
Say it to your AI. It gets the exact code below, not its best guess, and knows where it goes.
Put my account links in a Kodza Dropdown Menu.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<div class="kodza-dropdown">
<button class="kodza-dropdown__trigger" type="button" aria-haspopup="menu" aria-expanded="false">
Options
<svg class="kodza-dropdown__chevron" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 6.5 8 10.5l4-4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<div class="kodza-dropdown__menu" role="menu" aria-label="Options">
<button class="kodza-dropdown__item" type="button" role="menuitem">Copy code <kbd class="kodza-dropdown__hint">⌘C</kbd></button>
<button class="kodza-dropdown__item" type="button" role="menuitem">Download ZIP</button>
<button class="kodza-dropdown__item" type="button" role="menuitem">Open in a new tab</button>
<div class="kodza-dropdown__separator" role="separator"></div>
<button class="kodza-dropdown__item" type="button" role="menuitem" aria-disabled="true">Deploy on a Friday <span class="kodza-dropdown__hint">Nope</span></button>
</div>
</div>
<div class="kodza-dropdown">
<button class="kodza-dropdown__trigger" type="button" aria-haspopup="menu" aria-expanded="false">
Sort: <span class="kodza-dropdown__value">Newest</span>
<svg class="kodza-dropdown__chevron" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 6.5 8 10.5l4-4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<div class="kodza-dropdown__menu" role="menu" aria-label="Sort by">
<p class="kodza-dropdown__label" role="presentation">Sort by</p>
<button class="kodza-dropdown__item" type="button" role="menuitemradio" aria-checked="true">Newest</button>
<button class="kodza-dropdown__item" type="button" role="menuitemradio" aria-checked="false">Oldest</button>
<button class="kodza-dropdown__item" type="button" role="menuitemradio" aria-checked="false">Most copied</button>
<button class="kodza-dropdown__item" type="button" role="menuitemradio" aria-checked="false">Least broken</button>
</div>
</div>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Dropdown Menu
A button that unrolls a menu of actions, or of choices (menuitemradio: the picked one gets
a check and shows in the button). A highlight slides from item to item, under the pointer
or the arrow keys; the picked item blinks once before the menu closes.
Everything is tuned with the --kz-menu-* variables. */
.kodza-dropdown {
--kz-menu-bg: #ffffff;
--kz-menu-fg: #0a0a0a;
--kz-menu-muted: rgba(10, 10, 10, 0.58);
--kz-menu-border: rgba(10, 10, 10, 0.1);
--kz-menu-soft: rgba(10, 10, 10, 0.06);
--kz-menu-shadow: 0 18px 40px -16px rgba(10, 10, 10, 0.3);
--kz-menu-width: 220px;
--kz-menu-duration: 0.35s;
--kz-menu-ease: cubic-bezier(0.22, 1, 0.36, 1);
position: relative;
display: inline-block;
/* The closed menu keeps its size, ready to unroll, but never lengthens the page; contained,
the box loses its baseline, so it sits on the middle of the line like its label did */
contain: layout;
vertical-align: middle;
/* A number, not auto, so it can wait for the menu to roll up before dropping back */
z-index: 0;
transition: z-index 0s linear var(--kz-menu-duration);
color: var(--kz-menu-fg);
}
/* Open, the button rises above what follows it (its menu included) */
.kodza-dropdown.is-open {
z-index: 20;
transition-delay: 0s;
}
.kodza-dropdown__trigger {
display: inline-flex;
align-items: center;
gap: 8px;
height: 40px;
padding: 0 12px 0 16px;
border: 0;
border-radius: 999px;
background: var(--kz-menu-bg);
box-shadow: inset 0 0 0 1px var(--kz-menu-border);
color: inherit;
font: inherit;
font-size: 15px;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
transition: box-shadow 0.25s ease;
}
.kodza-dropdown__trigger:hover,
.kodza-dropdown__trigger[aria-expanded="true"] {
box-shadow: inset 0 0 0 1px var(--kz-menu-muted);
}
.kodza-dropdown__trigger:focus-visible {
outline: 2px solid var(--kz-menu-fg);
outline-offset: 2px;
}
.kodza-dropdown__value {
display: inline-grid;
margin-left: -4px;
overflow: hidden;
text-align: left;
white-space: nowrap;
}
.kodza-dropdown__value > span {
grid-area: 1 / 1;
}
.kodza-dropdown__chevron {
width: 16px;
height: 16px;
color: var(--kz-menu-muted);
transition: transform var(--kz-menu-duration) var(--kz-menu-ease);
}
.kodza-dropdown__trigger[aria-expanded="true"] .kodza-dropdown__chevron {
transform: rotate(180deg);
}
/* The menu unrolls from its top edge: clip-path, a little travel and a fade */
.kodza-dropdown__menu {
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 20;
display: flex;
flex-direction: column;
min-width: max(100%, var(--kz-menu-width));
padding: 6px;
border-radius: 14px;
background: var(--kz-menu-bg);
box-shadow: 0 0 0 1px var(--kz-menu-border), var(--kz-menu-shadow);
visibility: hidden;
opacity: 0;
clip-path: inset(0 0 100% 0 round 14px);
transform: translateY(-4px);
transition:
clip-path var(--kz-menu-duration) var(--kz-menu-ease),
opacity 0.2s ease,
transform var(--kz-menu-duration) var(--kz-menu-ease),
visibility 0s linear var(--kz-menu-duration);
}
.kodza-dropdown.is-open .kodza-dropdown__menu {
visibility: visible;
opacity: 1;
clip-path: inset(-40px -40px -60px -40px round 14px);
transform: none;
transition-delay: 0s;
}
/* Not enough room below: it opens upwards; too close to the right edge: it aligns right */
.kodza-dropdown.is-above .kodza-dropdown__menu {
top: auto;
bottom: calc(100% + 8px);
clip-path: inset(100% 0 0 0 round 14px);
transform: translateY(4px);
}
.kodza-dropdown.is-above.is-open .kodza-dropdown__menu {
clip-path: inset(-60px -40px -40px -40px round 14px);
transform: none;
}
.kodza-dropdown.is-end .kodza-dropdown__menu {
right: 0;
left: auto;
}
/* One highlight for the whole menu, sent to the focused item */
.kodza-dropdown__highlight {
position: absolute;
top: 0;
right: 6px;
left: 6px;
height: var(--kz-hl-h, 36px);
border-radius: 8px;
background: var(--kz-menu-soft);
opacity: var(--kz-hl-on, 0);
transform: translateY(var(--kz-hl-y, 0));
pointer-events: none;
}
.kodza-dropdown.is-open .kodza-dropdown__highlight {
transition: transform 0.22s var(--kz-menu-ease), opacity 0.15s ease;
}
.kodza-dropdown__item {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
height: 36px;
padding: 0 10px;
border: 0;
border-radius: 8px;
background: none;
color: inherit;
font: inherit;
font-size: 14px;
font-weight: 500;
text-align: left;
white-space: nowrap;
cursor: pointer;
}
.kodza-dropdown__item:focus {
outline: none;
}
.kodza-dropdown__item[aria-disabled="true"] {
color: var(--kz-menu-muted);
cursor: not-allowed;
}
/* Choices keep room for the check of the picked one */
.kodza-dropdown__item[role="menuitemradio"] {
justify-content: flex-start;
padding-left: 32px;
}
.kodza-dropdown__item[aria-checked="true"]::before {
content: "";
position: absolute;
left: 10px;
width: 14px;
height: 14px;
background: currentColor;
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3 8.5l3.2 3L13 4.5' fill='none' stroke='black' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3 8.5l3.2 3L13 4.5' fill='none' stroke='black' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / contain no-repeat;
}
.kodza-dropdown__hint {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
font-weight: 400;
color: var(--kz-menu-muted);
}
.kodza-dropdown__label {
margin: 0;
padding: 6px 10px 4px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 11px;
color: var(--kz-menu-muted);
}
.kodza-dropdown__separator {
height: 1px;
margin: 6px -6px;
background: var(--kz-menu-border);
}
/* The picked item blinks once, like a desktop menu */
.kodza-dropdown.is-picking .kodza-dropdown__highlight {
animation: kodza-dropdown-blink 0.16s steps(2, jump-none) 2;
}
@keyframes kodza-dropdown-blink {
50% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.kodza-dropdown__menu,
.kodza-dropdown.is-open .kodza-dropdown__highlight,
.kodza-dropdown__chevron {
transition-duration: 0.01ms !important;
}
.kodza-dropdown.is-picking .kodza-dropdown__highlight {
animation: none;
}
}
/* Theming example — on a dark background:
.dark-section .kodza-dropdown {
--kz-menu-bg: #161616;
--kz-menu-fg: #f2f2f0;
--kz-menu-muted: rgba(242, 242, 240, 0.52);
--kz-menu-border: rgba(255, 255, 255, 0.12);
--kz-menu-soft: rgba(255, 255, 255, 0.08);
--kz-menu-shadow: 0 18px 40px -16px rgba(0, 0, 0, 0.8);
}
*/
/* kodza-guard 0962f9ef0b8d — 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-dropdown__trigger):not(#_) {
outline: revert;
outline-offset: revert;
}
:where(.kodza-dropdown__item):not(#_) {
outline: revert;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-dropdown:not(#_),
.kodza-dropdown__highlight:not(#_),
.kodza-dropdown__separator:not(#_),
.kodza-dropdown__value:not(#_),
.kodza-dropdown__value span:where(:not([class*="kodza-"])):not(#_) {
box-sizing: revert;
}
.kodza-dropdown__trigger:not(#_) {
margin: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: 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;
min-height: revert;
}
.kodza-dropdown__chevron: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;
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-dropdown__menu:not(#_) {
outline: revert;
box-sizing: revert;
outline-offset: revert;
}
.kodza-dropdown__item:not(#_) {
margin: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
min-height: revert;
}
.kodza-dropdown__hint:not(#_) {
border: revert;
margin: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: 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;
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-dropdown__label:not(#_) {
border: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: 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;
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-dropdown__chevron:not(#_):where(:not([hidden])),
.kodza-dropdown__hint:not(#_):where(:not([hidden])),
.kodza-dropdown__label:not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-dropdown__chevron:not(#_):where([hidden]),
.kodza-dropdown__hint:not(#_):where([hidden]),
.kodza-dropdown__label:not(#_):where([hidden]) {
display: none;
}
.kodza-dropdown__item:not(#_)::before {
box-sizing: revert;
}
/* 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-dropdown__trigger:not(#_) {
display: inline-flex;
align-items: center;
gap: 8px;
height: 40px;
padding: 0 12px 0 16px;
border: 0;
border-radius: 999px;
background: var(--kz-menu-bg);
box-shadow: inset 0 0 0 1px var(--kz-menu-border);
color: inherit;
font: inherit;
font-size: 15px;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
transition: box-shadow 0.25s ease;
}
.kodza-dropdown__trigger:hover:not(#_),
.kodza-dropdown__trigger[aria-expanded="true"]:not(#_) {
box-shadow: inset 0 0 0 1px var(--kz-menu-muted);
}
.kodza-dropdown__trigger:focus-visible:not(#_) {
outline: 2px solid var(--kz-menu-fg);
outline-offset: 2px;
}
.kodza-dropdown__chevron:not(#_) {
color: var(--kz-menu-muted);
transition: transform var(--kz-menu-duration) var(--kz-menu-ease);
}
.kodza-dropdown__menu:not(#_) {
box-shadow: 0 0 0 1px var(--kz-menu-border), var(--kz-menu-shadow);
}
.kodza-dropdown__item:not(#_) {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
height: 36px;
padding: 0 10px;
border: 0;
border-radius: 8px;
background: none;
color: inherit;
font: inherit;
font-size: 14px;
font-weight: 500;
text-align: left;
white-space: nowrap;
cursor: pointer;
}
.kodza-dropdown__item:focus:not(#_) {
outline: none;
}
.kodza-dropdown__item[aria-disabled="true"]:not(#_) {
color: var(--kz-menu-muted);
cursor: not-allowed;
}
.kodza-dropdown__item[role="menuitemradio"]:not(#_) {
justify-content: flex-start;
padding-left: 32px;
}
.kodza-dropdown__item[aria-checked="true"]:not(#_)::before {
content: "";
position: absolute;
left: 10px;
width: 14px;
height: 14px;
background: currentColor;
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3 8.5l3.2 3L13 4.5' fill='none' stroke='black' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3 8.5l3.2 3L13 4.5' fill='none' stroke='black' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / contain no-repeat;
}
.kodza-dropdown__hint:where(kbd):not(#_) {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
font-weight: 400;
color: var(--kz-menu-muted);
}
.kodza-dropdown__label:not(#_) {
margin: 0;
padding: 6px 10px 4px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 11px;
color: var(--kz-menu-muted);
}
@media (prefers-reduced-motion: reduce) {
.kodza-dropdown__chevron:not(#_) {
transition-duration: 0.01ms !important;
}
}
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — Dropdown Menu
// Opens and closes the menu (click, Enter, Space, arrows), moves the focus and the highlight
// from item to item (arrows, Home, End, first letter), and closes on Escape, Tab or a click
// outside. Picking an item fires "kodza:select" with { item, value } on the dropdown.
(() => {
const SELECTOR = '.kodza-dropdown';
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// One instance. Everything it starts is tied to `signal`, so kodza:destroy stops it all
function mount(root, signal) {
const trigger = root.querySelector('.kodza-dropdown__trigger');
const menu = root.querySelector('.kodza-dropdown__menu');
if (!trigger || !menu) return;
const items = [...menu.querySelectorAll('.kodza-dropdown__item')];
const value = trigger.querySelector('.kodza-dropdown__value');
const highlight = document.createElement('span');
highlight.className = 'kodza-dropdown__highlight';
highlight.setAttribute('aria-hidden', 'true');
menu.prepend(highlight);
items.forEach((item) => (item.tabIndex = -1));
const isOpen = () => root.classList.contains('is-open');
// The picked value rolls into the button: the old word leaves upwards, the new one rises
if (value) value.replaceChildren(Object.assign(document.createElement('span'), { textContent: value.textContent.trim() }));
// and the button eases to its new width instead of jumping
const roll = (text) => {
const prev = value.lastElementChild;
if (!prev || prev.textContent === text) return;
const from = value.offsetWidth;
const span = document.createElement('span');
span.textContent = text;
value.append(span);
if (reduceMotion) return prev.remove();
const to = span.offsetWidth;
const timing = { duration: 450, delay: 280, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' };
prev.setAttribute('aria-hidden', 'true');
value.animate([{ width: `${from}px` }, { width: `${to}px` }], { ...timing, fill: 'backwards' });
prev.animate([{ transform: 'none' }, { transform: 'translateY(-110%)' }], { ...timing, fill: 'forwards' }).finished.then(() => prev.remove());
span.animate([{ transform: 'translateY(110%)' }, { transform: 'none' }], { ...timing, fill: 'backwards' });
};
const light = (item) => {
if (!item) {
menu.style.setProperty('--kz-hl-on', 0);
return;
}
menu.style.setProperty('--kz-hl-y', `${item.offsetTop}px`);
menu.style.setProperty('--kz-hl-h', `${item.offsetHeight}px`);
menu.style.setProperty('--kz-hl-on', 1);
};
const focusItem = (item) => {
if (!item) return;
item.focus({ preventScroll: true });
light(item);
};
const open = (which = 'none') => {
if (!isOpen()) {
const box = trigger.getBoundingClientRect();
const height = menu.offsetHeight + 8;
const above = box.bottom + height > innerHeight - 8 && box.top - height > 8;
// A closed menu that changes side gets there at once, so it always unrolls from the button
if (above !== root.classList.contains('is-above')) {
menu.style.transition = 'none';
root.classList.toggle('is-above', above);
void menu.offsetWidth;
menu.style.transition = '';
}
root.classList.toggle('is-end', box.left + menu.offsetWidth > innerWidth - 8);
root.classList.add('is-open');
trigger.setAttribute('aria-expanded', 'true');
}
const checked = items.find((item) => item.getAttribute('aria-checked') === 'true');
if (which === 'first') focusItem(checked || items[0]);
else if (which === 'last') focusItem(items[items.length - 1]);
else light(null);
};
const close = (refocus = false) => {
if (!isOpen()) return;
root.classList.remove('is-open', 'is-picking');
trigger.setAttribute('aria-expanded', 'false');
light(null);
if (refocus) trigger.focus();
};
const pick = (item) => {
if (item.getAttribute('aria-disabled') === 'true') return;
if (item.getAttribute('role') === 'menuitemradio') {
items.forEach((other) => other.getAttribute('role') === 'menuitemradio' && other.setAttribute('aria-checked', String(other === item)));
if (value) roll(item.textContent.trim());
}
root.dispatchEvent(new CustomEvent('kodza:select', { detail: { item, value: item.textContent.trim() }, bubbles: true }));
light(item);
root.classList.add('is-picking');
setTimeout(() => !signal.aborted && close(true), reduceMotion ? 0 : 320);
};
trigger.addEventListener('click', () => (isOpen() ? close() : open()), { signal });
trigger.addEventListener('keydown', (event) => {
if (['ArrowDown', 'Enter', ' '].includes(event.key)) {
event.preventDefault();
open('first');
} else if (event.key === 'ArrowUp') {
event.preventDefault();
open('last');
}
}, { signal });
items.forEach((item) => {
item.addEventListener('click', () => pick(item), { signal });
item.addEventListener('pointermove', () => document.activeElement !== item && focusItem(item), { signal });
});
menu.addEventListener('pointerleave', () => {
if (menu.contains(document.activeElement)) menu.focus?.({ preventScroll: true });
light(null);
}, { signal });
menu.addEventListener('keydown', (event) => {
const index = items.indexOf(document.activeElement);
const moves = { ArrowDown: index + 1, ArrowUp: index - 1, Home: 0, End: items.length - 1 };
if (event.key in moves) {
event.preventDefault();
focusItem(items[(moves[event.key] + items.length) % items.length]);
} else if (event.key === 'Escape') {
event.preventDefault();
close(true);
} else if (event.key === 'Tab') {
close();
} else if (event.key.length === 1 && /\S/.test(event.key)) {
// Type a letter: jump to the next item that starts with it
const letter = event.key.toLowerCase();
const order = [...items.slice(index + 1), ...items.slice(0, index + 1)];
focusItem(order.find((item) => item.textContent.trim().toLowerCase().startsWith(letter)));
}
}, { signal });
document.addEventListener('pointerdown', (event) => {
if (isOpen() && !root.contains(event.target)) close();
}, { signal });
// Escape closes it wherever the focus is: opened with a click, it stays on the button
// (Chrome) or on the page (Safari), not in the menu
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || !isOpen()) return;
event.preventDefault();
close(true);
}, { signal });
menu.tabIndex = -1;
}
// 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 — Dropdown Menu, for React. From https://kodza.dev/components/dropdown-menu/
// 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: dropdown-menu.css and dropdown-menu.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 './dropdown-menu.css';
export type DropdownMenuProps = Omit<ComponentPropsWithoutRef<'div'>, "children" | "vars" | "onSelect"> & {
/** Settings, as CSS variables on the root */
vars?: {
/** default: #ffffff */
"--kz-menu-bg"?: string | number;
/** default: #0a0a0a */
"--kz-menu-fg"?: string | number;
/** default: rgba(10, 10, 10, 0.58) */
"--kz-menu-muted"?: string | number;
/** default: rgba(10, 10, 10, 0.1) */
"--kz-menu-border"?: string | number;
/** default: rgba(10, 10, 10, 0.06) */
"--kz-menu-soft"?: string | number;
/** default: 0 18px 40px -16px rgba(10, 10, 10, 0.3) */
"--kz-menu-shadow"?: string | number;
/** default: 220px */
"--kz-menu-width"?: string | number;
/** default: 0.35s */
"--kz-menu-duration"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-menu-ease"?: string | number;
};
/** "kodza:select", fired by the script (bubbles from the root) */
onSelect?: (event: CustomEvent) => void;
className?: string;
};
export function DropdownMenu({ vars, onSelect, className, style, ...rest }: DropdownMenuProps) {
const ref = useRef<ComponentRef<'div'>>(null);
const classes = ["kodza-dropdown", 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('./dropdown-menu.js').then(() => {
if (live) document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root } }));
});
return () => {
live = false;
document.dispatchEvent(new CustomEvent('kodza:destroy', { detail: { root } }));
};
}, [key]);
useEffect(() => {
const root = ref.current;
if (!root || !onSelect) return;
const listener = (event: Event) => onSelect(event as CustomEvent);
root.addEventListener("kodza:select", listener);
return () => root.removeEventListener("kodza:select", listener);
}, [onSelect, key]);
return (
<div key={key} {...rest} className={classes} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<button className="kodza-dropdown__trigger" type="button" aria-haspopup="menu" aria-expanded="false"> Options <svg className="kodza-dropdown__chevron" viewBox="0 0 16 16" aria-hidden="true"> <path d="M4 6.5 8 10.5l4-4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /> </svg> </button>
<div className="kodza-dropdown__menu" role="menu" aria-label="Options">
<button className="kodza-dropdown__item" type="button" role="menuitem">Copy code <kbd className="kodza-dropdown__hint">⌘C</kbd></button>
{' '}
<button className="kodza-dropdown__item" type="button" role="menuitem">Download ZIP</button>
{' '}
<button className="kodza-dropdown__item" type="button" role="menuitem">Open in a new tab</button>
<div className="kodza-dropdown__separator" role="separator" />
<button className="kodza-dropdown__item" type="button" role="menuitem" aria-disabled="true">Deploy on a Friday <span className="kodza-dropdown__hint">Nope</span></button>
</div>
</div>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.