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 codeTabs with a pill (or a line) that slides to the chosen one, a panel that comes in from its side, and the full keyboard pattern.
With your AI
Say it to your AI. It gets the exact code below, not its best guess, and knows where it goes.
Split my product details into Kodza Tabs.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<div class="kodza-tabs">
<div class="kodza-tabs__list" role="tablist" aria-label="Component details">
<span class="kodza-tabs__indicator" aria-hidden="true"></span>
<button class="kodza-tabs__tab" type="button" role="tab" aria-selected="true">Preview</button>
<button class="kodza-tabs__tab" type="button" role="tab" aria-selected="false">Code</button>
<button class="kodza-tabs__tab" type="button" role="tab" aria-selected="false">Changelog</button>
</div>
<div class="kodza-tabs__panels">
<div class="kodza-tabs__panel" role="tabpanel">
<p>It moves, it fades, and it sits still for people who asked for less motion. Your designer will nod slowly.</p>
</div>
<div class="kodza-tabs__panel" role="tabpanel" hidden>
<p>One HTML block, one CSS file, a few lines of JS. No build step was harmed in the making of this tab.</p>
</div>
<div class="kodza-tabs__panel" role="tabpanel" hidden>
<p>v1.2 fixed the bug we shipped in v1.1. v1.3 will fix the one we shipped in v1.2.</p>
</div>
</div>
</div>
<div class="kodza-tabs kodza-tabs--line">
<div class="kodza-tabs__list" role="tablist" aria-label="Notifications">
<span class="kodza-tabs__indicator" aria-hidden="true"></span>
<button class="kodza-tabs__tab" type="button" role="tab" aria-selected="true">All</button>
<button class="kodza-tabs__tab" type="button" role="tab" aria-selected="false">Unread</button>
<button class="kodza-tabs__tab" type="button" role="tab" aria-selected="false">Mentions</button>
</div>
<div class="kodza-tabs__panels">
<div class="kodza-tabs__panel" role="tabpanel">
<p>142 notifications. 140 of them are a bot telling you the build passed.</p>
</div>
<div class="kodza-tabs__panel" role="tabpanel" hidden>
<p>Three unread. One is from your mum: she found the website.</p>
</div>
<div class="kodza-tabs__panel" role="tabpanel" hidden>
<p>Nobody mentioned you. Enjoy the quiet while it lasts.</p>
</div>
</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 — Tabs
Tabs with a pill that slides to the chosen one (or a line, with --line), and a panel that
comes in from the side of the tab you picked. Arrow keys, Home and End move between tabs.
More tabs than room (a phone): the row scrolls sideways and follows the chosen tab.
Everything is tuned with the --kz-tabs-* variables. */
.kodza-tabs {
--kz-tabs-bg: #ffffff;
--kz-tabs-fg: #0a0a0a;
--kz-tabs-muted: rgba(10, 10, 10, 0.58);
--kz-tabs-border: rgba(10, 10, 10, 0.1);
--kz-tabs-ink: #0a0a0a;
--kz-tabs-on-ink: #ffffff;
--kz-tabs-width: 520px;
--kz-tabs-duration: 0.5s;
--kz-tabs-ease: cubic-bezier(0.22, 1, 0.36, 1);
width: 100%;
max-width: var(--kz-tabs-width);
color: var(--kz-tabs-fg);
}
.kodza-tabs__list {
position: relative;
display: inline-flex;
box-sizing: border-box;
max-width: 100%;
padding: 4px;
border-radius: 999px;
background: var(--kz-tabs-bg);
box-shadow: inset 0 0 0 1px var(--kz-tabs-border);
}
/* More tabs than room: the row scrolls sideways (the script adds is-scroll) */
.kodza-tabs__list.is-scroll {
overflow-x: auto;
overscroll-behavior-x: contain;
scrollbar-width: none;
}
.kodza-tabs__list.is-scroll::-webkit-scrollbar {
display: none;
}
/* The pill covers the whole row (scrolled part included) and is cut down to the chosen tab:
only clip-path moves */
.kodza-tabs__indicator {
position: absolute;
inset: 0 auto 0 0;
width: var(--kz-tabs-track, 100%);
border-radius: 999px;
background: var(--kz-tabs-ink);
clip-path: inset(4px var(--kz-ind-r, 100%) 4px var(--kz-ind-l, 0) round 999px);
}
.kodza-tabs.is-live .kodza-tabs__indicator {
transition: clip-path var(--kz-tabs-duration) var(--kz-tabs-ease);
}
.kodza-tabs__tab {
position: relative;
height: 36px;
padding: 0 18px;
border: 0;
border-radius: 999px;
background: none;
color: var(--kz-tabs-muted);
font: inherit;
font-size: 15px;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
transition: color 0.3s ease;
}
.kodza-tabs__tab:hover {
color: var(--kz-tabs-fg);
}
.kodza-tabs__tab[aria-selected="true"] {
color: var(--kz-tabs-fg);
}
/* A white copy of the labels, cut to the pill: the text turns white exactly at its edges */
.kodza-tabs__mirror {
position: absolute;
inset: 0 auto 0 0;
box-sizing: border-box;
width: var(--kz-tabs-track, 100%);
display: flex;
padding: 4px;
border-radius: 999px;
color: var(--kz-tabs-on-ink);
clip-path: inset(4px var(--kz-ind-r, 100%) 4px var(--kz-ind-l, 0) round 999px);
pointer-events: none;
}
.kodza-tabs.is-live .kodza-tabs__mirror {
transition: clip-path var(--kz-tabs-duration) var(--kz-tabs-ease);
}
.kodza-tabs__ghost {
display: inline-flex;
align-items: center;
height: 36px;
padding: 0 18px;
font-size: 15px;
font-weight: 500;
white-space: nowrap;
}
.kodza-tabs__tab:focus-visible {
outline: 2px solid var(--kz-tabs-fg);
outline-offset: 2px;
}
/* --line: no pill, a line under the chosen tab */
.kodza-tabs--line .kodza-tabs__list {
display: flex;
gap: 4px;
padding: 0;
border-radius: 0;
background: none;
box-shadow: inset 0 -1px 0 var(--kz-tabs-border);
}
.kodza-tabs--line .kodza-tabs__indicator {
top: auto;
height: 2px;
border-radius: 0;
clip-path: inset(0 var(--kz-ind-r, 100%) 0 var(--kz-ind-l, 0));
}
.kodza-tabs--line .kodza-tabs__tab {
height: 40px;
padding: 0 12px;
border-radius: 6px 6px 0 0;
}
.kodza-tabs--line .kodza-tabs__tab[aria-selected="true"] {
color: var(--kz-tabs-fg);
}
/* The panels are stacked: the block keeps the height of the tallest one, so nothing under it
moves. The old panel leaves on one side while the new one comes in from the side of the
tab you picked. The column never grows past the tabs: a long line inside a panel (a code
block) scrolls on its own instead of widening the page. */
.kodza-tabs__panels {
display: grid;
grid-template-columns: minmax(0, 1fr);
padding-top: 20px;
}
.kodza-tabs__panel {
grid-area: 1 / 1;
font-size: 15px;
line-height: 1.55;
color: var(--kz-tabs-muted);
text-wrap: pretty;
}
.kodza-tabs__panel p {
margin: 0;
}
.kodza-tabs.is-ready .kodza-tabs__panel[hidden] {
display: block;
}
.kodza-tabs.is-ready .kodza-tabs__panel {
visibility: hidden;
opacity: 0;
/* A waiting panel sits a little to the side, never further than the page leaves there (a 10px
phone gutter): it never widens the page */
transform: translateX(calc(var(--kz-tabs-leave, -1) * min(14px, (100vw - 100%) / 2)));
transition:
opacity 0.2s ease,
transform 0.3s ease-in,
visibility 0s linear 0.3s;
}
.kodza-tabs.is-ready .kodza-tabs__panel.is-active {
visibility: visible;
opacity: 1;
transform: none;
transition:
opacity 0.4s ease 0.14s,
transform var(--kz-tabs-duration) var(--kz-tabs-ease) 0.14s,
visibility 0s;
}
.kodza-tabs.is-ready .kodza-tabs__panel.is-from {
transform: translateX(calc(var(--kz-tabs-leave, -1) * -1 * min(22px, (100vw - 100%) / 2)));
transition: none;
}
.kodza-tabs__panel:focus-visible {
border-radius: 6px;
outline: 2px solid var(--kz-tabs-fg);
outline-offset: 4px;
}
@media (prefers-reduced-motion: reduce) {
.kodza-tabs.is-live .kodza-tabs__indicator,
.kodza-tabs.is-live .kodza-tabs__mirror,
.kodza-tabs__tab,
.kodza-tabs.is-ready .kodza-tabs__panel,
.kodza-tabs.is-ready .kodza-tabs__panel.is-active {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
}
}
/* Theming example — on a dark background:
.dark-section .kodza-tabs {
--kz-tabs-bg: #161616;
--kz-tabs-fg: #f2f2f0;
--kz-tabs-muted: rgba(242, 242, 240, 0.62);
--kz-tabs-border: rgba(255, 255, 255, 0.12);
--kz-tabs-ink: #f2f2f0;
--kz-tabs-on-ink: #0a0a0a;
}
*/
/* kodza-guard e829b4517a13 — 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-tabs__tab):not(#_),
:where(.kodza-tabs__panel):not(#_) {
outline: revert;
outline-offset: revert;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-tabs:not(#_),
.kodza-tabs__indicator:not(#_),
.kodza-tabs__ghost:not(#_),
.kodza-tabs__panels:not(#_) {
box-sizing: revert;
}
.kodza-tabs__tab:not(#_) {
margin: revert;
text-decoration: 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;
min-height: revert;
}
.kodza-tabs__panel:not(#_) {
box-sizing: revert;
box-shadow: revert;
}
.kodza-tabs__panel p:where(:not([class*="kodza-"]):not(.kodza-tabs__panel [class*="kodza-"] *)):not(#_) {
border: 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-tabs__tab:not(#_):where(:not([hidden])),
.kodza-tabs__panel p:where(:not([class*="kodza-"]):not(.kodza-tabs__panel [class*="kodza-"] *)):not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-tabs__tab:not(#_):where([hidden]),
.kodza-tabs__panel p:where(:not([class*="kodza-"]):not(.kodza-tabs__panel [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-tabs:not(#_) {
width: min(100%, var(--kz-tabs-width));
}
.kodza-tabs__tab:not(#_) {
position: relative;
height: 36px;
padding: 0 18px;
border: 0;
border-radius: 999px;
background: none;
color: var(--kz-tabs-muted);
font: inherit;
font-size: 15px;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
transition: color 0.3s ease;
}
.kodza-tabs__tab:hover:not(#_) {
color: var(--kz-tabs-fg);
}
.kodza-tabs__tab[aria-selected="true"]:not(#_) {
color: var(--kz-tabs-fg);
}
.kodza-tabs__tab:focus-visible:not(#_) {
outline: 2px solid var(--kz-tabs-fg);
outline-offset: 2px;
}
.kodza-tabs--line .kodza-tabs__tab:not(#_) {
height: 40px;
padding: 0 12px;
border-radius: 6px 6px 0 0;
}
.kodza-tabs--line .kodza-tabs__tab[aria-selected="true"]:not(#_) {
color: var(--kz-tabs-fg);
}
.kodza-tabs__panel p:not(#_) {
margin: 0;
}
.kodza-tabs__panel:focus-visible:not(#_) {
outline: 2px solid var(--kz-tabs-fg);
outline-offset: 4px;
}
@media (prefers-reduced-motion: reduce) {
.kodza-tabs__tab: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 — Tabs
// Wires the tabs to their panels (in order), slides the indicator to the chosen tab and
// handles the keyboard: arrows, Home and End move and select, as the ARIA pattern asks.
// When the tabs outgrow the row, it scrolls sideways and brings the chosen tab into view.
(() => {
const SELECTOR = '.kodza-tabs';
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let uid = 0;
// One instance. Everything it starts is tied to `signal`, so kodza:destroy stops it all
function mount(root, signal) {
const list = root.querySelector('.kodza-tabs__list');
const tabs = [...root.querySelectorAll('.kodza-tabs__tab')];
const panels = [...root.querySelectorAll('.kodza-tabs__panel')];
if (!list || !tabs.length) return;
// ids are made here, so the copied HTML stays free of them
const prefix = `kodza-tabs-${++uid}`;
tabs.forEach((tab, i) => {
const panel = panels[i];
if (!panel) return;
tab.id = `${prefix}-tab-${i}`;
panel.id = `${prefix}-panel-${i}`;
tab.setAttribute('aria-controls', panel.id);
panel.setAttribute('aria-labelledby', tab.id);
panel.tabIndex = 0;
});
// The pill version gets a white copy of the labels, cut to the same shape as the pill
if (!root.classList.contains('kodza-tabs--line')) {
const mirror = document.createElement('span');
mirror.className = 'kodza-tabs__mirror';
mirror.setAttribute('aria-hidden', 'true');
mirror.append(...tabs.map((tab) => {
const ghost = document.createElement('span');
ghost.className = 'kodza-tabs__ghost';
ghost.textContent = tab.textContent;
return ghost;
}));
list.append(mirror);
}
// The pill and the white copy span the whole row, scrolled part included
const place = (tab, scroll = false) => {
list.style.removeProperty('--kz-tabs-track');
const track = list.scrollWidth;
list.classList.toggle('is-scroll', track > list.clientWidth + 1);
list.style.setProperty('--kz-tabs-track', `${track}px`);
list.style.setProperty('--kz-ind-l', `${tab.offsetLeft}px`);
list.style.setProperty('--kz-ind-r', `${track - tab.offsetLeft - tab.offsetWidth}px`);
if (scroll && list.classList.contains('is-scroll')) {
const left = tab.offsetLeft - (list.clientWidth - tab.offsetWidth) / 2;
list.scrollTo({ left, behavior: reduceMotion ? 'auto' : 'smooth' });
}
};
let current = Math.max(0, tabs.findIndex((tab) => tab.getAttribute('aria-selected') === 'true'));
const select = (index, focus = false, first = false) => {
const next = (index + tabs.length) % tabs.length;
tabs.forEach((tab, i) => {
const on = i === next;
tab.setAttribute('aria-selected', String(on));
tab.tabIndex = on ? 0 : -1;
});
// The old panel leaves on the side you come from, the new one enters from the other
root.style.setProperty('--kz-tabs-leave', next > current ? -1 : 1);
panels.forEach((panel, i) => {
if (i !== next) panel.classList.remove('is-active');
});
const panel = panels[next];
if (panel && (next !== current || first)) {
if (!first) {
panel.classList.add('is-from');
void panel.offsetWidth;
panel.classList.remove('is-from');
}
panel.classList.add('is-active');
}
current = next;
place(tabs[next], !first);
if (focus) tabs[next].focus();
};
tabs.forEach((tab, i) => tab.addEventListener('click', () => select(i), { signal }));
list.addEventListener('keydown', (event) => {
const keys = { ArrowRight: current + 1, ArrowLeft: current - 1, Home: 0, End: tabs.length - 1 };
if (!(event.key in keys)) return;
event.preventDefault();
select(keys[event.key], true);
}, { signal });
// From here, the panels are shown by the script (stacked), not by the hidden attribute
panels.forEach((panel) => panel.removeAttribute('hidden'));
root.classList.add('is-ready');
select(current, false, true);
const observer = new ResizeObserver(() => place(tabs[current]));
observer.observe(list);
signal.addEventListener('abort', () => observer.disconnect());
document.fonts?.ready.then(() => place(tabs[current]));
// The first placement is instant; only later moves slide
requestAnimationFrame(() => !signal.aborted && root.classList.add('is-live'));
}
// 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 — Tabs, for React. From https://kodza.dev/components/tabs-component/
// 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: tabs-component.css and tabs-component.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 './tabs-component.css';
type Variant = "line";
export type TabsProps = Omit<ComponentPropsWithoutRef<'div'>, "children" | "variant" | "vars"> & {
/** Modifier class(es): .kodza-tabs--line */
variant?: Variant | Variant[];
/** Settings, as CSS variables on the root */
vars?: {
/** default: #ffffff */
"--kz-tabs-bg"?: string | number;
/** default: #0a0a0a */
"--kz-tabs-fg"?: string | number;
/** default: rgba(10, 10, 10, 0.58) */
"--kz-tabs-muted"?: string | number;
/** default: rgba(10, 10, 10, 0.1) */
"--kz-tabs-border"?: string | number;
/** default: #0a0a0a */
"--kz-tabs-ink"?: string | number;
/** default: #ffffff */
"--kz-tabs-on-ink"?: string | number;
/** default: 520px */
"--kz-tabs-width"?: string | number;
/** default: 0.5s */
"--kz-tabs-duration"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-tabs-ease"?: string | number;
};
className?: string;
};
export function Tabs({ variant, vars, className, style, ...rest }: TabsProps) {
const ref = useRef<ComponentRef<'div'>>(null);
const classes = ["kodza-tabs", ...([] as string[]).concat(variant ?? []).map((v) => `kodza-tabs--${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('./tabs-component.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 (
<div key={key} {...rest} className={classes} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<div className="kodza-tabs__list" role="tablist" aria-label="Component details">
<span className="kodza-tabs__indicator" aria-hidden="true" />
{' '}
<button className="kodza-tabs__tab" type="button" role="tab" aria-selected="true">Preview</button>
{' '}
<button className="kodza-tabs__tab" type="button" role="tab" aria-selected="false">Code</button>
{' '}
<button className="kodza-tabs__tab" type="button" role="tab" aria-selected="false">Changelog</button>
</div>
<div className="kodza-tabs__panels">
<div className="kodza-tabs__panel" role="tabpanel">
<p>It moves, it fades, and it sits still for people who asked for less motion. Your designer will nod slowly.</p>
</div>
<div className="kodza-tabs__panel" role="tabpanel" hidden>
<p>One HTML block, one CSS file, a few lines of JS. No build step was harmed in the making of this tab.</p>
</div>
<div className="kodza-tabs__panel" role="tabpanel" hidden>
<p>v1.2 fixed the bug we shipped in v1.1. v1.3 will fix the one we shipped in v1.2.</p>
</div>
</div>
</div>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.