Direction-Aware Hover
Tiles 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.
View codeA background slideshow behind fixed words: each photo drifts slowly toward its point of interest, and the next is uncovered by a slow curtain in the same direction. A thin line counts the time, with a pause button.
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 Ken Burns Slider to my page.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<section class="kodza-ken-burns-slider" data-interval="6000" aria-label="Maison Rempart">
<ul class="kodza-ken-burns-slider__slides">
<li class="kodza-ken-burns-slider__slide is-current" data-focus="60% 62%">
<img class="kodza-ken-burns-slider__image" src="https://kodza.dev/media/kz-table-1400.webp" srcset="https://kodza.dev/media/kz-table-1400.webp 1400w, https://kodza.dev/media/kz-table-2400.webp 2400w" sizes="(max-width: 760px) 200vw, 100vw" width="1400" height="1050" alt="A white table set with two glasses and a bottle, facing a misty sea with a sailboat" fetchpriority="high">
</li>
<li class="kodza-ken-burns-slider__slide" data-focus="42% 56%">
<img class="kodza-ken-burns-slider__image" src="https://kodza.dev/media/kz-arch-1400.webp" srcset="https://kodza.dev/media/kz-arch-1400.webp 1400w, https://kodza.dev/media/kz-arch-2400.webp 2400w" sizes="(max-width: 760px) 200vw, 100vw" width="1400" height="1400" alt="A concrete wall with a round-topped opening, sunlight pouring through it" loading="lazy">
</li>
<li class="kodza-ken-burns-slider__slide" data-focus="64% 50%">
<img class="kodza-ken-burns-slider__image" src="https://kodza.dev/media/kz-columns-1400.webp" srcset="https://kodza.dev/media/kz-columns-1400.webp 1400w, https://kodza.dev/media/kz-columns-2400.webp 2400w" sizes="(max-width: 760px) 200vw, 100vw" width="1120" height="1400" alt="A row of square concrete pillars under a flat roof, casting striped shadows" loading="lazy">
</li>
<li class="kodza-ken-burns-slider__slide" data-focus="50% 58%">
<img class="kodza-ken-burns-slider__image" src="https://kodza.dev/media/kz-overflow-1400.webp" srcset="https://kodza.dev/media/kz-overflow-1400.webp 1400w, https://kodza.dev/media/kz-overflow-2400.webp 2400w" sizes="(max-width: 760px) 200vw, 100vw" width="1400" height="1400" alt="A two-storey concrete house, its upper floor cantilevered over the ground" loading="lazy">
</li>
</ul>
<div class="kodza-ken-burns-slider__content">
<p class="kodza-ken-burns-slider__label">Maison Rempart · Saint-Malo</p>
<h2 class="kodza-ken-burns-slider__title"><span class="kodza-ken-burns-slider__phrase">Nine rooms.</span> <span class="kodza-ken-burns-slider__phrase">The sea does the rest.</span></h2>
<p class="kodza-ken-burns-slider__text">No spa, no welcome drink, no app to open the door. Thick walls, a good bed and the tide twice a day.</p>
<a class="kodza-ken-burns-slider__button" href="#">Check availability<svg class="kodza-ken-burns-slider__arrow" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8h10M9 4l4 4-4 4" /></svg></a>
</div>
<div class="kodza-ken-burns-slider__controls">
<span class="kodza-ken-burns-slider__count"><span class="kodza-ken-burns-slider__index"><span>01</span></span> / <span class="kodza-ken-burns-slider__total">04</span></span>
<span class="kodza-ken-burns-slider__line" aria-hidden="true"><span class="kodza-ken-burns-slider__fill"></span></span>
<button class="kodza-ken-burns-slider__pause" type="button" aria-pressed="false" aria-label="Pause the slideshow"><svg class="kodza-ken-burns-slider__icon" viewBox="0 0 16 16" aria-hidden="true"><path class="kodza-ken-burns-slider__bars" d="M5.5 4v8M10.5 4v8" /><path class="kodza-ken-burns-slider__play" d="M5.5 3.8v8.4L12 8z" /></svg></button>
</div>
</section>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Ken Burns Slider
A slideshow behind words that stay put, like a hotel's hero: each photo drifts slowly toward its
point of interest (data-focus on the slide), and the next one is uncovered by a slow curtain in
the direction of that drift while the last one is still moving. A thin line counts each photo's
time; the slideshow waits while it is paused, out of view, in a hidden tab or holds the focus.
Everything is tuned with the --kz-ken-burns-slider-* variables (and data-interval, in ms). */
.kodza-ken-burns-slider {
--kz-ken-burns-slider-height: 640px; /* 100svh for a full-screen hero */
--kz-ken-burns-slider-ink: #0a0a0a;
--kz-ken-burns-slider-paper: #ffffff;
--kz-ken-burns-slider-text: rgba(255, 255, 255, 0.9);
--kz-ken-burns-slider-faint: rgba(255, 255, 255, 0.9);
--kz-ken-burns-slider-track: rgba(255, 255, 255, 0.3);
--kz-ken-burns-slider-backdrop: #1c1c1c; /* under the photos while they load */
/* The shade under the words: measured in px from the bottom edge, where the words stand
whatever the height, and a little from the left where they sit */
--kz-ken-burns-slider-shade: linear-gradient(to top, rgba(10, 10, 10, 0.82), rgba(10, 10, 10, 0.66) 300px, rgba(10, 10, 10, 0.54) 460px, rgba(10, 10, 10, 0.16)), linear-gradient(to right, rgba(10, 10, 10, 0.3), rgba(10, 10, 10, 0) 60%);
--kz-ken-burns-slider-pad: clamp(22px, 4.4vw, 56px);
--kz-ken-burns-slider-radius: 0px;
--kz-ken-burns-slider-mono: var(--kz-font-mono, "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace);
--kz-ken-burns-slider-ease: cubic-bezier(0.22, 1, 0.36, 1);
container: kodza-ken-burns-slider / inline-size;
position: relative;
isolation: isolate;
box-sizing: border-box;
/* The words at the bottom left, the controls at the bottom right, under the words when narrow */
display: flex;
flex-wrap: wrap;
align-items: flex-end;
align-content: flex-end;
justify-content: space-between;
gap: 24px 40px;
width: 100%;
min-height: var(--kz-ken-burns-slider-height);
margin: 0;
padding: var(--kz-ken-burns-slider-pad);
overflow: clip;
border-radius: var(--kz-ken-burns-slider-radius);
background: var(--kz-ken-burns-slider-backdrop);
color: var(--kz-ken-burns-slider-paper);
}
/* The photos: stacked in the same box, behind the words */
.kodza-ken-burns-slider__slides {
position: absolute;
z-index: -1;
inset: 0;
margin: 0;
padding: 0;
list-style: none;
}
.kodza-ken-burns-slider__slides::after {
content: "";
position: absolute;
z-index: 3;
inset: 0;
background: var(--kz-ken-burns-slider-shade);
pointer-events: none;
}
.kodza-ken-burns-slider__slide {
position: absolute;
inset: 0;
margin: 0;
padding: 0;
overflow: clip;
visibility: hidden;
}
/* The one on show above the one leaving; the others wait, hidden */
.kodza-ken-burns-slider__slide.is-current {
z-index: 2;
visibility: visible;
}
.kodza-ken-burns-slider__slide.is-leaving {
z-index: 1;
visibility: visible;
}
.kodza-ken-burns-slider__image {
display: block;
width: 100%;
max-width: none;
height: 100%;
margin: 0;
object-fit: cover;
}
/* The words */
.kodza-ken-burns-slider__content {
flex: 0 1 680px;
min-width: 0;
}
.kodza-ken-burns-slider__label {
margin: 0 0 18px;
font-family: var(--kz-font-label, var(--kz-ken-burns-slider-mono));
font-size: var(--kz-font-label-size, 12.5px);
font-weight: var(--kz-font-label-weight, 500);
letter-spacing: var(--kz-font-label-tracking, 0.02em);
text-transform: var(--kz-font-label-case, uppercase);
line-height: var(--kz-font-label-leading, 16px);
color: var(--kz-ken-burns-slider-faint);
}
.kodza-ken-burns-slider__title {
margin: 0;
font-family: var(--kz-font-display, inherit);
font-size: calc(clamp(36px, 5.2vw, 72px) * var(--kz-font-display-scale, 1));
font-weight: var(--kz-font-display-weight, 500);
letter-spacing: var(--kz-font-display-tracking, -0.045em);
text-transform: var(--kz-font-display-case, none);
line-height: 1.02;
color: inherit;
text-wrap: balance;
}
/* One sentence per line, each balanced on its own when it has to wrap */
.kodza-ken-burns-slider__phrase {
display: block;
}
.kodza-ken-burns-slider__text {
max-width: 44ch;
margin: 20px 0 0;
font-size: 16px;
line-height: 1.55;
color: var(--kz-ken-burns-slider-text);
text-wrap: pretty;
}
.kodza-ken-burns-slider__button {
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: 10px;
height: 52px;
margin-top: 28px;
padding: 0 24px 0 26px;
border-radius: var(--kz-radius-pill, 999px);
background: var(--kz-ken-burns-slider-paper);
color: var(--kz-ken-burns-slider-ink);
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 15px);
font-weight: var(--kz-font-label-weight, 500);
letter-spacing: var(--kz-font-label-tracking, inherit);
text-transform: var(--kz-font-label-case, inherit);
line-height: 1;
white-space: nowrap;
text-decoration: none;
}
.kodza-ken-burns-slider__arrow {
flex: none;
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
transition: transform calc(0.25s * var(--kz-motion-scale, 1)) var(--kz-ken-burns-slider-ease);
}
@media (hover: hover) {
.kodza-ken-burns-slider__button:hover .kodza-ken-burns-slider__arrow {
transform: translateX(3px);
}
}
.kodza-ken-burns-slider__button:focus-visible,
.kodza-ken-burns-slider__pause:focus-visible {
outline: 2px solid var(--kz-ken-burns-slider-paper);
outline-offset: 3px;
}
/* Which photo, the time it has left, and the pause button */
.kodza-ken-burns-slider__controls {
flex: none;
display: flex;
align-items: center;
gap: 16px;
font-family: var(--kz-font-label, var(--kz-ken-burns-slider-mono));
font-size: var(--kz-font-label-size, 12.5px);
font-weight: var(--kz-font-label-weight, 500);
letter-spacing: var(--kz-font-label-tracking, inherit);
line-height: 16px;
color: var(--kz-ken-burns-slider-faint);
}
/* Nothing to count or pause without the script, or with a single still photo */
.kodza-ken-burns-slider:not(.is-ready) .kodza-ken-burns-slider__controls,
.kodza-ken-burns-slider.is-still .kodza-ken-burns-slider__controls {
display: none;
}
.kodza-ken-burns-slider__count {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.kodza-ken-burns-slider__index {
display: inline-grid;
overflow: clip;
vertical-align: top;
}
.kodza-ken-burns-slider__index > span {
grid-area: 1 / 1;
}
.kodza-ken-burns-slider__line {
position: relative;
display: block;
width: 88px;
height: 2px;
overflow: clip;
border-radius: var(--kz-radius-pill, 999px);
background: var(--kz-ken-burns-slider-track);
}
.kodza-ken-burns-slider__fill {
position: absolute;
inset: 0;
background: var(--kz-ken-burns-slider-paper);
transform: translateX(-100%);
}
.kodza-ken-burns-slider__pause {
box-sizing: border-box;
display: grid;
place-items: center;
flex: none;
width: 40px;
height: 40px;
margin: 0;
padding: 0;
border: 1px solid var(--kz-ken-burns-slider-track);
border-radius: 50%;
background: rgba(10, 10, 10, 0.2);
color: var(--kz-ken-burns-slider-paper);
cursor: pointer;
transition: border-color calc(0.2s * var(--kz-motion-scale, 1));
}
@media (hover: hover) {
.kodza-ken-burns-slider__pause:hover {
border-color: var(--kz-ken-burns-slider-paper);
}
}
.kodza-ken-burns-slider__icon {
width: 14px;
height: 14px;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
}
.kodza-ken-burns-slider__play {
fill: currentColor;
stroke-width: 1.2;
opacity: 0;
transform: scale(0.6);
transform-origin: 50% 50%;
transition:
opacity calc(0.2s * var(--kz-motion-scale, 1)),
transform calc(0.25s * var(--kz-motion-scale, 1)) var(--kz-ken-burns-slider-ease);
}
.kodza-ken-burns-slider__bars {
transform-origin: 50% 50%;
transition:
opacity calc(0.2s * var(--kz-motion-scale, 1)),
transform calc(0.25s * var(--kz-motion-scale, 1)) var(--kz-ken-burns-slider-ease);
}
.kodza-ken-burns-slider__pause[aria-pressed="true"] .kodza-ken-burns-slider__bars {
opacity: 0;
transform: scale(0.6);
}
.kodza-ken-burns-slider__pause[aria-pressed="true"] .kodza-ken-burns-slider__play {
opacity: 1;
transform: none;
}
/* Narrow: a slightly smaller text */
@container kodza-ken-burns-slider (max-width: 640px) {
.kodza-ken-burns-slider__text {
font-size: 15px;
}
}
@media (prefers-reduced-motion: reduce) {
.kodza-ken-burns-slider *,
.kodza-ken-burns-slider *::before,
.kodza-ken-burns-slider *::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}
/* kodza-guard d2a496d7d49b — 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-ken-burns-slider__slides):not(#_),
:where(.kodza-ken-burns-slider__slide):not(#_),
:where(.kodza-ken-burns-slider__image):not(#_),
:where(.kodza-ken-burns-slider__label):not(#_),
:where(.kodza-ken-burns-slider__title):not(#_),
:where(.kodza-ken-burns-slider__text):not(#_),
:where(.kodza-ken-burns-slider__icon):not(#_) {
transition-duration: revert;
}
:where(.kodza-ken-burns-slider__button):not(#_) {
outline: revert;
outline-offset: revert;
transition-duration: revert;
}
:where(.kodza-ken-burns-slider__pause):not(#_) {
outline: revert;
outline-offset: revert;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-ken-burns-slider__slides:not(#_) {
border: revert;
border-radius: revert;
outline: revert;
text-decoration: 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;
transition-property: revert;
transition-timing-function: revert;
transition-delay: revert;
}
.kodza-ken-burns-slider__slide:not(#_) {
border: 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-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-ken-burns-slider__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-ken-burns-slider__content:not(#_),
.kodza-ken-burns-slider__phrase:not(#_),
.kodza-ken-burns-slider__controls:not(#_),
.kodza-ken-burns-slider__count:not(#_),
.kodza-ken-burns-slider__index:not(#_),
.kodza-ken-burns-slider__total:not(#_),
.kodza-ken-burns-slider__line:not(#_),
.kodza-ken-burns-slider__fill:not(#_),
.kodza-ken-burns-slider__bars:not(#_),
.kodza-ken-burns-slider__play:not(#_),
.kodza-ken-burns-slider__index span:where(:not([class*="kodza-"])):not(#_) {
box-sizing: revert;
}
.kodza-ken-burns-slider__label:not(#_),
.kodza-ken-burns-slider__title: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-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;
transition-delay: revert;
}
.kodza-ken-burns-slider__text: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;
font-family: revert;
font-weight: revert;
font-style: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
transition-property: revert;
transition-timing-function: revert;
transition-delay: revert;
}
.kodza-ken-burns-slider__button:not(#_) {
border: revert;
list-style: revert;
float: revert;
position: revert;
margin-right: revert;
margin-bottom: revert;
margin-left: 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;
transition-delay: revert;
}
.kodza-ken-burns-slider__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;
}
.kodza-ken-burns-slider__pause:not(#_) {
text-decoration: revert;
list-style: revert;
float: revert;
position: revert;
box-shadow: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
white-space: revert;
min-height: revert;
}
.kodza-ken-burns-slider__icon: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-ken-burns-slider__slides:not(#_):where(:not([hidden])),
.kodza-ken-burns-slider__slide:not(#_):where(:not([hidden])),
.kodza-ken-burns-slider__label:not(#_):where(:not([hidden])),
.kodza-ken-burns-slider__title:not(#_):where(:not([hidden])),
.kodza-ken-burns-slider__text:not(#_):where(:not([hidden])),
.kodza-ken-burns-slider__arrow:not(#_):where(:not([hidden])),
.kodza-ken-burns-slider__icon:not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-ken-burns-slider__slides:not(#_):where([hidden]),
.kodza-ken-burns-slider__slide:not(#_):where([hidden]),
.kodza-ken-burns-slider__label:not(#_):where([hidden]),
.kodza-ken-burns-slider__title:not(#_):where([hidden]),
.kodza-ken-burns-slider__text:not(#_):where([hidden]),
.kodza-ken-burns-slider__arrow:not(#_):where([hidden]),
.kodza-ken-burns-slider__icon:not(#_):where([hidden]) {
display: none;
}
.kodza-ken-burns-slider__slides: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-ken-burns-slider__slides:not(#_) {
position: absolute;
margin: 0;
padding: 0;
list-style: none;
}
.kodza-ken-burns-slider__slide:not(#_) {
position: absolute;
margin: 0;
padding: 0;
}
.kodza-ken-burns-slider__image:not(#_) {
display: block;
max-width: none;
margin: 0;
}
.kodza-ken-burns-slider__label:not(#_) {
margin: 0 0 18px;
font-family: var(--kz-font-label, var(--kz-ken-burns-slider-mono));
font-size: var(--kz-font-label-size, 12.5px);
font-weight: var(--kz-font-label-weight, 500);
letter-spacing: var(--kz-font-label-tracking, 0.02em);
text-transform: var(--kz-font-label-case, uppercase);
line-height: var(--kz-font-label-leading, 16px);
color: var(--kz-ken-burns-slider-faint);
}
.kodza-ken-burns-slider__title:not(#_) {
margin: 0;
font-family: var(--kz-font-display, inherit);
font-size: calc(clamp(36px, 5.2vw, 72px) * var(--kz-font-display-scale, 1));
font-weight: var(--kz-font-display-weight, 500);
letter-spacing: var(--kz-font-display-tracking, -0.045em);
text-transform: var(--kz-font-display-case, none);
line-height: 1.02;
color: inherit;
}
.kodza-ken-burns-slider__text:not(#_) {
max-width: 44ch;
margin: 20px 0 0;
font-size: 16px;
line-height: 1.55;
color: var(--kz-ken-burns-slider-text);
}
.kodza-ken-burns-slider__button:not(#_) {
box-sizing: border-box;
display: inline-flex;
margin-top: 28px;
padding: 0 24px 0 26px;
border-radius: var(--kz-radius-pill, 999px);
background: var(--kz-ken-burns-slider-paper);
color: var(--kz-ken-burns-slider-ink);
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 15px);
font-weight: var(--kz-font-label-weight, 500);
letter-spacing: var(--kz-font-label-tracking, inherit);
text-transform: var(--kz-font-label-case, inherit);
line-height: 1;
text-decoration: none;
}
.kodza-ken-burns-slider__arrow:not(#_) {
transition: transform calc(0.25s * var(--kz-motion-scale, 1)) var(--kz-ken-burns-slider-ease);
}
.kodza-ken-burns-slider__button:focus-visible:not(#_) {
outline: 2px solid var(--kz-ken-burns-slider-paper);
outline-offset: 3px;
}
.kodza-ken-burns-slider__pause:focus-visible:not(#_) {
outline: 2px solid var(--kz-ken-burns-slider-paper);
outline-offset: 3px;
}
.kodza-ken-burns-slider__pause:not(#_) {
box-sizing: border-box;
display: grid;
place-items: center;
flex: none;
width: 40px;
height: 40px;
margin: 0;
padding: 0;
border: 1px solid var(--kz-ken-burns-slider-track);
border-radius: 50%;
background: rgba(10, 10, 10, 0.2);
color: var(--kz-ken-burns-slider-paper);
cursor: pointer;
transition: border-color calc(0.2s * var(--kz-motion-scale, 1));
}
@media (hover: hover) {
.kodza-ken-burns-slider__pause:hover:not(#_) {
border-color: var(--kz-ken-burns-slider-paper);
}
}
@container kodza-ken-burns-slider (max-width: 640px) {
.kodza-ken-burns-slider__text:not(#_) {
font-size: 15px;
}
}
@media (prefers-reduced-motion: reduce) {
.kodza-ken-burns-slider *:not(#_),
.kodza-ken-burns-slider *:not(#_)::before,
.kodza-ken-burns-slider *:not(#_)::after {
transition-duration: 0.01ms !important;
animation-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 — Ken Burns Slider
// A slideshow behind words that stay put. Each photo drifts slowly toward its point of interest
// (data-focus="60% 62%" on the slide, in percent of the photo), never far enough to show an edge;
// once its time is up, the next photo is uncovered by a slow curtain travelling the way the last
// one drifts, while that one is still moving and is pushed along. The thin line fills over each
// photo's time and empties as it turns, and the count rolls with it. It all waits while paused,
// out of view, in a hidden tab, or while a link inside holds the keyboard focus. With reduced
// motion, the first photo simply stays. "kodza:replay" starts again from the first photo.
// Tune it with the --kz-ken-burns-slider-* variables in the CSS, data-interval (ms) on the root
// and the constants below.
(() => {
const SELECTOR = '.kodza-ken-burns-slider';
const DWELL = 6000; // ms each photo stays, unless data-interval says otherwise (a wait: the pace leaves it)
const CURTAIN = 1600; // ms, the next photo uncovered while the last one is pushed along
const CURTAIN_EASE = 'cubic-bezier(0.65, 0, 0.35, 1)';
const PUSH = 8; // %, how far the curtain pushes the photo it covers
const ZOOM_FROM = 1.04; // the drift starts at this scale...
const ZOOM_TO = 1.16; // ...and ends at this one, its point of interest pulled toward the middle
const ROLL = 700; // ms, the count rolling to the next number
const ROLL_EASE = 'cubic-bezier(0.22, 1, 0.36, 1)';
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 pad = (n) => String(n).padStart(2, '0');
// A photo ready to show: loaded and decoded (a lazy one is asked for now)
const ready = (img) => {
img.loading = 'eager';
return img.decode().catch(() => {});
};
// The point of interest, from 0 to 1 on each axis (the middle when not given)
const focusOf = (slide) => {
const [x = 50, y = 50] = (slide.dataset.focus || '').split(/\s+/).map(parseFloat).filter((n) => !Number.isNaN(n));
const unit = (n) => Math.min(100, Math.max(0, n)) / 100;
return { x: unit(x), y: unit(y) };
};
// How far the drift moves the photo on one axis, in parts of its size: the point of interest
// toward the middle, as far as the zoom covers. Both bounds hold all along the way, so no edge
// ever shows, and the curtain travels the same way.
const reach = (o) => {
const room = (ZOOM_TO - 1) * 0.96; // a hair inside the edge, for rounding
return Math.min(o * room, Math.max(-(1 - o) * room, 0.5 - o));
};
// The curtain for each way: where the next photo starts uncovered from, and the push of the last
const WAYS = {
left: { from: 'inset(0 0 0 100%)', push: `translate(-${PUSH}%, 0)` },
right: { from: 'inset(0 100% 0 0)', push: `translate(${PUSH}%, 0)` },
up: { from: 'inset(100% 0 0 0)', push: `translate(0, -${PUSH}%)` },
down: { from: 'inset(0 0 100% 0)', push: `translate(0, ${PUSH}%)` },
};
// One instance, with its own state: two copies on a page never interfere. Everything it
// starts is tied to `signal` (listeners get { signal }, observers and timers stop on its
// 'abort' event), so kodza:destroy stops it all.
function mount(root, signal) {
const pace = paceOf(root);
const list = root.querySelector('.kodza-ken-burns-slider__slides');
const slides = list ? [...list.children].filter((el) => el.matches('.kodza-ken-burns-slider__slide')) : [];
const images = slides.map((slide) => slide.querySelector('img'));
const count = root.querySelector('.kodza-ken-burns-slider__index');
const total = root.querySelector('.kodza-ken-burns-slider__total');
const fill = root.querySelector('.kodza-ken-burns-slider__fill');
const button = root.querySelector('.kodza-ken-burns-slider__pause');
const interval = parseInt(root.dataset.interval, 10) || DWELL;
if (!slides.length || images.some((img) => !img)) return;
// Every photo framed on its point of interest, moving or not
const focus = slides.map(focusOf);
images.forEach((img, i) => {
const at = `${focus[i].x * 100}% ${focus[i].y * 100}%`;
img.style.objectPosition = at;
img.style.transformOrigin = at;
});
const first = Math.max(0, slides.findIndex((slide) => slide.classList.contains('is-current')));
// The still path: no motion asked, by the visitor or the site, or a single photo
if (reduceMotion || !pace || slides.length < 2) {
root.classList.add('is-still');
slides.forEach((slide, i) => slide.classList.toggle('is-current', i === first));
return;
}
if (total) total.textContent = pad(slides.length);
// Every animation in play, paused and played again together
const running = new Set();
let visible = false;
let focused = false;
let held = false;
const waiting = () => held || focused || !visible || document.hidden;
const animate = (el, keyframes, options) => {
const animation = el.animate(keyframes, options);
running.add(animation);
const drop = () => running.delete(animation);
animation.finished.then(drop, drop);
if (waiting()) animation.pause();
return animation;
};
const sync = () => {
const wait = waiting();
running.forEach((animation) => {
if (wait && animation.playState === 'running') animation.pause();
if (!wait && animation.playState === 'paused') animation.play();
});
};
// The drift: steady like a camera on rails, long enough to keep moving under the next curtain
const drifts = new Map();
const drift = (i) => {
const [x, y] = [reach(focus[i].x), reach(focus[i].y)];
drifts.set(i, animate(images[i], [
{ transform: `translate(0%, 0%) scale(${ZOOM_FROM})` },
{ transform: `translate(${x * 100}%, ${y * 100}%) scale(${ZOOM_TO})` },
], { duration: interval + 3 * CURTAIN * pace, easing: 'linear', fill: 'forwards' }));
};
// The way a photo drifts on screen: its larger move, in pixels
const wayOf = (i) => {
const x = reach(focus[i].x) * root.offsetWidth;
const y = reach(focus[i].y) * root.offsetHeight;
if (Math.max(Math.abs(x), Math.abs(y)) < 4) return 'left';
if (Math.abs(x) >= Math.abs(y)) return x < 0 ? 'left' : 'right';
return y < 0 ? 'up' : 'down';
};
const roll = (text) => {
if (!count) return;
const old = count.lastElementChild;
const digits = Object.assign(document.createElement('span'), { textContent: text });
count.append(digits);
const timing = { duration: ROLL * pace, easing: ROLL_EASE };
if (old) animate(old, [{ transform: 'none' }, { transform: 'translateY(-100%)' }], { ...timing, fill: 'forwards' }).finished.then(() => old.remove(), () => {});
animate(digits, [{ transform: 'translateY(100%)' }, { transform: 'none' }], timing);
};
let index = first;
let round = 0; // a replay or a destroy starts a new round: the old one's promises stop there
const next = async (at, filled) => {
const from = index;
const to = (index + 1) % slides.length;
// The photo is ready before anything moves: the curtain never uncovers an empty frame
await ready(images[to]);
if (at !== round) return;
index = to;
const [leaving, coming] = [slides[from], slides[to]];
const way = WAYS[wayOf(from)];
const timing = { duration: CURTAIN * pace, easing: CURTAIN_EASE };
leaving.classList.add('is-leaving');
leaving.classList.remove('is-current');
coming.classList.add('is-current');
drift(to);
const opened = animate(coming, [{ clipPath: way.from }, { clipPath: 'inset(0)' }], timing);
const pushed = animate(leaving, [{ transform: 'none' }, { transform: way.push }], { ...timing, fill: 'forwards' });
// The line empties forward as the photo turns
const emptied = fill ? animate(fill, [{ transform: 'none' }, { transform: 'translateX(100%)' }], { ...timing, fill: 'forwards' }) : null;
filled.cancel();
roll(pad(to + 1));
// The photo after it starts loading now, well before its turn
ready(images[(to + 1) % slides.length]);
if (!(await opened.finished.then(() => true, () => false)) || at !== round) return;
leaving.classList.remove('is-leaving');
pushed.cancel();
drifts.get(from)?.cancel();
drifts.delete(from);
emptied?.cancel();
tick(at);
};
// The timer is the line itself: it fills over the photo's time, then the photo turns
const tick = (at) => {
// (no line in the markup: an empty animation keeps the time, and pauses like the rest)
const filled = animate(fill || root, fill ? [{ transform: 'translateX(-100%)' }, { transform: 'none' }] : [], { duration: interval, easing: 'linear', fill: 'forwards' });
filled.finished.then(() => at === round && next(at, filled), () => {});
};
const start = () => {
round += 1;
running.forEach((animation) => animation.cancel());
running.clear();
drifts.clear();
index = first;
slides.forEach((slide, i) => {
slide.classList.toggle('is-current', i === first);
slide.classList.remove('is-leaving');
});
count?.replaceChildren(Object.assign(document.createElement('span'), { textContent: pad(first + 1) }));
drift(first);
tick(round);
ready(images[(first + 1) % slides.length]);
};
button?.addEventListener('click', () => {
held = !held;
button.setAttribute('aria-pressed', String(held));
sync();
}, { signal });
// A link reached with the keyboard holds the slideshow; the pause button has its own say
root.addEventListener('focusin', (event) => {
focused = event.target !== button && event.target.matches(':focus-visible');
sync();
}, { signal });
root.addEventListener('focusout', (event) => {
if (!root.contains(event.relatedTarget)) focused = false;
sync();
}, { signal });
document.addEventListener('visibilitychange', sync, { signal });
const seen = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
sync();
});
seen.observe(root);
root.addEventListener('kodza:replay', start, { signal });
signal.addEventListener('abort', () => {
round += 1;
seen.disconnect();
running.forEach((animation) => animation.cancel());
running.clear();
});
// The first photo drifts once it is there
ready(images[first]).then(() => signal.aborted || start());
}
// 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 — Ken Burns Slider, for React. From https://kodza.dev/components/ken-burns-slider/
// 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: ken-burns-slider.css and ken-burns-slider.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 './ken-burns-slider.css';
export type KenBurnsSliderProps = Omit<ComponentPropsWithoutRef<'section'>, "children" | "vars" | "interval"> & {
/** data-interval on the root (default: 6000) */
interval?: "6000";
/** Settings, as CSS variables on the root */
vars?: {
/** 100svh for a full-screen hero — default: 640px */
"--kz-ken-burns-slider-height"?: string | number;
/** default: #0a0a0a */
"--kz-ken-burns-slider-ink"?: string | number;
/** default: #ffffff */
"--kz-ken-burns-slider-paper"?: string | number;
/** default: rgba(255, 255, 255, 0.9) */
"--kz-ken-burns-slider-text"?: string | number;
/** default: rgba(255, 255, 255, 0.9) */
"--kz-ken-burns-slider-faint"?: string | number;
/** default: rgba(255, 255, 255, 0.3) */
"--kz-ken-burns-slider-track"?: string | number;
/** under the photos while they load — default: #1c1c1c */
"--kz-ken-burns-slider-backdrop"?: string | number;
/** default: linear-gradient(to top, rgba(10, 10, 10, 0.82), rgba(10, 10, 10, 0.66) 300px, rgba(10, 10, 10, 0.54) 460px, rgba(10, 10, 10, 0.16)), linear-gradient(to right, rgba(10, 10, 10, 0.3), rgba(10, 10, 10, 0) 60%) */
"--kz-ken-burns-slider-shade"?: string | number;
/** default: clamp(22px, 4.4vw, 56px) */
"--kz-ken-burns-slider-pad"?: string | number;
/** default: 0px */
"--kz-ken-burns-slider-radius"?: string | number;
/** default: var(--kz-font-mono, "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace) */
"--kz-ken-burns-slider-mono"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-ken-burns-slider-ease"?: string | number;
};
className?: string;
};
export function KenBurnsSlider({ interval = "6000", vars, className, style, ...rest }: KenBurnsSliderProps) {
const ref = useRef<ComponentRef<'section'>>(null);
const classes = ["kodza-ken-burns-slider", 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, interval, 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('./ken-burns-slider.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 (
<section aria-label="Maison Rempart" key={key} {...rest} className={classes} data-kodza-manual data-interval={interval} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<ul className="kodza-ken-burns-slider__slides">
<li className="kodza-ken-burns-slider__slide is-current" data-focus="60% 62%">
<img className="kodza-ken-burns-slider__image" src="https://kodza.dev/media/kz-table-1400.webp" srcSet="https://kodza.dev/media/kz-table-1400.webp 1400w, https://kodza.dev/media/kz-table-2400.webp 2400w" sizes="(max-width: 760px) 200vw, 100vw" width="1400" height="1050" alt="A white table set with two glasses and a bottle, facing a misty sea with a sailboat" fetchPriority="high" />
</li>
<li className="kodza-ken-burns-slider__slide" data-focus="42% 56%">
<img className="kodza-ken-burns-slider__image" src="https://kodza.dev/media/kz-arch-1400.webp" srcSet="https://kodza.dev/media/kz-arch-1400.webp 1400w, https://kodza.dev/media/kz-arch-2400.webp 2400w" sizes="(max-width: 760px) 200vw, 100vw" width="1400" height="1400" alt="A concrete wall with a round-topped opening, sunlight pouring through it" loading="lazy" />
</li>
<li className="kodza-ken-burns-slider__slide" data-focus="64% 50%">
<img className="kodza-ken-burns-slider__image" src="https://kodza.dev/media/kz-columns-1400.webp" srcSet="https://kodza.dev/media/kz-columns-1400.webp 1400w, https://kodza.dev/media/kz-columns-2400.webp 2400w" sizes="(max-width: 760px) 200vw, 100vw" width="1120" height="1400" alt="A row of square concrete pillars under a flat roof, casting striped shadows" loading="lazy" />
</li>
<li className="kodza-ken-burns-slider__slide" data-focus="50% 58%">
<img className="kodza-ken-burns-slider__image" src="https://kodza.dev/media/kz-overflow-1400.webp" srcSet="https://kodza.dev/media/kz-overflow-1400.webp 1400w, https://kodza.dev/media/kz-overflow-2400.webp 2400w" sizes="(max-width: 760px) 200vw, 100vw" width="1400" height="1400" alt="A two-storey concrete house, its upper floor cantilevered over the ground" loading="lazy" />
</li>
</ul>
<div className="kodza-ken-burns-slider__content">
<p className="kodza-ken-burns-slider__label">Maison Rempart · Saint-Malo</p>
<h2 className="kodza-ken-burns-slider__title">
<span className="kodza-ken-burns-slider__phrase">Nine rooms.</span>
{' '}
<span className="kodza-ken-burns-slider__phrase">The sea does the rest.</span>
</h2>
<p className="kodza-ken-burns-slider__text">No spa, no welcome drink, no app to open the door. Thick walls, a good bed and the tide twice a day.</p>
<a className="kodza-ken-burns-slider__button" href="#">Check availability<svg className="kodza-ken-burns-slider__arrow" viewBox="0 0 16 16" aria-hidden="true"> <path d="M3 8h10M9 4l4 4-4 4" /> </svg></a>
</div>
<div className="kodza-ken-burns-slider__controls">
<span className="kodza-ken-burns-slider__count"><span className="kodza-ken-burns-slider__index"> <span>01</span> </span> / <span className="kodza-ken-burns-slider__total">04</span></span>
{' '}
<span className="kodza-ken-burns-slider__line" aria-hidden="true">
<span className="kodza-ken-burns-slider__fill" />
</span>
{' '}
<button className="kodza-ken-burns-slider__pause" type="button" aria-pressed="false" aria-label="Pause the slideshow">
<svg className="kodza-ken-burns-slider__icon" viewBox="0 0 16 16" aria-hidden="true">
<path className="kodza-ken-burns-slider__bars" d="M5.5 4v8M10.5 4v8" />
<path className="kodza-ken-burns-slider__play" d="M5.5 3.8v8.4L12 8z" />
</svg>
</button>
</div>
</section>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.