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 breadcrumb trail that folds its middle behind a "…" button when it would not fit on one line, and unfolds it crumb after crumb.
With your AI
Say it to your AI. It gets the exact code below, not its best guess, and knows where it goes.
Add Kodza Breadcrumbs above my product pages.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<nav class="kodza-breadcrumbs" aria-label="Breadcrumb">
<ol class="kodza-breadcrumbs__list">
<li class="kodza-breadcrumbs__item"><a class="kodza-breadcrumbs__link" href="#">Home</a></li>
<li class="kodza-breadcrumbs__item"><a class="kodza-breadcrumbs__link" href="#">Docs</a></li>
<li class="kodza-breadcrumbs__item"><a class="kodza-breadcrumbs__link" href="#">Components</a></li>
<li class="kodza-breadcrumbs__item"><span class="kodza-breadcrumbs__current" aria-current="page">Breadcrumbs</span></li>
</ol>
</nav>
<nav class="kodza-breadcrumbs kodza-breadcrumbs--collapse" aria-label="Breadcrumb">
<ol class="kodza-breadcrumbs__list">
<li class="kodza-breadcrumbs__item"><a class="kodza-breadcrumbs__link" href="#">Home</a></li>
<li class="kodza-breadcrumbs__item"><a class="kodza-breadcrumbs__link" href="#">Side projects</a></li>
<li class="kodza-breadcrumbs__item"><a class="kodza-breadcrumbs__link" href="#">Unfinished</a></li>
<li class="kodza-breadcrumbs__item"><a class="kodza-breadcrumbs__link" href="#">2019</a></li>
<li class="kodza-breadcrumbs__item"><span class="kodza-breadcrumbs__current" aria-current="page">todo-app-final-v3</span></li>
</ol>
</nav>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Breadcrumbs
Where the visitor is, one link per level. When the trail would not fit on one line (or
always, with the --collapse modifier), its middle folds behind a "…" button that unfolds
it, crumb after crumb. Without JS, the whole trail shows and wraps.
Everything is tuned with the --kz-crumbs-* variables. */
.kodza-breadcrumbs {
--kz-crumbs-fg: #0a0a0a;
--kz-crumbs-muted: rgba(10, 10, 10, 0.58);
--kz-crumbs-separator: rgba(10, 10, 10, 0.28);
--kz-crumbs-soft: rgba(10, 10, 10, 0.06);
--kz-crumbs-soft-fg: #ffffff;
--kz-crumbs-size: 15px;
--kz-crumbs-gap: 10px;
--kz-crumbs-ease: cubic-bezier(0.22, 1, 0.36, 1);
font-size: var(--kz-crumbs-size);
line-height: 1.4;
color: var(--kz-crumbs-fg);
}
.kodza-breadcrumbs__list {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px var(--kz-crumbs-gap);
margin: 0;
padding: 0;
list-style: none;
}
.kodza-breadcrumbs__item {
display: inline-flex;
align-items: center;
gap: var(--kz-crumbs-gap);
min-width: 0;
}
/* The slash says which way the path goes */
.kodza-breadcrumbs__item + .kodza-breadcrumbs__item::before {
content: "/";
color: var(--kz-crumbs-separator);
}
.kodza-breadcrumbs__link {
border-radius: 4px;
background: linear-gradient(currentColor, currentColor) 0 100% / 0 1px no-repeat;
color: var(--kz-crumbs-muted);
text-decoration: none;
transition: color 0.25s ease, background-size 0.4s var(--kz-crumbs-ease);
}
.kodza-breadcrumbs__link:hover {
background-size: 100% 1px;
color: var(--kz-crumbs-fg);
}
.kodza-breadcrumbs__link:focus-visible,
.kodza-breadcrumbs__more button:focus-visible {
outline: 2px solid var(--kz-crumbs-fg);
outline-offset: 3px;
}
.kodza-breadcrumbs__current {
overflow: hidden;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
/* The fold: only shown while the middle is folded */
.kodza-breadcrumbs__more {
display: none;
}
.kodza-breadcrumbs__more button {
display: grid;
place-items: center;
width: 32px;
height: 24px;
padding: 0 0 4px;
border: 0;
border-radius: 999px;
background: var(--kz-crumbs-soft);
color: var(--kz-crumbs-muted);
font: inherit;
line-height: 1;
letter-spacing: 0.05em;
cursor: pointer;
transition: background-color 0.25s ease, color 0.25s ease;
}
.kodza-breadcrumbs__more button:hover {
background: var(--kz-crumbs-fg);
color: var(--kz-crumbs-soft-fg);
}
.kodza-breadcrumbs.is-collapsed .kodza-breadcrumbs__more {
display: inline-flex;
}
.kodza-breadcrumbs.is-collapsed .kodza-breadcrumbs__folded {
display: none;
}
/* Unfolding: the hidden crumbs slide in one after the other */
.kodza-breadcrumbs.is-unfolding .kodza-breadcrumbs__folded {
animation: kodza-crumbs-in 0.6s var(--kz-crumbs-ease) both;
animation-delay: calc(80ms + var(--i, 0) * 70ms);
}
@keyframes kodza-crumbs-in {
from {
opacity: 0;
transform: translateX(-12px);
}
}
@media (prefers-reduced-motion: reduce) {
.kodza-breadcrumbs.is-unfolding .kodza-breadcrumbs__folded {
animation: none;
}
.kodza-breadcrumbs__link,
.kodza-breadcrumbs__more button {
transition-duration: 0.01ms !important;
}
}
/* Theming example — on a dark background:
.dark-section .kodza-breadcrumbs {
--kz-crumbs-fg: #f2f2f0;
--kz-crumbs-muted: rgba(242, 242, 240, 0.6);
--kz-crumbs-separator: rgba(242, 242, 240, 0.3);
--kz-crumbs-soft: rgba(255, 255, 255, 0.1);
--kz-crumbs-soft-fg: #0a0a0a;
}
*/
/* kodza-guard 70c65248e6f4 — 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-breadcrumbs__link):not(#_),
:where(.kodza-breadcrumbs__item button:where(:not([class*="kodza-"]))):not(#_) {
outline: revert;
outline-offset: revert;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-breadcrumbs: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-weight: revert;
font-style: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-breadcrumbs__list:not(#_) {
border: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: 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-breadcrumbs__item: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;
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-breadcrumbs__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-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-breadcrumbs__current:not(#_) {
box-sizing: revert;
}
.kodza-breadcrumbs__item button:where(:not([class*="kodza-"])):not(#_) {
margin: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
box-shadow: revert;
appearance: revert;
max-width: revert;
max-height: 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-breadcrumbs:not(#_):where(:not([hidden])),
.kodza-breadcrumbs__link:not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-breadcrumbs:not(#_):where([hidden]),
.kodza-breadcrumbs__link:not(#_):where([hidden]) {
display: none;
}
.kodza-breadcrumbs__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-breadcrumbs:not(#_) {
font-size: var(--kz-crumbs-size);
line-height: 1.4;
color: var(--kz-crumbs-fg);
}
.kodza-breadcrumbs__list:not(#_) {
display: flex;
margin: 0;
padding: 0;
list-style: none;
}
.kodza-breadcrumbs__item:not(#_) {
display: inline-flex;
}
.kodza-breadcrumbs__link:not(#_) {
border-radius: 4px;
background: linear-gradient(currentColor, currentColor) 0 100% / 0 1px no-repeat;
color: var(--kz-crumbs-muted);
text-decoration: none;
transition: color 0.25s ease, background-size 0.4s var(--kz-crumbs-ease);
}
.kodza-breadcrumbs__link:hover:not(#_) {
background-size: 100% 1px;
color: var(--kz-crumbs-fg);
}
.kodza-breadcrumbs__link:focus-visible:not(#_) {
outline: 2px solid var(--kz-crumbs-fg);
outline-offset: 3px;
}
.kodza-breadcrumbs__more button:focus-visible:not(#_) {
outline: 2px solid var(--kz-crumbs-fg);
outline-offset: 3px;
}
.kodza-breadcrumbs__more:not(#_) {
display: none;
}
.kodza-breadcrumbs__more button:not(#_) {
display: grid;
place-items: center;
width: 32px;
height: 24px;
padding: 0 0 4px;
border: 0;
border-radius: 999px;
background: var(--kz-crumbs-soft);
color: var(--kz-crumbs-muted);
font: inherit;
line-height: 1;
letter-spacing: 0.05em;
cursor: pointer;
transition: background-color 0.25s ease, color 0.25s ease;
}
.kodza-breadcrumbs__more button:hover:not(#_) {
background: var(--kz-crumbs-fg);
color: var(--kz-crumbs-soft-fg);
}
.kodza-breadcrumbs.is-collapsed .kodza-breadcrumbs__more:not(#_) {
display: inline-flex;
}
.kodza-breadcrumbs.is-collapsed .kodza-breadcrumbs__folded:not(#_) {
display: none;
}
@media (prefers-reduced-motion: reduce) {
.kodza-breadcrumbs__link:not(#_) {
transition-duration: 0.01ms !important;
}
}
@media (prefers-reduced-motion: reduce) {
.kodza-breadcrumbs__more button: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 — Breadcrumbs
// Folds the middle of the trail behind a "…" button when it would wrap onto a second line
// (or always, with --collapse), and unfolds it on click: the hidden crumbs slide in while the
// others glide to their new place. The first crumb and the last two stay. root.kodzaFold()
// folds it back (after a client-side navigation, or to replay): the crumbs leave in reverse
// order, then the trail closes up around the "…".
(() => {
const SELECTOR = '.kodza-breadcrumbs';
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 list = root.querySelector('.kodza-breadcrumbs__list');
const items = list ? [...list.children] : [];
root.classList.add('is-ready');
if (items.length < 4) return;
const folded = items.slice(1, -2);
folded.forEach((item, i) => {
item.classList.add('kodza-breadcrumbs__folded');
item.style.setProperty('--i', i);
});
const more = document.createElement('li');
const button = document.createElement('button');
more.className = 'kodza-breadcrumbs__item kodza-breadcrumbs__more';
button.type = 'button';
button.textContent = '…';
button.setAttribute('aria-label', `Show ${folded.length} hidden levels`);
more.append(button);
items[0].after(more);
let unfolded = false;
const fold = (on) => root.classList.toggle('is-collapsed', on);
// One line high, or wrapping?
const fits = () => {
fold(false);
return list.offsetHeight <= items[0].offsetHeight * 1.5;
};
const update = () => {
if (!unfolded) fold(root.classList.contains('kodza-breadcrumbs--collapse') || !fits());
};
// The crumbs that stay on screen glide to their new place (measured before and after)
const stay = items.filter((item) => !folded.includes(item));
const measure = () => new Map(stay.map((item) => [item, item.getBoundingClientRect()]));
const glide = (before) => {
if (reduceMotion) return;
// Screen pixels back to the trail's own (a parent may be scaled with a transform)
const k = root.getBoundingClientRect().width / root.offsetWidth || 1;
stay.forEach((item) => {
const from = before.get(item);
const to = item.getBoundingClientRect();
const dx = (from.left - to.left) / k;
const dy = (from.top - to.top) / k;
if (dx || dy) item.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }], { duration: 550, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' });
});
};
button.addEventListener('click', () => {
// From the keyboard, the focus moves on to the first unfolded crumb
const fromKeyboard = button.matches(':focus-visible');
const before = measure();
unfolded = true;
root.classList.add('is-unfolding');
fold(false);
glide(before);
if (fromKeyboard) folded[0].querySelector('a, [aria-current]')?.focus({ preventScroll: true });
}, { signal });
// Folding back: the hidden crumbs leave in reverse order, then the trail closes up and the
// "…" comes back where they were
let leaving = [];
root.kodzaFold = () => {
if (!unfolded || leaving.length) return;
if (!root.classList.contains('kodza-breadcrumbs--collapse') && fits()) return; // it fits now: nothing to fold
const close = () => {
leaving.forEach((animation) => animation.cancel());
leaving = [];
if (signal.aborted) return;
if (folded.some((item) => item.contains(document.activeElement))) button.focus({ preventScroll: true });
const before = measure();
unfolded = false;
root.classList.remove('is-unfolding');
update();
glide(before);
if (!reduceMotion) more.animate([{ opacity: 0, transform: 'translateX(-8px)' }, { opacity: 1, transform: 'none' }], { duration: 420, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' });
};
if (reduceMotion) return close();
leaving = folded.map((item, i) => item.animate(
[{ opacity: 1, transform: 'none' }, { opacity: 0, transform: 'translateX(-12px)' }],
{ duration: 320, delay: (folded.length - 1 - i) * 60, easing: 'cubic-bezier(0.65, 0, 0.35, 1)', fill: 'forwards' }
));
Promise.all(leaving.map((animation) => animation.finished)).then(close, () => {});
};
signal.addEventListener('abort', () => {
leaving.forEach((animation) => animation.cancel());
delete root.kodzaFold;
});
const observer = new ResizeObserver(update);
observer.observe(root);
signal.addEventListener('abort', () => observer.disconnect());
document.fonts?.ready.then(() => !signal.aborted && update());
update();
}
// 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 — Breadcrumbs, for React. From https://kodza.dev/components/breadcrumbs/
// 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: breadcrumbs.css and breadcrumbs.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 './breadcrumbs.css';
type Variant = "collapse";
export type BreadcrumbsProps = Omit<ComponentPropsWithoutRef<'nav'>, "children" | "variant" | "vars"> & {
/** Modifier class(es): .kodza-breadcrumbs--collapse */
variant?: Variant | Variant[];
/** Settings, as CSS variables on the root */
vars?: {
/** default: #0a0a0a */
"--kz-crumbs-fg"?: string | number;
/** default: rgba(10, 10, 10, 0.58) */
"--kz-crumbs-muted"?: string | number;
/** default: rgba(10, 10, 10, 0.28) */
"--kz-crumbs-separator"?: string | number;
/** default: rgba(10, 10, 10, 0.06) */
"--kz-crumbs-soft"?: string | number;
/** default: #ffffff */
"--kz-crumbs-soft-fg"?: string | number;
/** default: 15px */
"--kz-crumbs-size"?: string | number;
/** default: 10px */
"--kz-crumbs-gap"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-crumbs-ease"?: string | number;
};
className?: string;
};
export function Breadcrumbs({ variant, vars, className, style, ...rest }: BreadcrumbsProps) {
const ref = useRef<ComponentRef<'nav'>>(null);
const classes = ["kodza-breadcrumbs", ...([] as string[]).concat(variant ?? []).map((v) => `kodza-breadcrumbs--${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('./breadcrumbs.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="Breadcrumb" key={key} {...rest} className={classes} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<ol className="kodza-breadcrumbs__list">
<li className="kodza-breadcrumbs__item">
<a className="kodza-breadcrumbs__link" href="#">Home</a>
</li>
<li className="kodza-breadcrumbs__item">
<a className="kodza-breadcrumbs__link" href="#">Docs</a>
</li>
<li className="kodza-breadcrumbs__item">
<a className="kodza-breadcrumbs__link" href="#">Components</a>
</li>
<li className="kodza-breadcrumbs__item">
<span className="kodza-breadcrumbs__current" aria-current="page">Breadcrumbs</span>
</li>
</ol>
</nav>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.