Expanding Panels
Full-height photo panels side by side: the one you hover, focus or tap widens as the others close in, its spine title slides away and rises upright with a line of text and a link. Stacked when narrow.
View codeTiles whose caption, or a second photo, slides in from the edge the pointer comes from and leaves by the edge it goes out. The keyboard opens it from the bottom; on touch the caption sits under the photo.
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 Direction-Aware Hover to my page.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<div class="kodza-direction-hover" style="--kz-direction-hover-ratio: 4 / 3; --kz-direction-hover-columns: 3; --kz-direction-hover-columns-narrow: 1">
<ul class="kodza-direction-hover__grid">
<li class="kodza-direction-hover__item">
<a class="kodza-direction-hover__link" href="#">
<span class="kodza-direction-hover__media">
<img class="kodza-direction-hover__image" src="https://kodza.dev/media/kz-overflow-1400.webp" width="1400" height="1400" alt="A two-storey concrete house, its upper floor cantilevered over the ground" loading="lazy">
</span>
<span class="kodza-direction-hover__caption">
<span class="kodza-direction-hover__top"><span class="kodza-direction-hover__meta">House · 2025</span><svg class="kodza-direction-hover__arrow" viewBox="0 0 16 16" aria-hidden="true"><path d="M4.5 11.5l7-7M5.5 4.5h6v6" /></svg></span>
<span class="kodza-direction-hover__words"><span class="kodza-direction-hover__title">Falaise House</span><span class="kodza-direction-hover__place">Étretat</span></span>
</span>
</a>
</li>
<li class="kodza-direction-hover__item">
<a class="kodza-direction-hover__link" href="#">
<span class="kodza-direction-hover__media">
<img class="kodza-direction-hover__image" src="https://kodza.dev/media/kz-steps-1400.webp" width="1400" height="933" alt="Wide concrete steps climbing a slope, each one casting a sharp shadow" loading="lazy">
</span>
<span class="kodza-direction-hover__caption">
<span class="kodza-direction-hover__top"><span class="kodza-direction-hover__meta">Public space · 2024</span><svg class="kodza-direction-hover__arrow" viewBox="0 0 16 16" aria-hidden="true"><path d="M4.5 11.5l7-7M5.5 4.5h6v6" /></svg></span>
<span class="kodza-direction-hover__words"><span class="kodza-direction-hover__title">The Steps</span><span class="kodza-direction-hover__place">Le Havre</span></span>
</span>
</a>
</li>
<li class="kodza-direction-hover__item">
<a class="kodza-direction-hover__link" href="#">
<span class="kodza-direction-hover__media">
<img class="kodza-direction-hover__image" src="https://kodza.dev/media/kz-float-1400.webp" width="1400" height="933" alt="A flat concrete pavilion that seems to float above a pool of still water" loading="lazy">
</span>
<span class="kodza-direction-hover__caption">
<span class="kodza-direction-hover__top"><span class="kodza-direction-hover__meta">Pavilion · 2024</span><svg class="kodza-direction-hover__arrow" viewBox="0 0 16 16" aria-hidden="true"><path d="M4.5 11.5l7-7M5.5 4.5h6v6" /></svg></span>
<span class="kodza-direction-hover__words"><span class="kodza-direction-hover__title">Low Water</span><span class="kodza-direction-hover__place">Honfleur</span></span>
</span>
</a>
</li>
</ul>
</div>
<div class="kodza-direction-hover">
<ul class="kodza-direction-hover__grid">
<li class="kodza-direction-hover__item">
<a class="kodza-direction-hover__link" href="#">
<span class="kodza-direction-hover__media">
<img class="kodza-direction-hover__image" src="https://kodza.dev/media/kz-arch-1400.webp" width="1400" height="1400" alt="Print of a concrete wall with a round-topped opening" loading="lazy">
<img class="kodza-direction-hover__photo" src="https://kodza.dev/media/kz-arch-dark-1400.webp" width="1400" height="1400" alt="" loading="lazy">
</span>
<span class="kodza-direction-hover__info"><span class="kodza-direction-hover__name">Arch</span><span class="kodza-direction-hover__price">€48</span><span class="kodza-direction-hover__note">Day and night print, 40 × 50 cm</span></span>
</a>
</li>
<li class="kodza-direction-hover__item">
<a class="kodza-direction-hover__link" href="#">
<span class="kodza-direction-hover__media">
<img class="kodza-direction-hover__image" src="https://kodza.dev/media/kz-columns-1400.webp" width="1120" height="1400" alt="Print of a row of square concrete pillars under a flat roof" loading="lazy">
<img class="kodza-direction-hover__photo" src="https://kodza.dev/media/kz-columns-dark-1400.webp" width="1120" height="1400" alt="" loading="lazy">
</span>
<span class="kodza-direction-hover__info"><span class="kodza-direction-hover__name">Columns</span><span class="kodza-direction-hover__price">€48</span><span class="kodza-direction-hover__note">Day and night print, 40 × 50 cm</span></span>
</a>
</li>
<li class="kodza-direction-hover__item">
<a class="kodza-direction-hover__link" href="#">
<span class="kodza-direction-hover__media">
<img class="kodza-direction-hover__image" src="https://kodza.dev/media/kz-block-1400.webp" width="1120" height="1400" alt="Print of a tall concrete slab standing alone, its long shadow stretching forward" loading="lazy">
<img class="kodza-direction-hover__photo" src="https://kodza.dev/media/kz-block-dark-1400.webp" width="1120" height="1400" alt="" loading="lazy">
</span>
<span class="kodza-direction-hover__info"><span class="kodza-direction-hover__name">Block</span><span class="kodza-direction-hover__price">€36</span><span class="kodza-direction-hover__note">Day and night print, 30 × 40 cm</span></span>
</a>
</li>
<li class="kodza-direction-hover__item">
<a class="kodza-direction-hover__link" href="#">
<span class="kodza-direction-hover__media">
<img class="kodza-direction-hover__image" src="https://kodza.dev/media/kz-table-1400.webp" width="1400" height="1050" alt="Print of a white table set with two glasses, facing a misty sea" loading="lazy">
<img class="kodza-direction-hover__photo" src="https://kodza.dev/media/kz-table-dark-1400.webp" width="1400" height="1050" alt="" loading="lazy">
</span>
<span class="kodza-direction-hover__info"><span class="kodza-direction-hover__name">Table</span><span class="kodza-direction-hover__price">€42</span><span class="kodza-direction-hover__note">Day and night print, 40 × 50 cm</span></span>
</a>
</li>
</ul>
</div>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Direction-Aware Hover
A grid of tiles where the caption, or a second photo, comes in from the side the pointer
comes from and leaves by the side it goes out: the small script reads the side, a clip-path
does the rest. The keyboard opens it from the bottom; on a touch screen the caption sits under
the photo and nothing sticks. Everything is tuned with the --kz-direction-hover-* variables. */
.kodza-direction-hover {
--kz-direction-hover-columns: 4;
--kz-direction-hover-columns-narrow: 2; /* when the grid is narrower than 640px */
--kz-direction-hover-ratio: 4 / 5;
--kz-direction-hover-gap: 28px 16px;
--kz-direction-hover-radius: calc(12px * var(--kz-radius-scale, 1));
--kz-direction-hover-duration: 0.5s;
--kz-direction-hover-ease: cubic-bezier(0.22, 1, 0.36, 1);
--kz-direction-hover-ink: #0a0a0a;
--kz-direction-hover-muted: rgba(10, 10, 10, 0.62);
--kz-direction-hover-placeholder: rgba(10, 10, 10, 0.06); /* under a photo while it loads */
/* The caption laid over the photo */
--kz-direction-hover-veil: rgba(10, 10, 10, 0.64);
--kz-direction-hover-paper: #ffffff;
--kz-direction-hover-faint: rgba(255, 255, 255, 0.86);
--kz-direction-hover-mono: var(--kz-font-mono, "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace);
container: kodza-direction-hover / inline-size;
display: block;
width: 100%;
margin: 0;
color: var(--kz-direction-hover-ink);
}
.kodza-direction-hover__grid {
display: grid;
grid-template-columns: repeat(var(--kz-direction-hover-columns), minmax(0, 1fr));
gap: var(--kz-direction-hover-gap);
margin: 0;
padding: 0;
list-style: none;
}
@container kodza-direction-hover (max-width: 640px) {
.kodza-direction-hover__grid {
grid-template-columns: repeat(var(--kz-direction-hover-columns-narrow), minmax(0, 1fr));
}
/* A narrow column: the place goes under the title instead of squeezing it onto two lines */
.kodza-direction-hover__words {
flex-direction: column;
align-items: flex-start;
gap: 2px;
}
}
.kodza-direction-hover__item {
min-width: 0;
margin: 0;
padding: 0;
}
.kodza-direction-hover__link {
/* How far the words and the second photo travel in from the edge (set by data-edge, below) */
--kz-direction-hover-shift: 0 14px;
--kz-direction-hover-drift: 0 8%;
position: relative;
display: block;
border-radius: var(--kz-direction-hover-radius);
color: inherit;
text-decoration: none;
-webkit-tap-highlight-color: transparent;
}
.kodza-direction-hover__link:focus-visible {
outline: 2px solid currentColor;
outline-offset: 4px;
}
/* The photo: a real image, cropped by its frame, never stretched */
.kodza-direction-hover__media {
position: relative;
display: block;
aspect-ratio: var(--kz-direction-hover-ratio);
overflow: clip;
border-radius: var(--kz-direction-hover-radius);
background: var(--kz-direction-hover-placeholder);
}
.kodza-direction-hover__image,
.kodza-direction-hover__photo {
display: block;
width: 100%;
max-width: none;
height: 100%;
margin: 0;
object-fit: cover;
}
/* The second photo only shows under a pointer: on a touch screen it is never even loaded */
.kodza-direction-hover__photo {
position: absolute;
inset: 0;
display: none;
}
/* The caption, under the photo on a touch screen: title and place, then the meta */
.kodza-direction-hover__caption {
display: flex;
flex-direction: column-reverse;
gap: 4px;
margin-top: 12px;
}
.kodza-direction-hover__top,
.kodza-direction-hover__words {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
min-width: 0;
}
.kodza-direction-hover__meta {
font-family: var(--kz-font-label, var(--kz-direction-hover-mono));
font-size: var(--kz-font-label-size, 11.5px);
font-weight: var(--kz-font-label-weight, 500);
letter-spacing: var(--kz-font-label-tracking, 0.04em);
text-transform: var(--kz-font-label-case, uppercase);
line-height: var(--kz-font-label-leading, 16px);
color: var(--kz-direction-hover-muted);
}
.kodza-direction-hover__arrow {
display: none;
flex: none;
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
}
.kodza-direction-hover__title {
min-width: 0;
font-family: var(--kz-font-display, inherit);
font-size: calc(17px * var(--kz-font-display-scale, 1));
font-weight: var(--kz-font-display-weight, 500);
letter-spacing: var(--kz-font-display-tracking, -0.02em);
text-transform: var(--kz-font-display-case, none);
line-height: 1.2;
}
.kodza-direction-hover__place {
flex: none;
font-size: 13.5px;
line-height: 1.3;
color: var(--kz-direction-hover-muted);
}
/* The product: name and price, then a line about it */
.kodza-direction-hover__info {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 2px 12px;
margin-top: 12px;
}
.kodza-direction-hover__name,
.kodza-direction-hover__price {
font-size: 15px;
font-weight: 500;
line-height: 1.35;
}
.kodza-direction-hover__price {
font-variant-numeric: tabular-nums;
}
.kodza-direction-hover__note {
grid-column: 1 / -1;
font-size: 13.5px;
line-height: 1.4;
color: var(--kz-direction-hover-muted);
}
/* With a pointer: the caption covers the photo, and both covers wait, folded on an edge */
@media (hover: hover) {
.kodza-direction-hover__caption {
position: absolute;
inset: 0;
box-sizing: border-box;
flex-direction: column;
justify-content: space-between;
gap: 0;
margin: 0;
padding: 16px 18px 17px;
border-radius: var(--kz-direction-hover-radius);
background: var(--kz-direction-hover-veil);
color: var(--kz-direction-hover-paper);
}
.kodza-direction-hover__caption .kodza-direction-hover__meta,
.kodza-direction-hover__caption .kodza-direction-hover__place {
color: var(--kz-direction-hover-faint);
}
.kodza-direction-hover__caption .kodza-direction-hover__words {
flex-direction: column;
align-items: flex-start;
gap: 3px;
}
.kodza-direction-hover__caption .kodza-direction-hover__title {
font-size: calc(20px * var(--kz-font-display-scale, 1));
}
.kodza-direction-hover__arrow {
display: block;
}
.kodza-direction-hover__photo {
display: block;
}
/* Folded on the bottom edge by default; the script folds it on the side the pointer uses */
.kodza-direction-hover__caption,
.kodza-direction-hover__photo {
clip-path: inset(100% 0 0 0);
transition:
clip-path calc(var(--kz-direction-hover-duration) * var(--kz-motion-scale, 1)) var(--kz-direction-hover-ease),
translate calc(var(--kz-direction-hover-duration) * var(--kz-motion-scale, 1)) var(--kz-direction-hover-ease);
}
.kodza-direction-hover__link[data-edge="top"] {
--kz-direction-hover-shift: 0 -14px;
--kz-direction-hover-drift: 0 -8%;
}
.kodza-direction-hover__link[data-edge="top"] .kodza-direction-hover__caption,
.kodza-direction-hover__link[data-edge="top"] .kodza-direction-hover__photo {
clip-path: inset(0 0 100% 0);
}
.kodza-direction-hover__link[data-edge="right"] {
--kz-direction-hover-shift: 14px 0;
--kz-direction-hover-drift: 8% 0;
}
.kodza-direction-hover__link[data-edge="right"] .kodza-direction-hover__caption,
.kodza-direction-hover__link[data-edge="right"] .kodza-direction-hover__photo {
clip-path: inset(0 0 0 100%);
}
.kodza-direction-hover__link[data-edge="left"] {
--kz-direction-hover-shift: -14px 0;
--kz-direction-hover-drift: -8% 0;
}
.kodza-direction-hover__link[data-edge="left"] .kodza-direction-hover__caption,
.kodza-direction-hover__link[data-edge="left"] .kodza-direction-hover__photo {
clip-path: inset(0 100% 0 0);
}
/* The words and the second photo come in from that edge too, a little behind the cover */
.kodza-direction-hover__photo {
translate: var(--kz-direction-hover-drift);
}
.kodza-direction-hover__top,
.kodza-direction-hover__words {
translate: var(--kz-direction-hover-shift);
transition: translate calc(var(--kz-direction-hover-duration) * var(--kz-motion-scale, 1)) var(--kz-direction-hover-ease);
}
/* Open: under the pointer or the keyboard focus (and on hover alone, without the script) */
.kodza-direction-hover__link.is-open .kodza-direction-hover__caption,
.kodza-direction-hover__link.is-open .kodza-direction-hover__photo,
.kodza-direction-hover:not(.is-ready) .kodza-direction-hover__link:is(:hover, :focus-visible) .kodza-direction-hover__caption,
.kodza-direction-hover:not(.is-ready) .kodza-direction-hover__link:is(:hover, :focus-visible) .kodza-direction-hover__photo {
clip-path: inset(0);
translate: none;
}
.kodza-direction-hover__link.is-open .kodza-direction-hover__top,
.kodza-direction-hover__link.is-open .kodza-direction-hover__words,
.kodza-direction-hover:not(.is-ready) .kodza-direction-hover__link:is(:hover, :focus-visible) .kodza-direction-hover__top,
.kodza-direction-hover:not(.is-ready) .kodza-direction-hover__link:is(:hover, :focus-visible) .kodza-direction-hover__words {
translate: none;
}
/* Folded on a new edge in one go: it is hidden either way */
.kodza-direction-hover__link.is-placing .kodza-direction-hover__caption,
.kodza-direction-hover__link.is-placing .kodza-direction-hover__photo,
.kodza-direction-hover__link.is-placing .kodza-direction-hover__top,
.kodza-direction-hover__link.is-placing .kodza-direction-hover__words {
transition: none;
}
}
@media (prefers-reduced-motion: reduce) {
.kodza-direction-hover *,
.kodza-direction-hover *::before,
.kodza-direction-hover *::after {
transition-duration: 0.01ms !important;
}
}
/* kodza-guard 6821fab8e2f4 — 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-direction-hover__grid):not(#_),
:where(.kodza-direction-hover__item):not(#_),
:where(.kodza-direction-hover__image):not(#_),
:where(.kodza-direction-hover__arrow):not(#_) {
transition-duration: revert;
}
:where(.kodza-direction-hover__link):not(#_) {
outline: revert;
outline-offset: revert;
transition-duration: revert;
}
:where(.kodza-direction-hover__photo):not(#_) {
transition: revert;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-direction-hover:not(#_),
.kodza-direction-hover__media:not(#_),
.kodza-direction-hover__top:not(#_),
.kodza-direction-hover__meta:not(#_),
.kodza-direction-hover__words:not(#_),
.kodza-direction-hover__title:not(#_),
.kodza-direction-hover__place:not(#_),
.kodza-direction-hover__info:not(#_),
.kodza-direction-hover__name:not(#_),
.kodza-direction-hover__price:not(#_),
.kodza-direction-hover__note:not(#_) {
box-sizing: revert;
}
.kodza-direction-hover__grid:not(#_) {
border: revert;
border-radius: revert;
outline: revert;
text-decoration: 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;
transition-delay: revert;
}
.kodza-direction-hover__item: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;
transition-delay: revert;
}
.kodza-direction-hover__link:not(#_) {
border: revert;
margin: revert;
padding: revert;
list-style: revert;
box-sizing: revert;
float: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-family: revert;
font-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;
transition-delay: revert;
}
.kodza-direction-hover__image:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-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;
transition-delay: revert;
}
.kodza-direction-hover__arrow:not(#_) {
border: revert;
margin: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
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;
transition-delay: revert;
}
.kodza-direction-hover__photo:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-direction-hover__item:not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-direction-hover__item: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-direction-hover__grid:not(#_) {
display: grid;
margin: 0;
padding: 0;
list-style: none;
}
.kodza-direction-hover__item:not(#_) {
margin: 0;
padding: 0;
}
.kodza-direction-hover__link:not(#_) {
position: relative;
display: block;
border-radius: var(--kz-direction-hover-radius);
color: inherit;
text-decoration: none;
}
.kodza-direction-hover__link:focus-visible:not(#_) {
outline: 2px solid currentColor;
outline-offset: 4px;
}
.kodza-direction-hover__image:not(#_),
.kodza-direction-hover__photo:not(#_) {
display: block;
max-width: none;
margin: 0;
}
.kodza-direction-hover__photo:not(#_) {
position: absolute;
display: none;
}
.kodza-direction-hover__arrow:not(#_) {
display: none;
}
@media (hover: hover) {
.kodza-direction-hover__arrow:not(#_) {
display: block;
}
}
@media (hover: hover) {
.kodza-direction-hover__photo:not(#_) {
display: block;
}
}
@media (hover: hover) {
.kodza-direction-hover__photo:not(#_) {
transition: clip-path calc(var(--kz-direction-hover-duration) * var(--kz-motion-scale, 1)) var(--kz-direction-hover-ease),
translate calc(var(--kz-direction-hover-duration) * var(--kz-motion-scale, 1)) var(--kz-direction-hover-ease);
}
}
@media (hover: hover) {
.kodza-direction-hover__link.is-placing .kodza-direction-hover__photo:not(#_) {
transition: none;
}
}
@media (prefers-reduced-motion: reduce) {
.kodza-direction-hover *:where(ul, li, a, img, svg):not(#_) {
transition-duration: 0.01ms !important;
}
}
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — Direction-Aware Hover
// Reads the side of a tile the pointer comes in by, and the side it leaves by, and writes it on
// the tile (data-edge): the CSS uncovers the caption or the second photo from that edge, then
// folds it away toward the edge the pointer goes out. The keyboard focus opens it from the
// bottom. A finger does nothing here: the caption already sits under the photo.
// Everything else (timing, colours, sizes) is in the --kz-direction-hover-* variables.
(() => {
const SELECTOR = '.kodza-direction-hover';
// The edge of the box the pointer is nearest to, in proportion: a wide tile still tells its
// top from its sides. Measured on the whole link, the words under a product included.
const edgeOf = (box, event) => {
const rect = box.getBoundingClientRect();
const x = (event.clientX - rect.left) / rect.width - 0.5;
const y = (event.clientY - rect.top) / rect.height - 0.5;
return Math.abs(x) > Math.abs(y) ? (x < 0 ? 'left' : 'right') : (y < 0 ? 'top' : 'bottom');
};
// One instance, with its own state: two copies on a page never interfere. Everything it
// starts is tied to `signal` (listeners get { signal }), so kodza:destroy stops it all.
function mount(root, signal) {
const canHover = window.matchMedia('(hover: hover)');
const links = [...root.querySelectorAll('.kodza-direction-hover__link')].filter((link) => link.closest(SELECTOR) === root);
links.forEach((link) => {
const covers = [...link.querySelectorAll('.kodza-direction-hover__caption, .kodza-direction-hover__photo')];
const open = () => link.classList.add('is-open');
const close = (edge) => {
link.dataset.edge = edge; // the open state hides the change: it only says where to fold
link.classList.remove('is-open');
};
// Folds the closed cover on the edge it will open from, with no transition (it is hidden
// either way). Still on its way out, it is not moved: it turns back from where it is.
const place = (edge) => {
if (link.dataset.edge === edge || covers.some((el) => el.getAnimations().length)) return;
link.classList.add('is-placing');
link.dataset.edge = edge;
void link.offsetWidth;
link.classList.remove('is-placing');
};
// A mouse or a pen: a finger taps, and the tap follows the link
const pointed = (event) => event.pointerType !== 'touch' && canHover.matches;
link.addEventListener('pointerenter', (event) => {
if (!pointed(event)) return;
place(edgeOf(link, event));
open();
}, { signal });
link.addEventListener('pointerleave', (event) => {
if (!pointed(event) || link.matches(':focus-visible')) return;
close(edgeOf(link, event));
}, { signal });
// From the keyboard only: a click gives the focus too, and the pointer already opened it
link.addEventListener('focus', () => {
if (!link.matches(':focus-visible')) return;
place('bottom');
open();
}, { signal });
link.addEventListener('blur', () => {
if (!link.matches(':hover')) close('bottom');
}, { signal });
});
}
// 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 — Direction-Aware Hover, for React. From https://kodza.dev/components/direction-aware-hover/
// 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: direction-aware-hover.css and direction-aware-hover.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 './direction-aware-hover.css';
export type DirectionAwareHoverProps = Omit<ComponentPropsWithoutRef<'div'>, "children" | "vars"> & {
/** Settings, as CSS variables on the root */
vars?: {
/** default: 4 */
"--kz-direction-hover-columns"?: string | number;
/** when the grid is narrower than 640px — default: 2 */
"--kz-direction-hover-columns-narrow"?: string | number;
/** default: 4 / 5 */
"--kz-direction-hover-ratio"?: string | number;
/** default: 28px 16px */
"--kz-direction-hover-gap"?: string | number;
/** default: calc(12px * var(--kz-radius-scale, 1)) */
"--kz-direction-hover-radius"?: string | number;
/** default: 0.5s */
"--kz-direction-hover-duration"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-direction-hover-ease"?: string | number;
/** default: #0a0a0a */
"--kz-direction-hover-ink"?: string | number;
/** default: rgba(10, 10, 10, 0.62) */
"--kz-direction-hover-muted"?: string | number;
/** under a photo while it loads — default: rgba(10, 10, 10, 0.06) */
"--kz-direction-hover-placeholder"?: string | number;
/** default: rgba(10, 10, 10, 0.64) */
"--kz-direction-hover-veil"?: string | number;
/** default: #ffffff */
"--kz-direction-hover-paper"?: string | number;
/** default: rgba(255, 255, 255, 0.86) */
"--kz-direction-hover-faint"?: string | number;
/** default: var(--kz-font-mono, "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace) */
"--kz-direction-hover-mono"?: string | number;
};
className?: string;
};
export function DirectionAwareHover({ vars, className, style, ...rest }: DirectionAwareHoverProps) {
const ref = useRef<ComponentRef<'div'>>(null);
const classes = ["kodza-direction-hover", 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('./direction-aware-hover.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} data-kodza-manual style={{ "--kz-direction-hover-ratio": "4 / 3", "--kz-direction-hover-columns": "3", "--kz-direction-hover-columns-narrow": "1", ...vars, ...style } as CSSProperties} ref={ref}>
<ul className="kodza-direction-hover__grid">
<li className="kodza-direction-hover__item">
<a className="kodza-direction-hover__link" href="#">
<span className="kodza-direction-hover__media">
<img className="kodza-direction-hover__image" src="https://kodza.dev/media/kz-overflow-1400.webp" width="1400" height="1400" alt="A two-storey concrete house, its upper floor cantilevered over the ground" loading="lazy" />
</span>
{' '}
<span className="kodza-direction-hover__caption">
<span className="kodza-direction-hover__top">
<span className="kodza-direction-hover__meta">House · 2025</span>
<svg className="kodza-direction-hover__arrow" viewBox="0 0 16 16" aria-hidden="true">
<path d="M4.5 11.5l7-7M5.5 4.5h6v6" />
</svg>
</span>
{' '}
<span className="kodza-direction-hover__words">
<span className="kodza-direction-hover__title">Falaise House</span>
<span className="kodza-direction-hover__place">Étretat</span>
</span>
</span>
</a>
</li>
<li className="kodza-direction-hover__item">
<a className="kodza-direction-hover__link" href="#">
<span className="kodza-direction-hover__media">
<img className="kodza-direction-hover__image" src="https://kodza.dev/media/kz-steps-1400.webp" width="1400" height="933" alt="Wide concrete steps climbing a slope, each one casting a sharp shadow" loading="lazy" />
</span>
{' '}
<span className="kodza-direction-hover__caption">
<span className="kodza-direction-hover__top">
<span className="kodza-direction-hover__meta">Public space · 2024</span>
<svg className="kodza-direction-hover__arrow" viewBox="0 0 16 16" aria-hidden="true">
<path d="M4.5 11.5l7-7M5.5 4.5h6v6" />
</svg>
</span>
{' '}
<span className="kodza-direction-hover__words">
<span className="kodza-direction-hover__title">The Steps</span>
<span className="kodza-direction-hover__place">Le Havre</span>
</span>
</span>
</a>
</li>
<li className="kodza-direction-hover__item">
<a className="kodza-direction-hover__link" href="#">
<span className="kodza-direction-hover__media">
<img className="kodza-direction-hover__image" src="https://kodza.dev/media/kz-float-1400.webp" width="1400" height="933" alt="A flat concrete pavilion that seems to float above a pool of still water" loading="lazy" />
</span>
{' '}
<span className="kodza-direction-hover__caption">
<span className="kodza-direction-hover__top">
<span className="kodza-direction-hover__meta">Pavilion · 2024</span>
<svg className="kodza-direction-hover__arrow" viewBox="0 0 16 16" aria-hidden="true">
<path d="M4.5 11.5l7-7M5.5 4.5h6v6" />
</svg>
</span>
{' '}
<span className="kodza-direction-hover__words">
<span className="kodza-direction-hover__title">Low Water</span>
<span className="kodza-direction-hover__place">Honfleur</span>
</span>
</span>
</a>
</li>
</ul>
</div>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.