Header Announcement
A site header with a strip of news on top: messages roll and pause on hover or focus; the cross folds the strip, the header slides up without a jump, and it stays closed. Below 900 px, a shutter menu.
View codeA site header placed on your grid: brand, links and actions each take the columns you name, one link per column if you like. Still by default; a gliding line, hide on scroll or tighten on scroll as options.
With your AI
Say it to your AI. It gets the exact code below, not its best guess, and knows where it goes.
Add the Kodza Header Grid to my page.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<header class="kodza-header-grid" data-mobile="menu">
<div class="kodza-header-grid__row">
<a class="kodza-header-grid__brand" href="#" aria-label="Kiln, home"><svg viewBox="0 0 20 20" width="20" height="20" aria-hidden="true"><path d="M1 1h8v8H1zM11 1h8v8h-8zM1 11h8v8H1z"/><circle cx="15" cy="15" r="4"/></svg><span>kiln</span></a>
<nav class="kodza-header-grid__nav" aria-label="Main">
<ul class="kodza-header-grid__links">
<li><a class="kodza-header-grid__link" href="#" aria-current="page">Work</a></li>
<li><a class="kodza-header-grid__link" href="#">Services</a></li>
<li><a class="kodza-header-grid__link" href="#">Studio</a></li>
<li><a class="kodza-header-grid__link" href="#">Journal</a></li>
<li><a class="kodza-header-grid__link" href="#">Contact</a></li>
</ul>
</nav>
<div class="kodza-header-grid__actions">
<a class="kodza-header-grid__button" href="#">Start a project</a>
<button class="kodza-header-grid__toggle" type="button" aria-expanded="false"><span class="kodza-header-grid__lines" aria-hidden="true"></span><span class="kodza-header-grid__label"><span class="kodza-header-grid__say">Menu</span><span class="kodza-header-grid__say" aria-hidden="true">Close</span></span></button>
<div class="kodza-header-grid__panel" popover aria-label="Menu">
<nav class="kodza-header-grid__list" aria-label="Main"></nav>
<div class="kodza-header-grid__foot"></div>
</div>
</div>
</div>
</header>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Header Grid
The header that sits on your site's grid. Three zones, the brand, the links and the actions,
each on the columns you give it, so its edges fall where the page's do.
- Where things go: --kz-header-grid-columns, then one column per zone (-brand-column,
-nav-column, -actions-column). Add kodza-header-grid--cells and each link and each note takes
a column of its own, on its own rule.
- Still by default. Three motions, one modifier each, all at the pace of --kz-motion-scale:
--line (a line glides under the links), --hide (gone while you read down, back when you
scroll up), --tighten (the bar loses a little height once you scroll). --hide and --tighten
stick it to the top.
- Phones, below --kz-header-grid-breakpoint: data-mobile="menu" (a shutter behind a Menu
button), "wrap" (the links on a second line) or "scroll" (a second line that scrolls
sideways). Without data-mobile the row stays a row: give it fewer columns in your own media
query; only the notes step aside.
Everything is tuned with the --kz-header-grid-* variables (--kz-header-grid-menu-* for the
shutter). A site grid of six columns, one link per column, as on a Swiss studio site:
--kz-header-grid-columns: repeat(6, minmax(0, 1fr)); --kz-header-grid-nav-column: 2 / 5;
--kz-header-grid-actions-column: 5 / 7; --kz-header-grid-rule: 1px; --kz-header-grid-mark: 3px;
--kz-header-grid-align: end; with the --cells modifier. */
.kodza-header-grid {
/* Colours */
--kz-header-grid-bg: transparent; /* give it your page colour once it sticks */
--kz-header-grid-fg: #0a0a0a; /* the brand, the page you are on, the link you point at */
--kz-header-grid-muted: rgba(10, 10, 10, 0.62); /* the notes */
--kz-header-grid-link: var(--kz-header-grid-muted); /* the other links */
--kz-header-grid-rule-color: var(--kz-header-grid-fg); /* the rules under the cells */
--kz-header-grid-ink: #0a0a0a; /* the buttons */
--kz-header-grid-on-ink: #ffffff;
--kz-header-grid-soft: rgba(10, 10, 10, 0.06); /* the Menu button */
/* Where things go: the row is a grid, each zone takes the columns you name */
--kz-header-grid-columns: minmax(0, 1fr) auto minmax(0, 1fr); /* an auto column centres what it holds */
--kz-header-grid-gap: 16px; /* between the columns */
--kz-header-grid-brand-column: 1;
--kz-header-grid-nav-column: 2;
--kz-header-grid-actions-column: 3;
--kz-header-grid-align: center; /* end sets every zone on the bottom edge */
--kz-header-grid-width: 1200px; /* the row, centred; none = the full width */
--kz-header-grid-pad: clamp(16px, 4cqw, 24px); /* on both sides */
--kz-header-grid-pad-block: 0px; /* above and below (two values: above, then below) */
--kz-header-grid-height: 72px; /* at least */
--kz-header-grid-brand-size: 18px;
/* The page you are on */
--kz-header-grid-mark: 1.5px; /* the line under it, and under the link you point at */
--kz-header-grid-rule: 0px; /* with --cells, the rule under every cell; 0 = none */
/* Where it stays */
--kz-header-grid-position: static; /* sticky keeps it at the top */
--kz-header-grid-top: var(--wp-admin--admin-bar--height, 0px); /* where it sticks: under the WordPress admin bar */
--kz-header-grid-breakpoint: 900px; /* below it, data-mobile takes over */
/* Motion */
--kz-header-grid-duration: 0.42s; /* --line: the line gliding */
--kz-header-grid-lag: 0.08s; /* how long its back edge waits */
--kz-header-grid-ease: cubic-bezier(0.22, 1, 0.36, 1);
--kz-header-grid-hide-after: 600px; /* --hide: how far you read before it goes */
--kz-header-grid-tighten: 16px; /* --tighten: the height the bar loses */
--kz-header-grid-tighten-after: 40px; /* how far you scroll before it tightens */
--kz-header-grid-shrink: 0.86; /* the brand's size once tight; 1 = it stays */
/* The shutter, with data-mobile="menu" */
--kz-header-grid-menu-bg: #0a0a0a;
--kz-header-grid-menu-fg: #f2f2f0;
--kz-header-grid-menu-muted: rgba(242, 242, 240, 0.62);
--kz-header-grid-menu-line: rgba(242, 242, 240, 0.16);
--kz-header-grid-menu-side: 24px;
--kz-header-grid-menu-size: calc(clamp(34px, min(10cqw, calc((100cqh - 240px) / 6)), 96px) * var(--kz-font-display-scale, 1));
--kz-header-grid-menu-duration: 0.8s;
--kz-header-grid-menu-ease: cubic-bezier(0.76, 0, 0.24, 1);
position: var(--kz-header-grid-position);
top: var(--kz-header-grid-top);
z-index: 60;
display: block;
box-sizing: border-box;
width: 100%;
margin: 0;
padding: 0;
background: var(--kz-header-grid-bg);
color: var(--kz-header-grid-fg);
font-size: 15px;
line-height: 1.4;
text-align: left;
container-type: inline-size;
}
/* The row: the grid every zone is placed on */
.kodza-header-grid__row {
display: grid;
grid-template-columns: var(--kz-header-grid-columns);
align-items: var(--kz-header-grid-align);
column-gap: var(--kz-header-grid-gap);
box-sizing: border-box;
max-width: var(--kz-header-grid-width);
min-height: var(--kz-header-grid-height);
margin: 0 auto;
padding-block: var(--kz-header-grid-pad-block);
padding-inline: var(--kz-header-grid-pad);
}
/* ---------- The three zones ---------- */
.kodza-header-grid__brand {
position: relative;
display: inline-flex;
grid-column: var(--kz-header-grid-brand-column);
grid-row: 1;
align-items: center;
justify-self: start;
gap: 8px;
box-sizing: border-box;
min-height: 40px;
margin: 0;
border-radius: calc(6px * var(--kz-radius-scale, 1));
color: var(--kz-header-grid-fg);
font-family: var(--kz-font-display, inherit);
font-size: calc(var(--kz-header-grid-brand-size) * var(--kz-font-display-scale, 1));
font-weight: var(--kz-font-display-weight, 600);
line-height: 1;
letter-spacing: var(--kz-font-display-tracking, -0.04em);
text-transform: var(--kz-font-display-case, inherit);
text-decoration: none;
white-space: nowrap;
transform-origin: var(--kz-header-grid-origin, left center);
}
.kodza-header-grid__brand svg {
display: block;
flex: none;
fill: currentColor;
}
/* The links keep 14 px of room on each side for the pointer; the nav gives it back, so the first
and the last word sit on the edges of their columns, like the rest of the page */
.kodza-header-grid__nav {
position: relative;
display: block;
grid-column: var(--kz-header-grid-nav-column);
grid-row: 1;
justify-self: start;
min-width: 0;
margin: 0 -14px;
padding: 0;
}
.kodza-header-grid__links {
display: flex;
align-items: center;
gap: 2px;
margin: 0;
padding: 0;
list-style: none;
}
.kodza-header-grid__links > li {
display: block;
margin: 0;
padding: 0;
list-style: none;
}
.kodza-header-grid__link {
position: relative;
display: inline-flex;
align-items: center;
box-sizing: border-box;
height: 40px;
margin: 0;
padding: 0 14px;
border-radius: calc(8px * var(--kz-radius-scale, 1));
color: var(--kz-header-grid-link);
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 15px);
font-weight: var(--kz-font-label-weight, 500);
line-height: 1;
letter-spacing: var(--kz-font-label-tracking, -0.01em);
text-transform: var(--kz-font-label-case, inherit);
text-decoration: none;
white-space: nowrap;
transition: color calc(0.2s * var(--kz-motion-scale, 1));
}
.kodza-header-grid__link:focus-visible,
.kodza-header-grid__link[aria-current="page"] {
color: var(--kz-header-grid-fg);
}
/* The mark under the page you are on (and the link you point at): it fades in, it never moves */
.kodza-header-grid__link::after,
.kodza-header-grid__brand::after {
content: "";
position: absolute;
left: 14px;
right: 14px;
bottom: 8px;
height: var(--kz-header-grid-mark);
border-radius: calc(1px * var(--kz-radius-scale, 1));
background: var(--kz-header-grid-fg);
opacity: 0;
pointer-events: none;
transition: opacity calc(0.2s * var(--kz-motion-scale, 1));
}
/* The brand only marks the page in --cells, where it is a cell like the links */
.kodza-header-grid__brand::after {
content: none;
}
.kodza-header-grid__link[aria-current="page"]::after,
.kodza-header-grid__link:focus-visible::after {
opacity: 1;
}
@media (hover: hover) {
.kodza-header-grid__link:hover {
color: var(--kz-header-grid-fg);
}
.kodza-header-grid__link:hover::after {
opacity: 1;
}
}
.kodza-header-grid__brand:focus-visible,
.kodza-header-grid__link:focus-visible {
outline: 2px solid var(--kz-header-grid-fg);
outline-offset: 2px;
}
.kodza-header-grid__actions {
display: flex;
grid-column: var(--kz-header-grid-actions-column);
grid-row: 1;
align-items: center;
justify-self: end;
gap: 8px;
min-width: 0;
margin: 0;
}
/* A short text in the bar: an address, an issue number, the next opening */
.kodza-header-grid__note {
position: relative;
margin: 0;
color: var(--kz-header-grid-muted);
font-family: var(--kz-font-label, var(--kz-font-mono, "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace));
font-size: var(--kz-font-label-size, 12px);
font-weight: var(--kz-font-label-weight, inherit);
line-height: var(--kz-font-label-leading, 1.4);
letter-spacing: var(--kz-font-label-tracking, inherit);
text-transform: var(--kz-font-label-case, inherit);
white-space: nowrap;
}
.kodza-header-grid__button {
display: inline-flex;
align-items: center;
box-sizing: border-box;
height: 40px;
margin: 0;
padding: 0 18px;
border-radius: var(--kz-radius-pill, 999px);
background: var(--kz-header-grid-ink);
color: var(--kz-header-grid-on-ink);
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 15px);
font-weight: var(--kz-font-label-weight, 500);
line-height: 1;
letter-spacing: var(--kz-font-label-tracking, -0.01em);
text-decoration: none;
text-transform: var(--kz-font-label-case, inherit);
white-space: nowrap;
transition: background-color calc(0.2s * var(--kz-motion-scale, 1));
}
@media (hover: hover) {
.kodza-header-grid__button:hover {
background: color-mix(in srgb, var(--kz-header-grid-ink) 84%, transparent);
}
}
.kodza-header-grid__button:focus-visible {
outline: 2px solid var(--kz-header-grid-ink);
outline-offset: 3px;
}
/* ---------- --cells: one column per link and per note, each on its rule ---------- */
/* Subgrids keep the row's own gap (normal), so every cell lands on a column of the site */
.kodza-header-grid--cells .kodza-header-grid__nav,
.kodza-header-grid--cells .kodza-header-grid__actions {
display: grid;
grid-template-columns: subgrid;
align-items: var(--kz-header-grid-align);
align-self: stretch;
justify-self: stretch;
gap: normal;
}
.kodza-header-grid--cells .kodza-header-grid__nav {
margin: 0;
}
.kodza-header-grid--cells .kodza-header-grid__links {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
align-items: var(--kz-header-grid-align);
gap: normal;
}
/* Every cell sits on its rule (drawn inside, so nothing moves when it thickens) */
.kodza-header-grid--cells .kodza-header-grid__brand,
.kodza-header-grid--cells .kodza-header-grid__link,
.kodza-header-grid--cells .kodza-header-grid__note {
display: flex;
justify-self: stretch;
height: auto;
min-height: 0;
padding: 0 0 10px;
border-radius: 0;
box-shadow: inset 0 calc(-1 * var(--kz-header-grid-rule)) 0 var(--kz-header-grid-rule-color);
}
.kodza-header-grid--cells .kodza-header-grid__brand {
align-items: flex-end;
}
.kodza-header-grid--cells .kodza-header-grid__link {
align-items: flex-end;
line-height: var(--kz-font-label-leading, 1.4);
white-space: normal;
}
.kodza-header-grid--cells .kodza-header-grid__note {
display: block;
}
.kodza-header-grid--cells .kodza-header-grid__link::after,
.kodza-header-grid--cells .kodza-header-grid__brand::after {
content: "";
left: 0;
right: 0;
bottom: 0;
border-radius: 0;
}
.kodza-header-grid--cells .kodza-header-grid__brand[aria-current="page"]::after,
.kodza-header-grid--cells .kodza-header-grid__brand:focus-visible::after {
opacity: 1;
}
@media (hover: hover) {
.kodza-header-grid--cells .kodza-header-grid__brand:hover::after {
opacity: 1;
}
}
/* ---------- --line: the mark glides from link to link ---------- */
/* The script adds the line and sets where its two edges go. Each edge has its own transition, so
the one in front leaves first and the other catches up */
.kodza-header-grid__line {
position: absolute;
bottom: 8px;
left: 0;
right: 0;
height: var(--kz-header-grid-mark);
clip-path: inset(0 0 0 var(--kz-header-grid-line-left, 0px));
pointer-events: none;
}
.kodza-header-grid__line::after {
content: "";
position: absolute;
inset: 0;
border-radius: calc(1px * var(--kz-radius-scale, 1));
background: var(--kz-header-grid-fg);
clip-path: inset(0 var(--kz-header-grid-line-right, 100%) 0 0);
}
.kodza-header-grid--cells .kodza-header-grid__line {
bottom: 0;
}
.kodza-header-grid--cells .kodza-header-grid__line::after {
border-radius: 0;
}
.kodza-header-grid.is-ready .kodza-header-grid__line,
.kodza-header-grid.is-ready .kodza-header-grid__line::after {
transition: clip-path calc(var(--kz-header-grid-duration) * var(--kz-motion-scale, 1)) var(--kz-header-grid-ease);
}
.kodza-header-grid.is-ready .kodza-header-grid__line.is-rightward,
.kodza-header-grid.is-ready .kodza-header-grid__line.is-leftward::after {
transition-delay: calc(var(--kz-header-grid-lag) * var(--kz-motion-scale, 1));
}
.kodza-header-grid.is-ready .kodza-header-grid__line.is-instant,
.kodza-header-grid.is-ready .kodza-header-grid__line.is-instant::after {
transition: none;
}
/* The line does the marking: the links' own marks step aside (they come back below the
breakpoint, where the line has no room to glide) */
.kodza-header-grid--line.is-ready:not(.is-compact) .kodza-header-grid__link::after {
visibility: hidden;
}
.kodza-header-grid.is-compact .kodza-header-grid__line {
display: none;
}
/* ---------- --hide and --tighten: stuck to the top, and moved by the script ---------- */
.kodza-header-grid--hide,
.kodza-header-grid--tighten {
position: sticky;
}
/* Where the header starts in the page (put there by the script): it tells how far you went */
.kodza-header-grid__spot {
display: block;
height: 1px;
margin: 0 0 -1px;
padding: 0;
visibility: hidden;
pointer-events: none;
}
.kodza-header-grid.is-ready {
transition: translate calc(0.5s * var(--kz-motion-scale, 1)) cubic-bezier(0.22, 1, 0.36, 1);
}
.kodza-header-grid.is-ready .kodza-header-grid__row,
.kodza-header-grid.is-ready .kodza-header-grid__brand {
transition:
translate calc(0.6s * var(--kz-motion-scale, 1)) cubic-bezier(0.33, 1, 0.68, 1),
scale calc(0.6s * var(--kz-motion-scale, 1)) cubic-bezier(0.33, 1, 0.68, 1);
}
/* Tight: the whole bar slides up by what it loses, its content comes back down by its share of
it (both set by the script), so the box keeps its height and the page under it never jumps */
.kodza-header-grid.is-tight {
translate: 0 calc(-1 * var(--kz-header-grid-lift, 0px));
transition-duration: calc(0.6s * var(--kz-motion-scale, 1));
transition-timing-function: cubic-bezier(0.33, 1, 0.68, 1);
}
.kodza-header-grid.is-tight .kodza-header-grid__row {
translate: 0 var(--kz-header-grid-drop, 0px);
}
.kodza-header-grid.is-tight .kodza-header-grid__brand {
scale: var(--kz-header-grid-shrink);
}
/* Hidden: it leaves slower than it comes back */
.kodza-header-grid.is-hidden {
translate: 0 calc(-100% - 1px);
transition-duration: calc(0.8s * var(--kz-motion-scale, 1));
transition-timing-function: cubic-bezier(0.65, 0, 0.35, 1);
}
/* ---------- The Menu button (data-mobile="menu", below the breakpoint) ---------- */
/* Two lines meet, then cross; the label rolls without changing the width */
.kodza-header-grid__toggle {
display: none;
align-items: center;
gap: 10px;
box-sizing: border-box;
height: 40px;
margin: 0;
padding: 0 18px 0 16px;
border: 0;
border-radius: var(--kz-radius-pill, 999px);
background: var(--kz-header-grid-soft);
color: var(--kz-header-grid-fg);
font: inherit;
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 15px);
font-weight: var(--kz-font-label-weight, 500);
line-height: 1;
letter-spacing: var(--kz-font-label-tracking, -0.01em);
text-transform: var(--kz-font-label-case, revert);
white-space: nowrap;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.kodza-header-grid__toggle:focus-visible {
outline: 2px solid var(--kz-header-grid-fg);
outline-offset: 3px;
}
.kodza-header-grid__lines {
position: relative;
flex: none;
width: 14px;
height: 8px;
}
.kodza-header-grid__lines::before,
.kodza-header-grid__lines::after {
content: "";
position: absolute;
left: 0;
width: 100%;
height: 1.5px;
border-radius: calc(1px * var(--kz-radius-scale, 1));
background: currentColor;
/* On its own layer: drawn the same way at rest and while turning, so no snap at the end */
will-change: transform;
/* Closing: they uncross first, then part */
transition:
rotate calc(0.22s * var(--kz-motion-scale, 1)) cubic-bezier(0.65, 0, 0.35, 1),
translate calc(0.22s * var(--kz-motion-scale, 1)) cubic-bezier(0.65, 0, 0.35, 1) calc(0.18s * var(--kz-motion-scale, 1)),
clip-path calc(0.3s * var(--kz-motion-scale, 1)) cubic-bezier(0.22, 1, 0.36, 1);
}
.kodza-header-grid__lines::before {
top: 0;
}
/* Whole pixels: both lines sit on the pixel grid the same way. Whole at rest, as an inset: the
pointer slides it to a shorter one (from none it would jump) */
.kodza-header-grid__lines::after {
top: 6px;
clip-path: inset(0 0 0 0);
}
@media (hover: hover) {
.kodza-header-grid__toggle:hover .kodza-header-grid__lines::after {
clip-path: inset(0 45% 0 0);
}
}
.kodza-header-grid__toggle[aria-expanded="true"] .kodza-header-grid__lines::before,
.kodza-header-grid__toggle[aria-expanded="true"] .kodza-header-grid__lines::after {
clip-path: inset(0 0 0 0);
transition:
translate calc(0.2s * var(--kz-motion-scale, 1)) cubic-bezier(0.65, 0, 0.35, 1),
rotate calc(0.3s * var(--kz-motion-scale, 1)) cubic-bezier(0.22, 1, 0.36, 1) calc(0.17s * var(--kz-motion-scale, 1)),
clip-path calc(0.3s * var(--kz-motion-scale, 1)) cubic-bezier(0.22, 1, 0.36, 1);
}
.kodza-header-grid__toggle[aria-expanded="true"] .kodza-header-grid__lines::before {
translate: 0 3px;
rotate: 45deg;
}
.kodza-header-grid__toggle[aria-expanded="true"] .kodza-header-grid__lines::after {
translate: 0 -3px;
rotate: -45deg;
}
.kodza-header-grid__label {
display: grid;
margin: -3px 0;
padding: 3px 0;
overflow: hidden;
}
/* The script rolls the words both ways: the old one goes up, the new one comes up from below */
.kodza-header-grid__say {
grid-area: 1 / 1;
}
.kodza-header-grid__say + .kodza-header-grid__say {
translate: 0 115%;
}
.kodza-header-grid__toggle[aria-expanded="true"] .kodza-header-grid__say {
translate: 0 -115%;
}
.kodza-header-grid__toggle[aria-expanded="true"] .kodza-header-grid__say + .kodza-header-grid__say {
translate: 0 0;
}
/* ---------- The shutter (data-mobile="menu") ---------- */
/* From the bottom of the header to the bottom of the screen, rolled up until it opens. A native
popover, so Escape, a click outside and the focus going back to Menu are the browser's own */
.kodza-header-grid__panel {
position: fixed;
inset: var(--kz-header-grid-from, 0px) 0 0 0;
box-sizing: border-box;
width: 100%;
height: auto;
max-width: none;
max-height: none;
margin: 0;
padding: 0 var(--kz-header-grid-menu-side);
border: 0;
overflow: hidden auto;
overscroll-behavior: contain;
background: var(--kz-header-grid-menu-bg);
color: var(--kz-header-grid-menu-fg);
font: inherit;
font-size: 15px;
line-height: 1.4;
text-align: left;
container-type: size;
z-index: 99990;
/* Here and not on the open state: the panel keeps its rows while it rolls back up */
grid-template-rows: 1fr auto;
-webkit-font-smoothing: antialiased;
clip-path: inset(0 0 100% 0);
/* Closing: quicker than it came down */
transition:
clip-path calc(var(--kz-header-grid-menu-duration) * 0.7 * var(--kz-motion-scale, 1)) var(--kz-header-grid-menu-ease),
display calc(var(--kz-header-grid-menu-duration) * 0.7 * var(--kz-motion-scale, 1)) allow-discrete,
overlay calc(var(--kz-header-grid-menu-duration) * 0.7 * var(--kz-motion-scale, 1)) allow-discrete;
}
.kodza-header-grid__panel:popover-open {
display: grid;
clip-path: inset(0 0 0 0);
transition:
clip-path calc(var(--kz-header-grid-menu-duration) * var(--kz-motion-scale, 1)) var(--kz-header-grid-menu-ease),
display calc(var(--kz-header-grid-menu-duration) * var(--kz-motion-scale, 1)) allow-discrete,
overlay calc(var(--kz-header-grid-menu-duration) * var(--kz-motion-scale, 1)) allow-discrete;
}
@starting-style {
.kodza-header-grid__panel:popover-open {
clip-path: inset(0 0 100% 0);
}
}
.kodza-header-grid__list {
align-self: center;
display: grid;
margin: 0;
padding: 28px 0;
}
/* Each row: its hairline draws in, then the word rises from its mask */
.kodza-header-grid__item {
position: relative;
display: grid;
grid-template-columns: clamp(44px, 9cqw, 132px) minmax(0, 1fr);
align-items: start;
margin: 0;
padding: 14px 0 10px;
color: inherit;
text-decoration: none;
outline: none;
}
.kodza-header-grid__item::before,
.kodza-header-grid__item:last-child::after {
content: "";
position: absolute;
inset: 0 0 auto;
height: 1px;
background: var(--kz-header-grid-menu-line);
scale: 0 1;
transform-origin: left center;
transition: scale calc(0.3s * var(--kz-motion-scale, 1)) cubic-bezier(0.65, 0, 0.35, 1);
}
/* The last row closes the list with a line under it, so the list is framed top and bottom */
.kodza-header-grid__item:last-child::after {
inset: auto 0 0;
}
.kodza-header-grid__index {
display: flex;
align-items: center;
gap: 8px;
padding-top: 0.4em;
font-family: var(--kz-font-label, var(--kz-font-mono, "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace));
font-size: var(--kz-font-label-size, 12px);
font-weight: var(--kz-font-label-weight, inherit);
line-height: var(--kz-font-label-leading, 1.4);
letter-spacing: var(--kz-font-label-tracking, inherit);
color: var(--kz-header-grid-menu-muted);
opacity: 0;
transition:
opacity calc(0.2s * var(--kz-motion-scale, 1)),
color calc(0.25s * var(--kz-motion-scale, 1));
}
/* The page you are on: its number lights up, with a dot */
.kodza-header-grid__item[aria-current="page"] .kodza-header-grid__index {
color: var(--kz-header-grid-menu-fg);
}
.kodza-header-grid__item[aria-current="page"] .kodza-header-grid__index::after {
content: "";
flex: none;
width: 5px;
height: 5px;
border-radius: 50%;
background: currentColor;
}
/* The mask has the size of its word, so its room for descenders (g, j, p, q, y) and its step
aside are measured on the word */
.kodza-header-grid__mask {
display: block;
margin: -0.06em 0 -0.24em;
padding: 0.06em 0 0.24em;
overflow: hidden;
font-size: var(--kz-header-grid-menu-size);
transition: translate calc(0.45s * var(--kz-motion-scale, 1)) cubic-bezier(0.22, 1, 0.36, 1);
}
.kodza-header-grid__word {
display: block;
font-family: var(--kz-font-display, inherit);
font-size: var(--kz-header-grid-menu-size);
font-weight: var(--kz-font-display-weight, 500);
line-height: 0.95;
letter-spacing: var(--kz-font-display-tracking, -0.045em);
text-transform: var(--kz-font-display-case, inherit);
translate: 0 135%;
/* Closing: the words drop back into their masks while the shutter rolls up */
transition: translate calc(0.32s * var(--kz-motion-scale, 1)) cubic-bezier(0.65, 0, 0.35, 1);
}
/* Under the list: the bar's notes and buttons, copied by the script */
.kodza-header-grid__foot {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px 24px;
padding: 16px 0 22px;
border-top: 1px solid var(--kz-header-grid-menu-line);
opacity: 0;
translate: 0 8px;
transition:
opacity calc(0.2s * var(--kz-motion-scale, 1)),
translate calc(0.3s * var(--kz-motion-scale, 1)) cubic-bezier(0.65, 0, 0.35, 1);
}
.kodza-header-grid__foot:empty {
display: none;
}
.kodza-header-grid__fact {
margin: 0;
color: var(--kz-header-grid-menu-muted);
font-family: var(--kz-font-label, var(--kz-font-mono, "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace));
font-size: var(--kz-font-label-size, 12px);
font-weight: var(--kz-font-label-weight, inherit);
line-height: var(--kz-font-label-leading, 1.4);
letter-spacing: var(--kz-font-label-tracking, inherit);
text-transform: var(--kz-font-label-case, inherit);
}
.kodza-header-grid__action {
display: inline-flex;
align-items: center;
box-sizing: border-box;
height: 40px;
margin: 0;
padding: 0 18px;
border-radius: var(--kz-radius-pill, 999px);
background: var(--kz-header-grid-menu-fg);
color: var(--kz-header-grid-menu-bg);
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 15px);
font-weight: var(--kz-font-label-weight, 500);
line-height: 1;
letter-spacing: var(--kz-font-label-tracking, -0.01em);
text-decoration: none;
text-transform: var(--kz-font-label-case, inherit);
white-space: nowrap;
}
.kodza-header-grid__action:focus-visible {
outline: 2px solid var(--kz-header-grid-menu-fg);
outline-offset: 3px;
}
/* Open: each part starts when the edge reaches it (the script sets --kz-header-grid-delay) */
.kodza-header-grid__panel.is-open .kodza-header-grid__item::before {
scale: 1 1;
transition: scale calc(0.9s * var(--kz-motion-scale, 1)) cubic-bezier(0.22, 1, 0.36, 1) var(--kz-header-grid-delay, 0s);
}
.kodza-header-grid__panel.is-open .kodza-header-grid__item:last-child::after {
scale: 1 1;
transition: scale calc(0.9s * var(--kz-motion-scale, 1)) cubic-bezier(0.22, 1, 0.36, 1) var(--kz-header-grid-delay-end, 0s);
}
.kodza-header-grid__panel.is-open .kodza-header-grid__index {
opacity: 1;
transition:
opacity calc(0.6s * var(--kz-motion-scale, 1)) cubic-bezier(0.22, 1, 0.36, 1) calc(var(--kz-header-grid-delay, 0s) + 0.12s * var(--kz-motion-scale, 1)),
color calc(0.25s * var(--kz-motion-scale, 1));
}
.kodza-header-grid__panel.is-open .kodza-header-grid__word {
translate: 0 0;
transition: translate calc(0.75s * var(--kz-motion-scale, 1)) cubic-bezier(0.22, 1, 0.36, 1) var(--kz-header-grid-delay, 0s);
}
.kodza-header-grid__panel.is-open .kodza-header-grid__foot {
opacity: 1;
translate: 0 0;
transition:
opacity calc(0.6s * var(--kz-motion-scale, 1)) cubic-bezier(0.22, 1, 0.36, 1) var(--kz-header-grid-delay, 0s),
translate calc(0.7s * var(--kz-motion-scale, 1)) cubic-bezier(0.22, 1, 0.36, 1) var(--kz-header-grid-delay, 0s);
}
/* Pointed at: the word steps aside and its number lights up */
@media (hover: hover) {
.kodza-header-grid__item:hover .kodza-header-grid__mask {
translate: 0.14em 0;
}
.kodza-header-grid__item:hover .kodza-header-grid__index {
color: var(--kz-header-grid-menu-fg);
}
}
.kodza-header-grid__item:focus-visible .kodza-header-grid__mask {
translate: 0.14em 0;
}
.kodza-header-grid__item:focus-visible .kodza-header-grid__index {
color: var(--kz-header-grid-menu-fg);
}
.kodza-header-grid__item:focus-visible .kodza-header-grid__word {
text-decoration: underline;
text-decoration-thickness: 2px;
text-underline-offset: 0.12em;
}
/* ---------- Below the breakpoint (is-compact, set by the script) ---------- */
/* The notes step aside, whatever the phone layout, and a zone of notes only goes with them (it
would keep asking for its columns) */
.kodza-header-grid.is-compact .kodza-header-grid__note,
.kodza-header-grid.is-compact .kodza-header-grid__actions:not(:has(> :not(.kodza-header-grid__note))) {
display: none;
}
/* menu, wrap and scroll: the brand and the actions share the first line */
.kodza-header-grid.is-compact[data-mobile] .kodza-header-grid__row {
grid-template-columns: minmax(0, 1fr) auto;
}
.kodza-header-grid.is-compact[data-mobile] .kodza-header-grid__brand {
grid-column: 1;
}
.kodza-header-grid.is-compact[data-mobile] .kodza-header-grid__actions {
display: flex;
grid-column: 2;
justify-self: end;
}
.kodza-header-grid.is-compact[data-mobile] .kodza-header-grid__nav {
display: block;
grid-column: 1 / -1;
grid-row: 2;
justify-self: stretch;
margin: 0;
}
.kodza-header-grid.is-compact[data-mobile] .kodza-header-grid__links {
display: flex;
gap: 0 20px;
}
/* menu: the links wait in the shutter */
.kodza-header-grid.is-compact[data-mobile="menu"] .kodza-header-grid__nav {
display: none;
}
.kodza-header-grid.is-compact[data-mobile="menu"] .kodza-header-grid__toggle {
display: inline-flex;
}
/* wrap and scroll: the links on a second line, aligned on the brand */
.kodza-header-grid.is-compact[data-mobile="wrap"] .kodza-header-grid__row,
.kodza-header-grid.is-compact[data-mobile="scroll"] .kodza-header-grid__row {
grid-template-rows: minmax(var(--kz-header-grid-height), auto) auto;
min-height: 0;
}
.kodza-header-grid.is-compact[data-mobile="wrap"] .kodza-header-grid__link,
.kodza-header-grid.is-compact[data-mobile="scroll"] .kodza-header-grid__link {
padding-inline: 0;
}
.kodza-header-grid.is-compact[data-mobile="wrap"] .kodza-header-grid__link::after,
.kodza-header-grid.is-compact[data-mobile="scroll"] .kodza-header-grid__link::after {
left: 0;
right: 0;
}
.kodza-header-grid.is-compact[data-mobile="wrap"] .kodza-header-grid__links {
flex-wrap: wrap;
}
/* scroll: one line that runs to the edges, fading there */
.kodza-header-grid.is-compact[data-mobile="scroll"] .kodza-header-grid__nav {
margin: -4px calc(-1 * var(--kz-header-grid-pad));
padding: 4px var(--kz-header-grid-pad);
overflow-x: auto;
overscroll-behavior-x: contain;
scrollbar-width: none;
-webkit-mask-image: linear-gradient(to right, transparent, #000 var(--kz-header-grid-pad), #000 calc(100% - var(--kz-header-grid-pad)), transparent);
mask-image: linear-gradient(to right, transparent, #000 var(--kz-header-grid-pad), #000 calc(100% - var(--kz-header-grid-pad)), transparent);
}
.kodza-header-grid.is-compact[data-mobile="scroll"] .kodza-header-grid__nav::-webkit-scrollbar {
display: none;
}
.kodza-header-grid.is-compact[data-mobile="scroll"] .kodza-header-grid__links {
flex-wrap: nowrap;
width: max-content;
}
/* menu, on the narrowest phones: the buttons wait in the shutter too, only Menu stays */
@container (max-width: 519px) {
.kodza-header-grid.is-compact[data-mobile="menu"] .kodza-header-grid__button {
display: none;
}
}
/* Before the script: the default breakpoint, 900 px, so nothing jumps when it starts (a container
query can't read a variable; the script then follows --kz-header-grid-breakpoint) */
@container (max-width: 899px) {
.kodza-header-grid:not(.is-ready) .kodza-header-grid__note,
.kodza-header-grid:not(.is-ready) .kodza-header-grid__actions:not(:has(> :not(.kodza-header-grid__note))) {
display: none;
}
.kodza-header-grid[data-mobile]:not(.is-ready) .kodza-header-grid__row {
grid-template-columns: minmax(0, 1fr) auto;
}
.kodza-header-grid[data-mobile]:not(.is-ready) .kodza-header-grid__brand {
grid-column: 1;
}
.kodza-header-grid[data-mobile]:not(.is-ready) .kodza-header-grid__actions {
display: flex;
grid-column: 2;
justify-self: end;
}
.kodza-header-grid[data-mobile]:not(.is-ready) .kodza-header-grid__nav {
display: block;
grid-column: 1 / -1;
grid-row: 2;
justify-self: stretch;
margin: 0;
}
.kodza-header-grid[data-mobile]:not(.is-ready) .kodza-header-grid__links {
display: flex;
gap: 0 20px;
}
.kodza-header-grid[data-mobile="menu"]:not(.is-ready) .kodza-header-grid__nav {
display: none;
}
.kodza-header-grid[data-mobile="menu"]:not(.is-ready) .kodza-header-grid__toggle {
display: inline-flex;
}
.kodza-header-grid[data-mobile="wrap"]:not(.is-ready) .kodza-header-grid__row,
.kodza-header-grid[data-mobile="scroll"]:not(.is-ready) .kodza-header-grid__row {
grid-template-rows: minmax(var(--kz-header-grid-height), auto) auto;
min-height: 0;
}
.kodza-header-grid[data-mobile="wrap"]:not(.is-ready) .kodza-header-grid__link,
.kodza-header-grid[data-mobile="scroll"]:not(.is-ready) .kodza-header-grid__link {
padding-inline: 0;
}
.kodza-header-grid[data-mobile="wrap"]:not(.is-ready) .kodza-header-grid__link::after,
.kodza-header-grid[data-mobile="scroll"]:not(.is-ready) .kodza-header-grid__link::after {
left: 0;
right: 0;
}
.kodza-header-grid[data-mobile="wrap"]:not(.is-ready) .kodza-header-grid__links {
flex-wrap: wrap;
}
.kodza-header-grid[data-mobile="scroll"]:not(.is-ready) .kodza-header-grid__nav {
margin: -4px calc(-1 * var(--kz-header-grid-pad));
padding: 4px var(--kz-header-grid-pad);
overflow-x: auto;
scrollbar-width: none;
}
.kodza-header-grid[data-mobile="scroll"]:not(.is-ready) .kodza-header-grid__links {
flex-wrap: nowrap;
width: max-content;
}
}
@media (prefers-reduced-motion: reduce) {
.kodza-header-grid.is-ready,
.kodza-header-grid.is-ready .kodza-header-grid__row,
.kodza-header-grid.is-ready .kodza-header-grid__brand,
.kodza-header-grid.is-ready .kodza-header-grid__line,
.kodza-header-grid.is-ready .kodza-header-grid__line::after,
.kodza-header-grid__panel,
.kodza-header-grid__panel *,
.kodza-header-grid__panel *::before,
.kodza-header-grid__panel *::after,
.kodza-header-grid__toggle *,
.kodza-header-grid__toggle *::before,
.kodza-header-grid__toggle *::after {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
}
}
/* kodza-guard 01ff5c1ad8db — 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-header-grid):not(#_) {
transition: revert;
}
:where(.kodza-header-grid__brand):not(#_) {
padding: revert;
outline: revert;
transition: revert;
box-shadow: revert;
outline-offset: revert;
}
:where(.kodza-header-grid__link):not(#_) {
outline: revert;
box-shadow: revert;
outline-offset: revert;
}
:where(.kodza-header-grid__button):not(#_),
:where(.kodza-header-grid__toggle):not(#_) {
outline: revert;
outline-offset: revert;
}
:where(.kodza-header-grid__list):not(#_),
:where(.kodza-header-grid__item):not(#_) {
transition-duration: revert;
transition-delay: revert;
}
:where(.kodza-header-grid__action):not(#_) {
outline: revert;
outline-offset: revert;
transition-duration: revert;
transition-delay: revert;
}
:where(.kodza-header-grid__note):not(#_) {
padding: revert;
border-radius: revert;
box-shadow: revert;
}
:where(.kodza-header-grid__note):not(#_):where(:not([hidden])) {
display: revert;
}
:where(.kodza-header-grid__note):not(#_):where([hidden]) {
display: none;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-header-grid:not(#_) {
border: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: 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-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-header-grid__brand:not(#_),
.kodza-header-grid__link:not(#_) {
border: revert;
list-style: revert;
float: revert;
background-color: revert;
background-image: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-style: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-header-grid__nav:not(#_) {
border: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: 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-header-grid__links:not(#_),
.kodza-header-grid__links li:where(:is(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))):not(.kodza-header-grid__links :not(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))) *)):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-header-grid__actions:not(#_),
.kodza-header-grid__lines:not(#_),
.kodza-header-grid__label:not(#_),
.kodza-header-grid__say:not(#_),
.kodza-header-grid__index:not(#_),
.kodza-header-grid__mask:not(#_),
.kodza-header-grid__word:not(#_),
.kodza-header-grid__foot:not(#_),
.kodza-header-grid__line:not(#_),
.kodza-header-grid__brand span:where(:not([class*="kodza-"])):not(#_) {
box-sizing: revert;
}
.kodza-header-grid__button:not(#_) {
border: revert;
list-style: revert;
float: revert;
position: revert;
box-shadow: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-style: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-header-grid__toggle:not(#_) {
text-decoration: revert;
transition: revert;
list-style: revert;
float: revert;
position: revert;
box-shadow: revert;
appearance: revert;
max-width: revert;
max-height: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
min-height: revert;
}
.kodza-header-grid__list:not(#_) {
border: 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;
transition-property: revert;
transition-timing-function: revert;
}
.kodza-header-grid__item:not(#_) {
border: revert;
border-radius: revert;
list-style: revert;
box-sizing: revert;
float: 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;
transition-property: revert;
transition-timing-function: revert;
}
.kodza-header-grid__action:not(#_) {
border: revert;
list-style: revert;
float: revert;
position: revert;
box-shadow: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-style: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
transition-property: revert;
transition-timing-function: revert;
}
.kodza-header-grid__note:not(#_) {
border: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
background-color: revert;
background-image: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-style: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-header-grid__brand svg:where(:not([class*="kodza-"])):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-header-grid__link:not(#_)::after,
.kodza-header-grid__brand:not(#_)::after,
.kodza-header-grid__line:not(#_)::after,
.kodza-header-grid__lines:not(#_)::before,
.kodza-header-grid__lines:not(#_)::after,
.kodza-header-grid__item:not(#_)::before,
.kodza-header-grid__item:not(#_)::after,
.kodza-header-grid__index:not(#_)::after {
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-header-grid:not(#_) {
position: var(--kz-header-grid-position);
display: block;
box-sizing: border-box;
padding: 0;
background: var(--kz-header-grid-bg);
color: var(--kz-header-grid-fg);
font-size: 15px;
line-height: 1.4;
text-align: left;
}
.kodza-header-grid__brand:not(#_) {
position: relative;
display: inline-flex;
box-sizing: border-box;
margin: 0;
border-radius: calc(6px * var(--kz-radius-scale, 1));
color: var(--kz-header-grid-fg);
font-family: var(--kz-font-display, inherit);
font-size: calc(var(--kz-header-grid-brand-size) * var(--kz-font-display-scale, 1));
font-weight: var(--kz-font-display-weight, 600);
line-height: 1;
letter-spacing: var(--kz-font-display-tracking, -0.04em);
text-transform: var(--kz-font-display-case, inherit);
text-decoration: none;
}
.kodza-header-grid__brand svg:not(#_) {
display: block;
}
.kodza-header-grid__nav:not(#_) {
position: relative;
display: block;
margin: 0 -14px;
padding: 0;
}
.kodza-header-grid__links:not(#_) {
display: flex;
margin: 0;
padding: 0;
list-style: none;
}
.kodza-header-grid__links > li:not(#_) {
display: block;
margin: 0;
padding: 0;
list-style: none;
}
.kodza-header-grid__link:not(#_) {
position: relative;
display: inline-flex;
box-sizing: border-box;
margin: 0;
padding: 0 14px;
border-radius: calc(8px * var(--kz-radius-scale, 1));
color: var(--kz-header-grid-link);
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 15px);
font-weight: var(--kz-font-label-weight, 500);
line-height: 1;
letter-spacing: var(--kz-font-label-tracking, -0.01em);
text-transform: var(--kz-font-label-case, inherit);
text-decoration: none;
transition: color calc(0.2s * var(--kz-motion-scale, 1));
}
.kodza-header-grid__link:focus-visible:not(#_),
.kodza-header-grid__link[aria-current="page"]:not(#_) {
color: var(--kz-header-grid-fg);
}
@media (hover: hover) {
.kodza-header-grid__link:hover:not(#_) {
color: var(--kz-header-grid-fg);
}
}
.kodza-header-grid__brand:focus-visible:not(#_),
.kodza-header-grid__link:focus-visible:not(#_) {
outline: 2px solid var(--kz-header-grid-fg);
outline-offset: 2px;
}
.kodza-header-grid__note:not(#_) {
position: relative;
margin: 0;
color: var(--kz-header-grid-muted);
font-family: var(--kz-font-label, var(--kz-font-mono, "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace));
font-size: var(--kz-font-label-size, 12px);
font-weight: var(--kz-font-label-weight, inherit);
line-height: var(--kz-font-label-leading, 1.4);
letter-spacing: var(--kz-font-label-tracking, inherit);
text-transform: var(--kz-font-label-case, inherit);
}
.kodza-header-grid__button:not(#_) {
display: inline-flex;
box-sizing: border-box;
margin: 0;
padding: 0 18px;
border-radius: var(--kz-radius-pill, 999px);
background: var(--kz-header-grid-ink);
color: var(--kz-header-grid-on-ink);
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 15px);
font-weight: var(--kz-font-label-weight, 500);
line-height: 1;
letter-spacing: var(--kz-font-label-tracking, -0.01em);
text-decoration: none;
text-transform: var(--kz-font-label-case, inherit);
transition: background-color calc(0.2s * var(--kz-motion-scale, 1));
}
@media (hover: hover) {
.kodza-header-grid__button:hover:not(#_) {
background: color-mix(in srgb, var(--kz-header-grid-ink) 84%, transparent);
}
}
.kodza-header-grid__button:focus-visible:not(#_) {
outline: 2px solid var(--kz-header-grid-ink);
outline-offset: 3px;
}
.kodza-header-grid--cells .kodza-header-grid__nav:not(#_) {
display: grid;
}
.kodza-header-grid--cells .kodza-header-grid__nav:not(#_) {
margin: 0;
}
.kodza-header-grid--cells .kodza-header-grid__links:not(#_) {
display: grid;
}
.kodza-header-grid--cells .kodza-header-grid__brand:not(#_),
.kodza-header-grid--cells .kodza-header-grid__link:not(#_),
.kodza-header-grid--cells .kodza-header-grid__note:not(#_) {
display: flex;
padding: 0 0 10px;
border-radius: 0;
box-shadow: inset 0 calc(-1 * var(--kz-header-grid-rule)) 0 var(--kz-header-grid-rule-color);
}
.kodza-header-grid--cells .kodza-header-grid__link:not(#_) {
line-height: var(--kz-font-label-leading, 1.4);
}
.kodza-header-grid--cells .kodza-header-grid__note:not(#_) {
display: block;
}
.kodza-header-grid--hide:not(#_),
.kodza-header-grid--tighten:not(#_) {
position: sticky;
}
.kodza-header-grid.is-ready:not(#_) {
transition: translate calc(0.5s * var(--kz-motion-scale, 1)) cubic-bezier(0.22, 1, 0.36, 1);
}
.kodza-header-grid.is-ready .kodza-header-grid__brand:not(#_) {
transition: translate calc(0.6s * var(--kz-motion-scale, 1)) cubic-bezier(0.33, 1, 0.68, 1),
scale calc(0.6s * var(--kz-motion-scale, 1)) cubic-bezier(0.33, 1, 0.68, 1);
}
.kodza-header-grid.is-tight:not(#_) {
transition-duration: calc(0.6s * var(--kz-motion-scale, 1));
transition-timing-function: cubic-bezier(0.33, 1, 0.68, 1);
}
.kodza-header-grid.is-hidden:not(#_) {
transition-duration: calc(0.8s * var(--kz-motion-scale, 1));
transition-timing-function: cubic-bezier(0.65, 0, 0.35, 1);
}
.kodza-header-grid__toggle:not(#_) {
display: none;
align-items: center;
gap: 10px;
box-sizing: border-box;
height: 40px;
margin: 0;
padding: 0 18px 0 16px;
border: 0;
border-radius: var(--kz-radius-pill, 999px);
background: var(--kz-header-grid-soft);
color: var(--kz-header-grid-fg);
font: inherit;
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 15px);
font-weight: var(--kz-font-label-weight, 500);
line-height: 1;
letter-spacing: var(--kz-font-label-tracking, -0.01em);
text-transform: var(--kz-font-label-case, revert);
white-space: nowrap;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.kodza-header-grid__toggle:focus-visible:not(#_) {
outline: 2px solid var(--kz-header-grid-fg);
outline-offset: 3px;
}
.kodza-header-grid__list:not(#_) {
display: grid;
margin: 0;
padding: 28px 0;
}
.kodza-header-grid__item:not(#_) {
position: relative;
display: grid;
margin: 0;
padding: 14px 0 10px;
color: inherit;
text-decoration: none;
outline: none;
}
.kodza-header-grid__action:not(#_) {
display: inline-flex;
box-sizing: border-box;
margin: 0;
padding: 0 18px;
border-radius: var(--kz-radius-pill, 999px);
background: var(--kz-header-grid-menu-fg);
color: var(--kz-header-grid-menu-bg);
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 15px);
font-weight: var(--kz-font-label-weight, 500);
line-height: 1;
letter-spacing: var(--kz-font-label-tracking, -0.01em);
text-decoration: none;
text-transform: var(--kz-font-label-case, inherit);
}
.kodza-header-grid__action:focus-visible:not(#_) {
outline: 2px solid var(--kz-header-grid-menu-fg);
outline-offset: 3px;
}
.kodza-header-grid.is-compact .kodza-header-grid__note:not(#_) {
display: none;
}
.kodza-header-grid.is-compact[data-mobile] .kodza-header-grid__nav:not(#_) {
display: block;
margin: 0;
}
.kodza-header-grid.is-compact[data-mobile] .kodza-header-grid__links:not(#_) {
display: flex;
}
.kodza-header-grid.is-compact[data-mobile="menu"] .kodza-header-grid__nav:not(#_) {
display: none;
}
.kodza-header-grid.is-compact[data-mobile="menu"] .kodza-header-grid__toggle:not(#_) {
display: inline-flex;
}
.kodza-header-grid.is-compact[data-mobile="wrap"] .kodza-header-grid__link:not(#_),
.kodza-header-grid.is-compact[data-mobile="scroll"] .kodza-header-grid__link:not(#_) {
padding-inline: 0;
}
.kodza-header-grid.is-compact[data-mobile="scroll"] .kodza-header-grid__nav:not(#_) {
margin: -4px calc(-1 * var(--kz-header-grid-pad));
padding: 4px var(--kz-header-grid-pad);
}
@container (max-width: 519px) {
.kodza-header-grid.is-compact[data-mobile="menu"] .kodza-header-grid__button:not(#_) {
display: none;
}
}
@container (max-width: 899px) {
.kodza-header-grid:not(.is-ready) .kodza-header-grid__note:not(#_) {
display: none;
}
}
@container (max-width: 899px) {
.kodza-header-grid[data-mobile]:not(.is-ready) .kodza-header-grid__nav:not(#_) {
display: block;
margin: 0;
}
}
@container (max-width: 899px) {
.kodza-header-grid[data-mobile]:not(.is-ready) .kodza-header-grid__links:not(#_) {
display: flex;
}
}
@container (max-width: 899px) {
.kodza-header-grid[data-mobile="menu"]:not(.is-ready) .kodza-header-grid__nav:not(#_) {
display: none;
}
}
@container (max-width: 899px) {
.kodza-header-grid[data-mobile="menu"]:not(.is-ready) .kodza-header-grid__toggle:not(#_) {
display: inline-flex;
}
}
@container (max-width: 899px) {
.kodza-header-grid[data-mobile="wrap"]:not(.is-ready) .kodza-header-grid__link:not(#_),
.kodza-header-grid[data-mobile="scroll"]:not(.is-ready) .kodza-header-grid__link:not(#_) {
padding-inline: 0;
}
}
@container (max-width: 899px) {
.kodza-header-grid[data-mobile="scroll"]:not(.is-ready) .kodza-header-grid__nav:not(#_) {
margin: -4px calc(-1 * var(--kz-header-grid-pad));
padding: 4px var(--kz-header-grid-pad);
}
}
@media (prefers-reduced-motion: reduce) {
.kodza-header-grid.is-ready:not(#_),
.kodza-header-grid.is-ready .kodza-header-grid__brand:not(#_),
.kodza-header-grid__panel *:where(nav, a):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 — Header Grid
// The CSS places the zones on the grid; the script adds what CSS can't do on its own:
// - is-compact below --kz-header-grid-breakpoint (a container query can't read a variable);
// - with --line, one line under the links: it glides to the link you point at, front edge first,
// and goes back under the page you are on (the engine of Header Classic);
// - with --hide and --tighten, how far the page went and which way, read on a mark right before
// the header, so it works in any scrolling box;
// - with data-mobile="menu", the shutter (a native popover), written from the bar's own links,
// notes and buttons, so a menu changed in the CMS changes both;
// - with data-mobile="scroll", the page you are on brought into view.
// Everything you tune is a --kz-header-grid-* variable of the CSS; the numbers below are the
// script's own.
(() => {
const SELECTOR = '.kodza-header-grid';
const STEP = 6; // px: a scroll smaller than this never turns --hide around
const ROLL = 500; // ms: Menu rolling to Close, at the site's pace
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// The site's pace, --kz-motion-scale (set on :root, read on the instance): every duration of the
// script times it, and 0 moves nothing, like reduced motion
const paceOf = (el) => {
const pace = parseFloat(getComputedStyle(el).getPropertyValue('--kz-motion-scale'));
return pace >= 0 ? pace : 1;
};
const c = (name) => `.kodza-header-grid__${name}`;
// A length variable in px (px, rem or em), or the fallback
const lengthOf = (el, name, fallback) => {
const raw = getComputedStyle(el).getPropertyValue(name).trim();
const value = parseFloat(raw);
if (!(value >= 0)) return fallback;
if (/rem$/.test(raw)) return value * parseFloat(getComputedStyle(document.documentElement).fontSize);
if (/em$/.test(raw)) return value * parseFloat(getComputedStyle(el).fontSize);
return value;
};
// When (0 to 1 of the duration) a cubic-bezier curve gets to the progress p
const timeAt = ([x1, y1, x2, y2], p) => {
const at = (a, b, s) => 3 * a * s * (1 - s) ** 2 + 3 * b * s * s * (1 - s) + s ** 3;
let lo = 0;
let hi = 1;
for (let i = 0; i < 24; i++) {
const mid = (lo + hi) / 2;
if (at(y1, y2, mid) < p) lo = mid;
else hi = mid;
}
return at(x1, x2, (lo + hi) / 2);
};
// A wheel or a swipe only scrolls what can scroll in the panel, under the pointer
const scrollsInside = (panel, target, x, y, dy) => {
for (let el = target; el instanceof Element && panel.contains(el); el = el.parentElement) {
const box = el.getBoundingClientRect();
if (x < box.left || x > box.right || y < box.top || y > box.bottom) continue;
if (!/(auto|scroll)/.test(getComputedStyle(el).overflowY) || el.scrollHeight <= el.clientHeight + 1) continue;
if (dy > 0 ? el.scrollTop + el.clientHeight < el.scrollHeight - 1 : el.scrollTop > 0) return true;
}
return false;
};
// One instance, with its own state: two copies on a page never interfere. Everything it
// starts is tied to `signal`, so kodza:destroy stops it all.
function mount(root, signal) {
const pace = paceOf(root);
const still = reduceMotion || !pace; // the still path: no motion asked, by the visitor or the site
const has = (name) => root.classList.contains(`kodza-header-grid--${name}`);
const nav = root.querySelector(c('nav'));
const links = [...root.querySelectorAll(c('link'))];
const current = () => links.find((link) => link.getAttribute('aria-current') === 'page') || null;
const toggle = root.querySelector(c('toggle'));
const panel = root.querySelector(c('panel'));
let open = false;
let compact = false;
// ---------- The line (--line) ----------
let line = null;
let at = null; // the link the line is under
let over = null; // the link under the pointer or the focus
if (has('line') && nav && links.length) {
line = document.createElement('span');
line.className = 'kodza-header-grid__line';
line.setAttribute('aria-hidden', 'true');
nav.append(line);
}
// A link's word in the nav's own pixels: a parent scaled by transform does not shift it
const span = (link) => {
const n = nav.getBoundingClientRect();
const scale = n.width / nav.offsetWidth || 1;
const b = link.getBoundingClientRect();
const style = getComputedStyle(link);
return {
left: (b.left - n.left) / scale + (parseFloat(style.paddingLeft) || 0),
right: (n.right - b.right) / scale + (parseFloat(style.paddingRight) || 0),
};
};
const set = ({ left, right }) => {
line.style.setProperty('--kz-header-grid-line-left', `${left}px`);
line.style.setProperty('--kz-header-grid-line-right', `${right}px`);
};
// A line with no length, in the middle of a link's word
const dot = (link) => {
const s = span(link);
const mid = (s.left + nav.offsetWidth - s.right) / 2;
return { left: mid, right: nav.offsetWidth - mid };
};
const instantly = (fn) => {
line.classList.add('is-instant');
fn();
getComputedStyle(line, '::after').clipPath; // both edges settle before the transitions come back
void line.offsetWidth;
line.classList.remove('is-instant');
};
const go = (link, instant) => {
if (!line || !line.getClientRects().length) return; // below the breakpoint the links mark themselves
if (instant) {
instantly(() => set(link ? span(link) : { left: 0, right: nav.offsetWidth }));
} else if (link && !at) {
// From nowhere: it grows out of the middle of the word
line.classList.remove('is-rightward', 'is-leftward');
instantly(() => set(dot(link)));
set(span(link));
} else if (link) {
// From a link to the next: the edge in front leaves first
if (link !== at) {
const rightward = links.indexOf(link) > links.indexOf(at);
line.classList.toggle('is-rightward', rightward);
line.classList.toggle('is-leftward', !rightward);
}
set(span(link));
} else if (at) {
// Back to nowhere: it shrinks into the middle of the last word
line.classList.remove('is-rightward', 'is-leftward');
set(dot(at));
}
at = link;
};
if (line) {
nav.addEventListener('pointerover', (event) => {
const link = event.target.closest?.(c('link'));
if (!link || link === over) return;
over = link;
go(link);
}, { signal });
nav.addEventListener('pointerleave', () => {
over = null;
go(current());
}, { signal });
nav.addEventListener('focusin', (event) => {
const link = event.target.closest?.(c('link'));
if (!link) return;
over = link;
go(link);
}, { signal });
nav.addEventListener('focusout', (event) => {
if (nav.contains(event.relatedTarget)) return;
over = null;
go(current());
}, { signal });
document.fonts?.ready.then(() => !signal.aborted && fit());
}
// ---------- Scroll (data-mobile="scroll"): the page you are on, in view ----------
const reveal = () => {
const link = current();
if (!compact || root.dataset.mobile !== 'scroll' || !link || nav.scrollWidth <= nav.clientWidth) return;
const n = nav.getBoundingClientRect();
const scale = n.width / nav.offsetWidth || 1;
const b = link.getBoundingClientRect();
const left = nav.scrollLeft + (b.left - n.left) / scale - (nav.clientWidth - b.width / scale) / 2;
nav.scrollTo({ left: Math.max(0, left), behavior: 'instant' });
};
// ---------- How far the page went (--hide, --tighten) ----------
let spot = null;
let lastY = null;
let frame = 0;
// Tight, the bar slides up by what it loses (never more than the room around its content) and
// its content comes back down by its share of that room, so it keeps its place: centred stays
// centred, a row on the bottom edge stays on it. Measured at rest, in layout pixels
const shape = () => {
if (!has('tighten')) return;
const items = [...root.querySelectorAll(`${c('brand')}, ${c('link')}, ${c('note')}, ${c('button')}, ${c('toggle')}`)]
.filter((el) => el.getClientRects().length);
if (!items.length) return;
const offset = (el, side) => {
let value = 0;
for (let e = el; e && e !== root; e = e.offsetParent) value += side === 'top' ? e.offsetTop : e.offsetLeft;
return value;
};
let above = Infinity;
let below = Infinity;
items.forEach((el) => {
const top = offset(el, 'top');
above = Math.min(above, top);
below = Math.min(below, root.offsetHeight - top - el.offsetHeight);
});
above = Math.max(0, above);
below = Math.max(0, below);
const room = above + below;
const lift = Math.min(lengthOf(root, '--kz-header-grid-tighten', 16), room);
root.style.setProperty('--kz-header-grid-lift', `${lift}px`);
root.style.setProperty('--kz-header-grid-drop', `${room ? (lift * below) / room : 0}px`);
// The brand shrinks towards where it sits: its side of the row, or its middle when centred,
// and the edge the row sits on
const brand = root.querySelector(c('brand'));
if (brand) {
const middle = offset(brand, 'left') + brand.offsetWidth / 2 - root.offsetWidth / 2;
const x = Math.abs(middle) < 4 ? 'center' : middle < 0 ? 'left' : 'right';
const y = room ? `${Math.round((above / room) * 100)}%` : 'center';
root.style.setProperty('--kz-header-grid-origin', `${x} ${y}`);
}
};
const read = () => {
frame = 0;
if (!spot) return;
const top = parseFloat(getComputedStyle(root).top) || 0;
const y = top - spot.getBoundingClientRect().top; // how far past its place in the page
if (has('tighten')) {
const tight = y > lengthOf(root, '--kz-header-grid-tighten-after', 40);
if (tight !== root.classList.contains('is-tight')) {
if (tight) shape();
root.classList.toggle('is-tight', tight);
}
}
if (has('hide')) {
if (lastY === null) lastY = y;
const delta = y - lastY;
if (Math.abs(delta) < STEP) return;
lastY = y;
// Down, past --kz-header-grid-hide-after, and not while you use it from the keyboard or
// its menu is open; up, it comes back
const far = y > lengthOf(root, '--kz-header-grid-hide-after', 600);
const busy = open || root.querySelector(':focus-visible');
root.classList.toggle('is-hidden', delta > 0 && far && !busy);
}
};
const schedule = () => {
if (!frame) frame = requestAnimationFrame(read);
};
if (has('hide') || has('tighten')) {
spot = document.createElement('span');
spot.className = 'kodza-header-grid__spot';
spot.setAttribute('aria-hidden', 'true');
root.before(spot);
document.addEventListener('scroll', schedule, { capture: true, passive: true, signal });
// The keyboard comes in: it shows up
root.addEventListener('focusin', () => root.classList.remove('is-hidden'), { signal });
signal.addEventListener('abort', () => {
cancelAnimationFrame(frame);
spot.remove();
}, { once: true });
}
// ---------- The breakpoint, and everything that follows the width ----------
function fit() {
compact = root.clientWidth < lengthOf(root, '--kz-header-grid-breakpoint', 900);
root.classList.toggle('is-compact', compact);
// No Menu button left on screen: the shutter goes with it
if (open && toggle && !toggle.getClientRects().length) panel.hidePopover();
if (line) {
at = over || current();
go(at, true);
}
reveal();
if (!root.classList.contains('is-tight')) shape();
}
const watch = new ResizeObserver(() => fit());
watch.observe(root);
signal.addEventListener('abort', () => watch.disconnect());
fit();
read(); // already down the page (a reload, a link to an anchor): in place from the first frame
// ---------- The shutter (data-mobile="menu") ----------
const list = root.querySelector(c('list'));
if (!toggle || !panel || !list || !('popover' in panel)) return;
toggle.popoverTargetElement = panel;
// The menu is written from the bar: its links, then its notes and buttons under them
links.forEach((link, i) => {
const item = document.createElement('a');
item.className = 'kodza-header-grid__item';
['href', 'target', 'rel', 'aria-current'].forEach((name) => {
if (link.hasAttribute(name)) item.setAttribute(name, link.getAttribute(name));
});
const index = document.createElement('span');
index.className = 'kodza-header-grid__index';
index.setAttribute('aria-hidden', 'true');
index.textContent = String(i + 1).padStart(2, '0');
const mask = document.createElement('span');
mask.className = 'kodza-header-grid__mask';
const word = document.createElement('span');
word.className = 'kodza-header-grid__word';
word.textContent = link.textContent.trim();
mask.append(word);
item.append(index, mask);
list.append(item);
});
const foot = panel.querySelector(c('foot'));
if (foot) {
root.querySelectorAll(`${c('actions')} > ${c('note')}, ${c('actions')} > ${c('button')}`).forEach((el) => {
const copy = el.cloneNode(true);
copy.className = el.matches(c('note')) ? 'kodza-header-grid__fact' : 'kodza-header-grid__action';
foot.append(copy);
});
}
const words = [...toggle.querySelectorAll(c('say'))];
const parts = [...panel.querySelectorAll(`${c('item')}, ${c('foot')}`)];
let started = 0;
let held = null;
const rolls = new Map();
// The shutter starts under the header, wherever it is (stuck, tightened)
const place = () => {
const edge = Math.max(root.getBoundingClientRect().bottom, toggle.getBoundingClientRect().bottom);
panel.style.setProperty('--kz-header-grid-from', `${Math.max(0, Math.round(edge))}px`);
};
// Each row starts when the edge of the shutter gets to it (the delays are set at the site's pace)
const time = () => {
const style = getComputedStyle(root);
const raw = style.getPropertyValue('--kz-header-grid-menu-duration').trim();
const duration = still ? 0 : (parseFloat(raw) * (/ms$/.test(raw) ? 1 : 1000) || 800) * pace;
const match = style.getPropertyValue('--kz-header-grid-menu-ease').match(/cubic-bezier\(([^)]+)\)/);
const curve = match ? match[1].split(',').map(Number) : [0.76, 0, 0.24, 1];
const box = panel.getBoundingClientRect();
const late = performance.now() - started;
const when = (y) => {
const p = Math.min(1, Math.max(0, (y - box.top) / (box.height || 1)));
return `${Math.round(Math.max(0, duration * timeAt(curve, p) - late - 40 * pace))}ms`;
};
parts.forEach((part) => {
part.style.setProperty('--kz-header-grid-delay', when(part.getBoundingClientRect().top));
});
// The line under the last row too
const last = list.lastElementChild;
if (last) list.style.setProperty('--kz-header-grid-delay-end', when(last.getBoundingClientRect().bottom));
};
// Menu rolls to Close and back the same way: the old word goes up, the new one comes from below.
// A click during the roll turns it back from where the words are
const roll = () => {
if (still || words.length < 2) return;
words.forEach((word, i) => {
const coming = i === Number(open);
const from = rolls.has(word) ? getComputedStyle(word).translate : coming ? '0 115%' : '0 0';
rolls.get(word)?.cancel();
const run = word.animate(
{ translate: [from, coming ? '0 0' : '0 -115%'] },
{ duration: ROLL * pace, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' },
);
rolls.set(word, run);
run.finished.then(() => rolls.get(word) === run && rolls.delete(word), () => {});
});
};
// While it is open the page stays where it is
const hold = () => {
held = new AbortController();
const s = held.signal;
const where = [scrollX, scrollY];
let last = 0;
document.addEventListener('wheel', (event) => {
if (!scrollsInside(panel, event.target, event.clientX, event.clientY, event.deltaY)) event.preventDefault();
}, { passive: false, signal: s });
document.addEventListener('touchstart', (event) => (last = event.touches[0].clientY), { passive: true, signal: s });
document.addEventListener('touchmove', (event) => {
const touch = event.touches[0];
const dy = last - touch.clientY;
last = touch.clientY;
if (!scrollsInside(panel, event.target, touch.clientX, touch.clientY, dy)) event.preventDefault();
}, { passive: false, signal: s });
// A scrollbar dragged anyway: the page goes back to where it was
window.addEventListener('scroll', () => {
if (scrollX !== where[0] || scrollY !== where[1]) window.scrollTo({ left: where[0], top: where[1], behavior: 'instant' });
}, { signal: s });
window.addEventListener('resize', () => place(), { signal: s });
};
const release = () => {
held?.abort();
held = null;
};
panel.addEventListener('beforetoggle', (event) => {
open = event.newState === 'open';
toggle.setAttribute('aria-expanded', String(open));
words.forEach((word, i) => (i === Number(open) ? word.removeAttribute('aria-hidden') : word.setAttribute('aria-hidden', 'true')));
roll();
if (open) {
root.classList.remove('is-hidden');
started = performance.now();
place();
hold();
} else {
panel.classList.remove('is-open');
release();
}
}, { signal });
panel.addEventListener('toggle', (event) => {
if (event.newState !== 'open' || !open) return;
time();
panel.classList.add('is-open');
}, { signal });
// Tab past the last link, or a link followed: the shutter rolls back up
panel.addEventListener('focusout', (event) => {
const next = event.relatedTarget;
if (open && next && !panel.contains(next) && next !== toggle) panel.hidePopover();
}, { signal });
panel.addEventListener('click', (event) => {
if (open && event.target.closest('a[href]')) panel.hidePopover();
}, { signal });
signal.addEventListener('abort', () => {
rolls.forEach((run) => run.cancel());
release();
if (open) panel.hidePopover();
});
}
// 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. Each running root
// keeps its markup as written under KODZA, shared by every Kodza script on the page: a Kodza
// component inside another one is saved as written, not half-started, and stopped with it. Two
// components on one root (a reveal on a stat card) both start, and stop together.
const KODZA = Symbol.for('kodza');
const RUNNING = Symbol.for(`kodza ${SELECTOR}`); // a root this component runs on, for every copy of this script
const instances = new WeakMap();
const find = (scope = document) => [scope, ...scope.querySelectorAll(SELECTOR)].filter((el) => el.matches?.(SELECTOR));
const attrsOf = (el) => [...el.attributes].map((a) => [a.name, a.value]);
const put = (el, saved) => {
[...el.attributes].forEach((a) => el.removeAttribute(a.name));
saved.attrs.forEach(([name, value]) => el.setAttribute(name, value));
el.innerHTML = saved.html;
};
// The markup inside a root as written: Kodza components already running in it give back their own
const written = (root) => {
const all = [...root.querySelectorAll('*')];
if (!all.some((el) => el[KODZA])) return root.innerHTML;
const copy = root.cloneNode(true);
const twins = copy.querySelectorAll('*');
let last = null;
all.forEach((el, i) => {
if (!el[KODZA] || last?.contains(el)) return;
last = el;
put(twins[i], el[KODZA]);
});
return copy.innerHTML;
};
const init = (scope) => find(scope).forEach((root) => {
// Already running, maybe from another copy of this script. Marked is-ready with no Kodza component
// running on it, a root is a copy of a started one (a cloned slide): it is left as it is
if (root[RUNNING] || (root.classList.contains('is-ready') && !root[KODZA])) return;
// Rendered by React (data-kodza-manual): React starts it, with an init aimed at that markup once
// it has hydrated it. Any earlier start would change markup React has not taken over yet
const owner = root.closest('[data-kodza-manual]');
if (owner && !(scope && owner.contains(scope))) return;
const controller = new AbortController();
// Another Kodza component already running on this root: its markup as written is kept, and the
// two stop together
const before = root[KODZA];
const saved = { html: before ? before.html : written(root), attrs: before ? before.attrs : attrsOf(root), stop: () => destroy(root) };
if (before) before.above = saved;
instances.set(root, { controller, saved, before });
root[KODZA] = saved;
root[RUNNING] = true;
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.saved.above?.stop(); // a Kodza component started after it on the same root stops first
// The Kodza components running inside it stop first: once its markup is put back, their own
// script could not find them any more
root.querySelectorAll('*').forEach((el) => el[KODZA]?.stop());
instance.controller.abort();
delete root[RUNNING];
// Started on top of another one: that one stops next, and puts the markup back
if (instance.before) {
delete instance.before.above;
root[KODZA] = instance.before;
return instance.before.stop();
}
delete root[KODZA];
put(root, instance.saved);
});
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 — Header Grid, for React. From https://kodza.dev/components/header-grid/
// 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: header-grid.css and header-grid.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.
// data-kodza-manual keeps the script from starting it on its own: the effect below does, once
// React has hydrated the markup (on a page that streams in, the script can load first).
// Built from your data (tabs, rows, options)? Put what the markup is made of in the key, as plain
// values (ids, text, numbers): a change starts the script again on fresh markup. What the visitor
// changes (the open tab, the sort, what they type) is the script's: keep it out of the key, follow
// it with onClick or onInput on the root, and change it from your code as a visitor would (click
// the tab; set the field's value, then dispatch a bubbling input event).
import { useRef, useEffect, type ComponentPropsWithoutRef, type ComponentRef, type CSSProperties } from 'react';
import './header-grid.css';
type Variant = "cells" | "hide" | "line" | "tighten";
export type HeaderGridProps = Omit<ComponentPropsWithoutRef<'header'>, "children" | "variant" | "vars" | "mobile"> & {
/** Modifier class(es): .kodza-header-grid--cells, .kodza-header-grid--hide, .kodza-header-grid--line, .kodza-header-grid--tighten */
variant?: Variant | Variant[];
/** data-mobile on the root (default: menu) */
mobile?: "menu" | "wrap" | "scroll";
/** Settings, as CSS variables on the root */
vars?: {
/** give it your page colour once it sticks — default: transparent */
"--kz-header-grid-bg"?: string | number;
/** the brand, the page you are on, the link you point at — default: #0a0a0a */
"--kz-header-grid-fg"?: string | number;
/** the notes — default: rgba(10, 10, 10, 0.62) */
"--kz-header-grid-muted"?: string | number;
/** the other links — default: var(--kz-header-grid-muted) */
"--kz-header-grid-link"?: string | number;
/** the rules under the cells — default: var(--kz-header-grid-fg) */
"--kz-header-grid-rule-color"?: string | number;
/** the buttons — default: #0a0a0a */
"--kz-header-grid-ink"?: string | number;
/** default: #ffffff */
"--kz-header-grid-on-ink"?: string | number;
/** the Menu button — default: rgba(10, 10, 10, 0.06) */
"--kz-header-grid-soft"?: string | number;
/** an auto column centres what it holds — default: minmax(0, 1fr) auto minmax(0, 1fr) */
"--kz-header-grid-columns"?: string | number;
/** between the columns — default: 16px */
"--kz-header-grid-gap"?: string | number;
/** default: 1 */
"--kz-header-grid-brand-column"?: string | number;
/** default: 2 */
"--kz-header-grid-nav-column"?: string | number;
/** default: 3 */
"--kz-header-grid-actions-column"?: string | number;
/** end sets every zone on the bottom edge — default: center */
"--kz-header-grid-align"?: string | number;
/** the row, centred; none = the full width — default: 1200px */
"--kz-header-grid-width"?: string | number;
/** on both sides — default: clamp(16px, 4cqw, 24px) */
"--kz-header-grid-pad"?: string | number;
/** above and below (two values: above, then below) — default: 0px */
"--kz-header-grid-pad-block"?: string | number;
/** at least — default: 72px */
"--kz-header-grid-height"?: string | number;
/** default: 18px */
"--kz-header-grid-brand-size"?: string | number;
/** the line under it, and under the link you point at — default: 1.5px */
"--kz-header-grid-mark"?: string | number;
/** with --cells, the rule under every cell; 0 = none — default: 0px */
"--kz-header-grid-rule"?: string | number;
/** sticky keeps it at the top — default: static */
"--kz-header-grid-position"?: string | number;
/** where it sticks: under the WordPress admin bar — default: var(--wp-admin--admin-bar--height, 0px) */
"--kz-header-grid-top"?: string | number;
/** below it, data-mobile takes over — default: 900px */
"--kz-header-grid-breakpoint"?: string | number;
/** --line: the line gliding — default: 0.42s */
"--kz-header-grid-duration"?: string | number;
/** how long its back edge waits — default: 0.08s */
"--kz-header-grid-lag"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-header-grid-ease"?: string | number;
/** --hide: how far you read before it goes — default: 600px */
"--kz-header-grid-hide-after"?: string | number;
/** --tighten: the height the bar loses — default: 16px */
"--kz-header-grid-tighten"?: string | number;
/** how far you scroll before it tightens — default: 40px */
"--kz-header-grid-tighten-after"?: string | number;
/** the brand's size once tight; 1 = it stays — default: 0.86 */
"--kz-header-grid-shrink"?: string | number;
/** default: #0a0a0a */
"--kz-header-grid-menu-bg"?: string | number;
/** default: #f2f2f0 */
"--kz-header-grid-menu-fg"?: string | number;
/** default: rgba(242, 242, 240, 0.62) */
"--kz-header-grid-menu-muted"?: string | number;
/** default: rgba(242, 242, 240, 0.16) */
"--kz-header-grid-menu-line"?: string | number;
/** default: 24px */
"--kz-header-grid-menu-side"?: string | number;
/** default: calc(clamp(34px, min(10cqw, calc((100cqh - 240px) / 6)), 96px) * var(--kz-font-display-scale, 1)) */
"--kz-header-grid-menu-size"?: string | number;
/** default: 0.8s */
"--kz-header-grid-menu-duration"?: string | number;
/** default: cubic-bezier(0.76, 0, 0.24, 1) */
"--kz-header-grid-menu-ease"?: string | number;
};
className?: string;
};
export function HeaderGrid({ variant, mobile = "menu", vars, className, style, ...rest }: HeaderGridProps) {
const ref = useRef<ComponentRef<'header'>>(null);
const classes = ["kodza-header-grid", ...([] as string[]).concat(variant ?? []).map((v) => `kodza-header-grid--${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, mobile, 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('./header-grid.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 (
<header key={key} {...rest} className={classes} data-kodza-manual data-mobile={mobile} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<div className="kodza-header-grid__row">
<a className="kodza-header-grid__brand" href="#" aria-label="Kiln, home">
<svg viewBox="0 0 20 20" width="20" height="20" aria-hidden="true">
<path d="M1 1h8v8H1zM11 1h8v8h-8zM1 11h8v8H1z" />
<circle cx="15" cy="15" r="4" />
</svg>
<span>kiln</span>
</a>
<nav className="kodza-header-grid__nav" aria-label="Main">
<ul className="kodza-header-grid__links">
<li>
<a className="kodza-header-grid__link" href="#" aria-current="page">Work</a>
</li>
<li>
<a className="kodza-header-grid__link" href="#">Services</a>
</li>
<li>
<a className="kodza-header-grid__link" href="#">Studio</a>
</li>
<li>
<a className="kodza-header-grid__link" href="#">Journal</a>
</li>
<li>
<a className="kodza-header-grid__link" href="#">Contact</a>
</li>
</ul>
</nav>
<div className="kodza-header-grid__actions">
<a className="kodza-header-grid__button" href="#">Start a project</a>
{' '}
<button className="kodza-header-grid__toggle" type="button" aria-expanded="false">
<span className="kodza-header-grid__lines" aria-hidden="true" />
<span className="kodza-header-grid__label">
<span className="kodza-header-grid__say">Menu</span>
<span className="kodza-header-grid__say" aria-hidden="true">Close</span>
</span>
</button>
<div className="kodza-header-grid__panel" popover="" aria-label="Menu">
<nav className="kodza-header-grid__list" aria-label="Main" />
<div className="kodza-header-grid__foot" />
</div>
</div>
</div>
</header>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.