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 pill of links where the highlight slides under the pointer and settles back on the current page. The ink version turns the text white right at its edges.
With your AI
Say it to your AI. It gets the exact code below, not its best guess, and knows where it goes.
Turn my header links into a Kodza Hover Nav.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<nav class="kodza-hover-nav" aria-label="Main">
<a class="kodza-hover-nav__link" href="#" aria-current="page">Home</a>
<a class="kodza-hover-nav__link" href="#">Components</a>
<a class="kodza-hover-nav__link" href="#">Pricing</a>
<a class="kodza-hover-nav__link" href="#">Changelog</a>
</nav>
<nav class="kodza-hover-nav kodza-hover-nav--ink" aria-label="Studio">
<a class="kodza-hover-nav__link" href="#" aria-current="page">Work</a>
<a class="kodza-hover-nav__link" href="#">Studio</a>
<a class="kodza-hover-nav__link" href="#">Journal</a>
<a class="kodza-hover-nav__link" href="#">Contact</a>
</nav>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Hover Nav
A pill of links where a soft highlight slides under the pointer (or the keyboard focus),
then settles back on the current page. With --ink, the highlight is ink and the text it
covers turns white exactly at its edges, even mid-slide. When the links outgrow the screen
(a phone), they scroll sideways inside the pill.
Everything is tuned with the --kz-hnav-* variables. */
.kodza-hover-nav {
--kz-hnav-bg: #ffffff;
--kz-hnav-fg: #0a0a0a;
--kz-hnav-muted: rgba(10, 10, 10, 0.58);
--kz-hnav-border: rgba(10, 10, 10, 0.1);
--kz-hnav-soft: rgba(10, 10, 10, 0.06);
--kz-hnav-ink: #0a0a0a;
--kz-hnav-on-ink: #ffffff;
--kz-hnav-duration: 0.45s;
--kz-hnav-ease: cubic-bezier(0.22, 1, 0.36, 1);
position: relative;
display: inline-flex;
box-sizing: border-box;
max-width: 100%;
padding: 4px;
border-radius: 999px;
background: var(--kz-hnav-bg);
box-shadow: inset 0 0 0 1px var(--kz-hnav-border);
isolation: isolate;
/* More links than room (a phone): the pill keeps its width and its links scroll sideways */
overflow-x: auto;
overscroll-behavior-x: contain;
scrollbar-width: none;
}
.kodza-hover-nav::-webkit-scrollbar {
display: none;
}
.kodza-hover-nav__link,
.kodza-hover-nav__ghost {
display: inline-flex;
align-items: center;
height: 36px;
padding: 0 16px;
border-radius: 999px;
font-size: 15px;
font-weight: 500;
white-space: nowrap;
}
.kodza-hover-nav__link {
position: relative;
z-index: 1;
color: var(--kz-hnav-muted);
text-decoration: none;
transition: color 0.3s ease;
}
.kodza-hover-nav__link:hover,
.kodza-hover-nav__link[aria-current="page"] {
color: var(--kz-hnav-fg);
}
.kodza-hover-nav__link:focus-visible {
outline: 2px solid var(--kz-hnav-fg);
outline-offset: -2px;
}
/* The highlight covers the whole nav (every link, scrolled out of sight or not) and is cut down
to one link: only clip-path moves */
.kodza-hover-nav__highlight,
.kodza-hover-nav__mirror {
position: absolute;
inset: 0 auto 0 0;
box-sizing: border-box;
width: var(--kz-hl-w, 100%);
border-radius: 999px;
clip-path: inset(4px var(--kz-hl-r, 50%) 4px var(--kz-hl-l, 50%) round 999px);
opacity: var(--kz-hl-on, 0);
pointer-events: none;
}
.kodza-hover-nav__highlight {
z-index: 0;
background: var(--kz-hnav-soft);
}
.kodza-hover-nav.is-ready .kodza-hover-nav__highlight,
.kodza-hover-nav.is-ready .kodza-hover-nav__mirror {
transition: clip-path var(--kz-hnav-duration) var(--kz-hnav-ease), opacity 0.25s ease;
}
/* --ink: an ink highlight, and a white copy of the links cut to the same shape */
.kodza-hover-nav--ink .kodza-hover-nav__highlight {
background: var(--kz-hnav-ink);
}
.kodza-hover-nav__mirror {
z-index: 2;
display: flex;
padding: 4px;
color: var(--kz-hnav-on-ink);
}
@media (prefers-reduced-motion: reduce) {
.kodza-hover-nav.is-ready .kodza-hover-nav__highlight,
.kodza-hover-nav.is-ready .kodza-hover-nav__mirror,
.kodza-hover-nav__link {
transition-duration: 0.01ms !important;
}
}
/* Theming example — on a dark background:
.dark-section .kodza-hover-nav {
--kz-hnav-bg: #161616;
--kz-hnav-fg: #f2f2f0;
--kz-hnav-muted: rgba(242, 242, 240, 0.6);
--kz-hnav-border: rgba(255, 255, 255, 0.12);
--kz-hnav-soft: rgba(255, 255, 255, 0.09);
--kz-hnav-ink: #f2f2f0;
--kz-hnav-on-ink: #0a0a0a;
}
*/
/* kodza-guard 472370212bfd — 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-hover-nav__link):not(#_) {
outline: revert;
outline-offset: revert;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-hover-nav:not(#_) {
border: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
outline-offset: revert;
appearance: 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-hover-nav__link:not(#_) {
border: revert;
margin: revert;
list-style: revert;
box-sizing: revert;
float: revert;
background-color: revert;
background-image: 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-hover-nav__ghost:not(#_) {
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-hover-nav:not(#_) {
position: relative;
display: inline-flex;
box-sizing: border-box;
padding: 4px;
border-radius: 999px;
background: var(--kz-hnav-bg);
box-shadow: inset 0 0 0 1px var(--kz-hnav-border);
}
.kodza-hover-nav__link:not(#_) {
display: inline-flex;
padding: 0 16px;
border-radius: 999px;
font-size: 15px;
font-weight: 500;
}
.kodza-hover-nav__link:not(#_) {
position: relative;
color: var(--kz-hnav-muted);
text-decoration: none;
transition: color 0.3s ease;
}
.kodza-hover-nav__link:hover:not(#_),
.kodza-hover-nav__link[aria-current="page"]:not(#_) {
color: var(--kz-hnav-fg);
}
.kodza-hover-nav__link:focus-visible:not(#_) {
outline: 2px solid var(--kz-hnav-fg);
outline-offset: -2px;
}
@media (prefers-reduced-motion: reduce) {
.kodza-hover-nav__link: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 — Hover Nav
// Adds the highlight (and, with --ink, the white copy of the links), moves it to the link
// under the pointer or the keyboard focus, and brings it back to the current page.
(() => {
const SELECTOR = '.kodza-hover-nav';
// One instance. Everything it starts is tied to `signal`, so kodza:destroy stops it all
function mount(root, signal) {
const links = [...root.querySelectorAll('.kodza-hover-nav__link')];
if (!links.length) return;
const highlight = document.createElement('span');
highlight.className = 'kodza-hover-nav__highlight';
highlight.setAttribute('aria-hidden', 'true');
root.prepend(highlight);
if (root.classList.contains('kodza-hover-nav--ink')) {
const mirror = document.createElement('span');
mirror.className = 'kodza-hover-nav__mirror';
mirror.setAttribute('aria-hidden', 'true');
mirror.append(...links.map((link) => {
const ghost = document.createElement('span');
ghost.className = 'kodza-hover-nav__ghost';
ghost.textContent = link.textContent;
return ghost;
}));
root.append(mirror);
}
const current = () => links.find((link) => link.getAttribute('aria-current') === 'page');
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const padding = () => parseFloat(getComputedStyle(root).paddingRight) || 0;
// When the links scroll, the highlight spans all of them (read from the last link, not
// scrollWidth, which a highlight still at its old width would inflate); otherwise the pill
const span = () => {
const last = links[links.length - 1];
const end = last.offsetLeft + last.offsetWidth + padding();
return end > root.clientWidth + 1 ? end : 0;
};
const move = (link) => {
if (!link) {
root.style.setProperty('--kz-hl-on', 0);
return;
}
const scrolling = span();
const width = scrolling || root.clientWidth;
if (scrolling) root.style.setProperty('--kz-hl-w', `${width}px`);
else root.style.removeProperty('--kz-hl-w');
root.style.setProperty('--kz-hl-l', `${link.offsetLeft}px`);
root.style.setProperty('--kz-hl-r', `${width - link.offsetLeft - link.offsetWidth}px`);
root.style.setProperty('--kz-hl-on', 1);
};
const rest = () => move(current());
// A link reached by the keyboard scrolls fully into the pill (the browser stops at half)
const reveal = (link) => {
const left = link.offsetLeft - padding();
const right = link.offsetLeft + link.offsetWidth + padding() - root.clientWidth;
const behavior = reduceMotion ? 'auto' : 'smooth';
if (root.scrollLeft > left) root.scrollTo({ left, behavior });
else if (root.scrollLeft < right) root.scrollTo({ left: right, behavior });
};
links.forEach((link) => {
link.addEventListener('pointerenter', () => move(link), { signal });
link.addEventListener('focus', () => {
move(link);
reveal(link);
}, { signal });
// On a one-page site (#anchors), the clicked link becomes the current one
link.addEventListener('click', () => {
if (!link.getAttribute('href')?.startsWith('#')) return;
links.forEach((other) => other.toggleAttribute('aria-current', other === link));
link.setAttribute('aria-current', 'page');
}, { signal });
});
root.addEventListener('pointerleave', rest, { signal });
root.addEventListener('focusout', (event) => {
if (!root.contains(event.relatedTarget)) rest();
}, { signal });
rest();
// Its first place is computed now, before is-ready turns the slide on: it must not slide in
void getComputedStyle(highlight).clipPath;
// Links scrolling in a narrow pill: the current page starts in view
const here = current();
if (here && root.scrollWidth > root.clientWidth) root.scrollLeft = here.offsetLeft + here.offsetWidth / 2 - root.clientWidth / 2;
const observer = new ResizeObserver(() => move(links.find((link) => link.matches(':hover, :focus')) || current()));
observer.observe(root);
signal.addEventListener('abort', () => observer.disconnect());
document.fonts?.ready.then(() => !signal.aborted && rest());
}
// 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 — Hover Nav, for React. From https://kodza.dev/components/hover-nav/
// 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: hover-nav.css and hover-nav.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 './hover-nav.css';
type Variant = "ink";
export type HoverNavProps = Omit<ComponentPropsWithoutRef<'nav'>, "children" | "variant" | "vars"> & {
/** Modifier class(es): .kodza-hover-nav--ink */
variant?: Variant | Variant[];
/** Settings, as CSS variables on the root */
vars?: {
/** default: #ffffff */
"--kz-hnav-bg"?: string | number;
/** default: #0a0a0a */
"--kz-hnav-fg"?: string | number;
/** default: rgba(10, 10, 10, 0.58) */
"--kz-hnav-muted"?: string | number;
/** default: rgba(10, 10, 10, 0.1) */
"--kz-hnav-border"?: string | number;
/** default: rgba(10, 10, 10, 0.06) */
"--kz-hnav-soft"?: string | number;
/** default: #0a0a0a */
"--kz-hnav-ink"?: string | number;
/** default: #ffffff */
"--kz-hnav-on-ink"?: string | number;
/** default: 0.45s */
"--kz-hnav-duration"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-hnav-ease"?: string | number;
};
className?: string;
};
export function HoverNav({ variant, vars, className, style, ...rest }: HoverNavProps) {
const ref = useRef<ComponentRef<'nav'>>(null);
const classes = ["kodza-hover-nav", ...([] as string[]).concat(variant ?? []).map((v) => `kodza-hover-nav--${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('./hover-nav.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}>
<a className="kodza-hover-nav__link" href="#" aria-current="page">Home</a>
{' '}
<a className="kodza-hover-nav__link" href="#">Components</a>
{' '}
<a className="kodza-hover-nav__link" href="#">Pricing</a>
{' '}
<a className="kodza-hover-nav__link" href="#">Changelog</a>
</nav>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.