Before / After
Two pictures in one frame and a handle between them: drag it, click and it glides, or use the arrow keys. It nudges once when first seen; the labels step aside.
View codeA native scroll-snap carousel that works before the script loads. Then: page buttons, a rolling count, a progress bar or dots, and mouse drag with a throw. Never steals the wheel.
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 Carousel to my page.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<section class="kodza-carousel" aria-roledescription="carousel" aria-label="New prints">
<div class="kodza-carousel__head">
<h2 class="kodza-carousel__title">New prints</h2>
<div class="kodza-carousel__controls">
<span class="kodza-carousel__count" aria-hidden="true"></span>
<button class="kodza-carousel__prev" type="button" aria-label="Previous slides">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M13 8H3M7 4 3 8l4 4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button class="kodza-carousel__next" type="button" aria-label="Next slides">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8h10M9 4l4 4-4 4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
</div>
<div class="kodza-carousel__viewport" tabindex="0">
<div class="kodza-carousel__slide" role="group" aria-roledescription="slide">
<a class="kodza-carousel__card" href="#">
<span class="kodza-carousel__media"><img src="https://kodza.dev/media/kz-arch-1400.webp" width="1400" height="1400" alt="" loading="lazy" decoding="async"></span>
<span class="kodza-carousel__body">
<span class="kodza-carousel__name">The Arch</span>
<span class="kodza-carousel__price">€48</span>
<span class="kodza-carousel__text">One way in. No side doors.</span>
</span>
</a>
</div>
<div class="kodza-carousel__slide" role="group" aria-roledescription="slide">
<a class="kodza-carousel__card" href="#">
<span class="kodza-carousel__media"><img src="https://kodza.dev/media/kz-columns-1400.webp" width="1120" height="1400" alt="" loading="lazy" decoding="async"></span>
<span class="kodza-carousel__body">
<span class="kodza-carousel__name">Seven Columns</span>
<span class="kodza-carousel__price">€56</span>
<span class="kodza-carousel__text">A grid, no framework.</span>
</span>
</a>
</div>
<div class="kodza-carousel__slide" role="group" aria-roledescription="slide">
<a class="kodza-carousel__card" href="#">
<span class="kodza-carousel__media"><img src="https://kodza.dev/media/kz-float-1400.webp" width="1400" height="933" alt="" loading="lazy" decoding="async"></span>
<span class="kodza-carousel__body">
<span class="kodza-carousel__name">Float</span>
<span class="kodza-carousel__price">€42</span>
<span class="kodza-carousel__text">Out of the flow, on purpose.</span>
</span>
</a>
</div>
<div class="kodza-carousel__slide" role="group" aria-roledescription="slide">
<a class="kodza-carousel__card" href="#">
<span class="kodza-carousel__media"><img src="https://kodza.dev/media/kz-steps-1400.webp" width="1400" height="933" alt="" loading="lazy" decoding="async"></span>
<span class="kodza-carousel__body">
<span class="kodza-carousel__name">Stairs to Prod</span>
<span class="kodza-carousel__price">€64</span>
<span class="kodza-carousel__text">Never deployed on a Friday.</span>
</span>
</a>
</div>
<div class="kodza-carousel__slide" role="group" aria-roledescription="slide">
<a class="kodza-carousel__card" href="#">
<span class="kodza-carousel__media"><img src="https://kodza.dev/media/kz-stack-1400.webp" width="1400" height="1050" alt="" loading="lazy" decoding="async"></span>
<span class="kodza-carousel__body">
<span class="kodza-carousel__name">The Stack</span>
<span class="kodza-carousel__price">€52</span>
<span class="kodza-carousel__text">z-index: 9999, still behind.</span>
</span>
</a>
</div>
<div class="kodza-carousel__slide" role="group" aria-roledescription="slide">
<a class="kodza-carousel__card" href="#">
<span class="kodza-carousel__media"><img src="https://kodza.dev/media/kz-block-1400.webp" width="1120" height="1400" alt="" loading="lazy" decoding="async"></span>
<span class="kodza-carousel__body">
<span class="kodza-carousel__name">Block</span>
<span class="kodza-carousel__price">€39</span>
<span class="kodza-carousel__text">display: block. Done. Ship it.</span>
</span>
</a>
</div>
</div>
<div class="kodza-carousel__progress" aria-hidden="true"><span class="kodza-carousel__thumb"></span></div>
</section>
<section class="kodza-carousel kodza-carousel--quotes" aria-roledescription="carousel" aria-label="What developers say">
<div class="kodza-carousel__viewport" tabindex="0">
<div class="kodza-carousel__slide" role="group" aria-roledescription="slide">
<figure class="kodza-carousel__quote">
<blockquote class="kodza-carousel__says">Finally a carousel that doesn’t hijack my scroll wheel. I scrolled past it just to feel something.</blockquote>
<figcaption><span class="kodza-carousel__avatar" aria-hidden="true">MK</span><span class="kodza-carousel__who"><b>Mira K.</b> Frontend, at an agency</span></figcaption>
</figure>
</div>
<div class="kodza-carousel__slide" role="group" aria-roledescription="slide">
<figure class="kodza-carousel__quote">
<blockquote class="kodza-carousel__says">No npm install. No peer dependency drama. I pasted it into WordPress and went to lunch.</blockquote>
<figcaption><span class="kodza-carousel__avatar" aria-hidden="true">TB</span><span class="kodza-carousel__who"><b>Theo B.</b> WordPress, since 4.9</span></figcaption>
</figure>
</div>
<div class="kodza-carousel__slide" role="group" aria-roledescription="slide">
<figure class="kodza-carousel__quote">
<blockquote class="kodza-carousel__says">I removed 42 kB of slider plugin and nobody noticed. Except Lighthouse, who sent flowers.</blockquote>
<figcaption><span class="kodza-carousel__avatar" aria-hidden="true">SR</span><span class="kodza-carousel__who"><b>Sam R.</b> Freelance, Lyon</span></figcaption>
</figure>
</div>
<div class="kodza-carousel__slide" role="group" aria-roledescription="slide">
<figure class="kodza-carousel__quote">
<blockquote class="kodza-carousel__says">Keyboard, screen reader, reduced motion. The accessibility audit came back boring. Boring is good.</blockquote>
<figcaption><span class="kodza-carousel__avatar" aria-hidden="true">LV</span><span class="kodza-carousel__who"><b>Lena V.</b> Accessibility lead</span></figcaption>
</figure>
</div>
<div class="kodza-carousel__slide" role="group" aria-roledescription="slide">
<figure class="kodza-carousel__quote">
<blockquote class="kodza-carousel__says">Flicked it with the mouse and it landed on a slide. Like a cat. A well-behaved, snap-aligned cat.</blockquote>
<figcaption><span class="kodza-carousel__avatar" aria-hidden="true">YA</span><span class="kodza-carousel__who"><b>Yusuf A.</b> Design engineer</span></figcaption>
</figure>
</div>
<div class="kodza-carousel__slide" role="group" aria-roledescription="slide">
<figure class="kodza-carousel__quote">
<blockquote class="kodza-carousel__says">It worked on my machine. Then it worked on theirs. I’m still processing this.</blockquote>
<figcaption><span class="kodza-carousel__avatar" aria-hidden="true">ID</span><span class="kodza-carousel__who"><b>Ines D.</b> CTO, team of four</span></figcaption>
</figure>
</div>
</div>
<div class="kodza-carousel__dots"></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 — Carousel
Slides in a row that scrolls natively and snaps to each slide: without the script it already
works with a finger, a trackpad, Shift + wheel and the keyboard. The script adds the buttons
(one page at a time, dimmed at the ends), a count that rolls, a progress bar that follows the
scroll, dots with an ink pill that stretches from one to the next, mouse dragging with a throw
that glides onto a slide, arrow keys, and labels and announcements for screen readers. The
vertical wheel always scrolls the page. Any content goes in a slide: cards, products, quotes.
Keep the parts you want: __controls (count, prev, next), __progress, __dots.
data-autoplay="6000" plays the slides on its own (never under reduced motion until asked): it
stops while pointed at or focused, and adds a pause button. Labels for other languages:
data-slide-label="{n} of {total}", data-status-one, data-status-many, data-dot-label,
data-pause-label, data-play-label.
Everything is tuned with the --kz-carousel-* variables. */
.kodza-carousel {
--kz-carousel-per-view: 3; /* slides in view; a decimal shows part of the next one */
--kz-carousel-per-view-tablet: 2.2; /* carousel narrower than 860px */
--kz-carousel-per-view-phone: 1.1; /* carousel narrower than 560px */
--kz-carousel-gap: 16px;
--kz-carousel-inset: 0px; /* room before the first slide and after the last (full-width layouts) */
--kz-carousel-fg: #0a0a0a;
--kz-carousel-muted: rgba(10, 10, 10, 0.64);
--kz-carousel-dim: rgba(10, 10, 10, 0.45);
--kz-carousel-line: rgba(10, 10, 10, 0.12);
--kz-carousel-soft: rgba(10, 10, 10, 0.06);
--kz-carousel-ink: #0a0a0a; /* the buttons, the progress bar, the dot pill */
--kz-carousel-ink-hover: #2e2e2e;
--kz-carousel-on-ink: #ffffff;
--kz-carousel-card: #ffffff;
--kz-carousel-border: rgba(10, 10, 10, 0.1);
--kz-carousel-placeholder: rgba(10, 10, 10, 0.05);
--kz-carousel-radius: 16px;
--kz-carousel-ratio: 4 / 3; /* the picture frame of a card */
--kz-carousel-control: 40px;
--kz-carousel-ease: cubic-bezier(0.22, 1, 0.36, 1);
position: relative;
display: grid;
grid-template-areas: "head" "track" "progress" "dots";
grid-template-columns: minmax(0, 1fr);
box-sizing: border-box;
width: 100%;
min-width: 0;
container: kodza-carousel / inline-size;
color: var(--kz-carousel-fg);
}
/* The keyboard focus ring of the row goes around the slides in view, not the whole width */
.kodza-carousel::after {
grid-area: track;
margin: 0 var(--kz-carousel-inset);
border-radius: calc(var(--kz-carousel-radius) + 5px);
outline: 2px solid transparent;
outline-offset: 5px;
content: "";
pointer-events: none;
}
.kodza-carousel:has(> .kodza-carousel__viewport:focus-visible)::after {
outline-color: var(--kz-carousel-fg);
}
/* --quotes: two quotes in view */
.kodza-carousel--quotes {
--kz-carousel-per-view: 2;
--kz-carousel-per-view-tablet: 1.4;
--kz-carousel-per-view-phone: 1.08;
}
/* Title on the left, count and buttons on the right */
.kodza-carousel__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
box-sizing: border-box;
grid-area: head;
min-height: var(--kz-carousel-control);
margin: 0 0 24px;
padding: 0 var(--kz-carousel-inset);
}
.kodza-carousel__title {
margin: 0;
color: var(--kz-carousel-fg);
font-size: 26px;
font-weight: 600;
line-height: 1.15;
letter-spacing: -0.035em;
}
.kodza-carousel__controls {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 8px;
}
/* "01–03 / 06": the numbers roll when they change */
.kodza-carousel__count {
display: flex;
align-items: baseline;
margin-right: 10px;
color: var(--kz-carousel-muted);
font-family: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
font-size: 13px;
line-height: 1.4;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.kodza-carousel__num {
display: inline-grid;
overflow: hidden;
color: var(--kz-carousel-fg);
}
.kodza-carousel__num > * {
grid-area: 1 / 1;
}
.kodza-carousel__dash {
padding: 0 0.15em;
}
.kodza-carousel__total {
padding-left: 0.5em;
}
.kodza-carousel__count.is-single .kodza-carousel__dash,
.kodza-carousel__count.is-single .kodza-carousel__num + .kodza-carousel__dash + .kodza-carousel__num {
display: none;
}
.kodza-carousel__prev,
.kodza-carousel__next,
.kodza-carousel__toggle {
position: relative;
display: grid;
flex-shrink: 0;
place-items: center;
box-sizing: border-box;
width: var(--kz-carousel-control);
height: var(--kz-carousel-control);
margin: 0;
padding: 0;
overflow: hidden;
border: 0;
border-radius: 999px;
background: transparent;
box-shadow: inset 0 0 0 1px var(--kz-carousel-line);
color: var(--kz-carousel-fg);
font: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: background-color 0.25s ease, color 0.25s ease, transform 0.2s var(--kz-carousel-ease);
}
.kodza-carousel__toggle:hover {
background: var(--kz-carousel-soft);
}
/* Previous and next: solid ink, easy to find */
.kodza-carousel__prev,
.kodza-carousel__next {
background: var(--kz-carousel-ink);
box-shadow: none;
color: var(--kz-carousel-on-ink);
}
.kodza-carousel__prev:hover,
.kodza-carousel__next:hover {
background: var(--kz-carousel-ink-hover);
}
.kodza-carousel__prev:active,
.kodza-carousel__next:active,
.kodza-carousel__toggle:active {
transform: scale(0.94);
}
/* At the ends: a pale disc, still focusable, announced as unavailable */
.kodza-carousel__prev[aria-disabled="true"],
.kodza-carousel__next[aria-disabled="true"] {
background: var(--kz-carousel-soft);
color: var(--kz-carousel-dim);
cursor: default;
transform: none;
}
.kodza-carousel__prev svg,
.kodza-carousel__next svg,
.kodza-carousel__toggle svg {
display: block;
width: 16px;
height: 16px;
}
.kodza-carousel__prev:focus-visible,
.kodza-carousel__next:focus-visible,
.kodza-carousel__toggle:focus-visible,
.kodza-carousel__dot:focus-visible {
outline: 2px solid var(--kz-carousel-fg);
outline-offset: 2px;
}
/* The row of slides: native scrolling and snapping */
.kodza-carousel__viewport {
--kz-carousel-view: var(--kz-carousel-per-view);
position: relative;
display: flex;
grid-area: track;
gap: var(--kz-carousel-gap);
box-sizing: border-box;
width: 100%;
margin: 0;
padding: 0 var(--kz-carousel-inset);
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior-x: contain;
scroll-behavior: auto;
scroll-snap-type: x mandatory;
scroll-padding-inline: var(--kz-carousel-inset);
scrollbar-color: var(--kz-carousel-line) transparent;
scrollbar-width: thin;
}
/* With the script, the progress bar or the dots take the scrollbar's place */
.kodza-carousel.is-ready .kodza-carousel__viewport {
scrollbar-width: none;
}
.kodza-carousel.is-ready .kodza-carousel__viewport::-webkit-scrollbar {
display: none;
}
.kodza-carousel__viewport:focus-visible {
outline: none;
}
@supports not selector(:has(*)) {
.kodza-carousel__viewport:focus-visible {
outline: 2px solid var(--kz-carousel-fg);
outline-offset: -2px;
}
}
@container kodza-carousel (max-width: 860px) {
.kodza-carousel__viewport {
--kz-carousel-view: var(--kz-carousel-per-view-tablet);
}
}
@container kodza-carousel (max-width: 560px) {
.kodza-carousel__viewport {
--kz-carousel-view: var(--kz-carousel-per-view-phone);
}
}
/* A phone: the title makes room for the count and the buttons */
@container kodza-carousel (max-width: 420px) {
.kodza-carousel__title {
font-size: 22px;
}
.kodza-carousel__count {
margin-right: 4px;
}
}
.kodza-carousel__slide {
display: flex;
flex: 0 0 calc((100% - (var(--kz-carousel-view) - 1) * var(--kz-carousel-gap)) / var(--kz-carousel-view));
flex-direction: column;
box-sizing: border-box;
min-width: 0;
margin: 0;
scroll-snap-align: start;
}
/* Dragged with the mouse, or gliding to a slide: the script moves it, snapping waits */
.kodza-carousel.is-dragging .kodza-carousel__viewport,
.kodza-carousel.is-gliding .kodza-carousel__viewport {
scroll-snap-type: none;
}
.kodza-carousel.is-dragging .kodza-carousel__viewport {
cursor: grabbing;
user-select: none;
}
.kodza-carousel.is-dragging .kodza-carousel__slide {
pointer-events: none;
}
@media (hover: hover) and (pointer: fine) {
.kodza-carousel.is-ready:not(.is-static) .kodza-carousel__viewport {
cursor: grab;
}
}
/* A card: picture, name and price, a line of text */
.kodza-carousel__card {
display: flex;
flex: 1;
flex-direction: column;
box-sizing: border-box;
overflow: hidden;
border: 1px solid var(--kz-carousel-border);
border-radius: var(--kz-carousel-radius);
background: var(--kz-carousel-card);
color: var(--kz-carousel-fg);
text-decoration: none;
-webkit-tap-highlight-color: transparent;
}
.kodza-carousel__card:focus-visible {
outline: 2px solid var(--kz-carousel-fg);
outline-offset: -2px;
}
.kodza-carousel__media {
display: block;
aspect-ratio: var(--kz-carousel-ratio);
overflow: hidden;
background: var(--kz-carousel-placeholder);
}
.kodza-carousel__media img {
display: block;
width: 100%;
max-width: none;
height: 100%;
object-fit: cover;
user-select: none;
-webkit-user-drag: none;
transition: transform 0.9s var(--kz-carousel-ease);
}
.kodza-carousel__card:hover .kodza-carousel__media img {
transform: scale(1.04);
}
.kodza-carousel__body {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: baseline;
gap: 8px 16px;
padding: 28px;
}
.kodza-carousel__name {
font-size: 22px;
font-weight: 600;
line-height: 1.2;
letter-spacing: -0.02em;
}
.kodza-carousel__price {
font-size: 15px;
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.kodza-carousel__text {
grid-column: 1 / -1;
color: var(--kz-carousel-muted);
font-size: 15px;
line-height: 1.55;
text-wrap: pretty;
}
/* A quote: the words, then who said them */
.kodza-carousel__quote {
display: flex;
flex: 1;
flex-direction: column;
justify-content: space-between;
gap: 36px;
box-sizing: border-box;
margin: 0;
padding: 28px;
border: 1px solid var(--kz-carousel-border);
border-radius: var(--kz-carousel-radius);
background: var(--kz-carousel-card);
color: var(--kz-carousel-fg);
}
.kodza-carousel__says {
margin: 0;
padding: 0;
border: 0;
color: var(--kz-carousel-fg);
font-size: 20px;
font-style: normal;
font-weight: 500;
line-height: 1.45;
letter-spacing: -0.02em;
text-wrap: pretty;
quotes: none;
}
.kodza-carousel__says::before,
.kodza-carousel__says::after {
content: none;
}
.kodza-carousel__quote figcaption {
display: flex;
align-items: center;
gap: 12px;
margin: 0;
padding: 0;
color: var(--kz-carousel-muted);
font-size: 14px;
font-style: normal;
line-height: 1.4;
}
.kodza-carousel__avatar {
display: grid;
flex-shrink: 0;
place-items: center;
width: 40px;
height: 40px;
border-radius: 999px;
background: var(--kz-carousel-ink);
color: var(--kz-carousel-on-ink);
font-family: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.02em;
}
.kodza-carousel__who {
display: grid;
}
.kodza-carousel__who b {
color: var(--kz-carousel-fg);
font-size: 15px;
font-weight: 600;
letter-spacing: -0.01em;
}
/* The progress bar: the thumb is as wide as the part in view and follows the scroll */
.kodza-carousel__progress {
position: relative;
grid-area: progress;
height: 2px;
margin: 28px var(--kz-carousel-inset) 0;
overflow: hidden;
border-radius: 2px;
background: var(--kz-carousel-line);
}
.kodza-carousel__thumb {
position: absolute;
top: 0;
left: 0;
width: 33%;
height: 100%;
border-radius: inherit;
background: var(--kz-carousel-ink);
will-change: transform;
}
/* Dots, one per stop, under an ink pill that stretches to the next one */
.kodza-carousel__dots {
position: relative;
display: flex;
grid-area: dots;
align-items: center;
justify-content: center;
min-height: 24px;
margin: 24px var(--kz-carousel-inset) 0;
}
.kodza-carousel__dot {
display: grid;
place-items: center;
width: 24px;
height: 24px;
margin: 0;
padding: 0;
border: 0;
border-radius: 999px;
background: none;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.kodza-carousel__dot::before {
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--kz-carousel-dim);
content: "";
transition: background-color 0.25s ease, transform 0.25s var(--kz-carousel-ease);
}
.kodza-carousel__dot:hover::before {
background: var(--kz-carousel-muted);
transform: scale(1.35);
}
.kodza-carousel__pill {
position: absolute;
inset: 0;
background: var(--kz-carousel-ink);
pointer-events: none;
}
/* The autoplay button: pause or play, and a ring that counts down to the next slide */
.kodza-carousel__toggle svg {
position: absolute;
inset: 0;
margin: auto;
fill: currentColor;
transition: opacity 0.25s ease, transform 0.35s var(--kz-carousel-ease);
}
.kodza-carousel__dots .kodza-carousel__toggle {
margin-right: 12px;
}
.kodza-carousel__toggle .kodza-carousel__ring {
width: 100%;
height: 100%;
fill: none;
stroke: var(--kz-carousel-fg);
stroke-dasharray: 1;
stroke-dashoffset: 1;
stroke-width: 1.5;
transform: rotate(-90deg);
transition: none;
}
.kodza-carousel__toggle.is-paused .kodza-carousel__pause,
.kodza-carousel__toggle:not(.is-paused) .kodza-carousel__play {
opacity: 0;
transform: scale(0.5) rotate(-90deg);
}
/* Screen reader announcements */
.kodza-carousel__status {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* Before the script, or when everything fits: no controls (their room is kept) */
.kodza-carousel:not(.is-ready) .kodza-carousel__controls,
.kodza-carousel:not(.is-ready) .kodza-carousel__progress,
.kodza-carousel.is-static .kodza-carousel__controls,
.kodza-carousel.is-static .kodza-carousel__progress,
.kodza-carousel.is-static .kodza-carousel__dots {
visibility: hidden;
}
@media (prefers-reduced-motion: reduce) {
.kodza-carousel *,
.kodza-carousel *::before,
.kodza-carousel *::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}
/* kodza-guard 70763ca2eb45 — 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-carousel__title):not(#_),
:where(.kodza-carousel__quote):not(#_),
:where(.kodza-carousel__says):not(#_),
:where(.kodza-carousel__quote figcaption:where(:not([class*="kodza-"]):not(.kodza-carousel__quote [class*="kodza-"] *))):not(#_),
:where(.kodza-carousel__who b:where(:not([class*="kodza-"]))):not(#_) {
transition-duration: revert;
}
:where(.kodza-carousel__prev):not(#_),
:where(.kodza-carousel__next):not(#_),
:where(.kodza-carousel__viewport):not(#_) {
outline: revert;
outline-offset: revert;
}
:where(.kodza-carousel__card):not(#_),
:where(.kodza-carousel__dot):not(#_) {
outline: revert;
outline-offset: revert;
transition-duration: revert;
}
:where(.kodza-carousel__prev svg:where(:not([class*="kodza-"]))):not(#_),
:where(.kodza-carousel__next svg:where(:not([class*="kodza-"]))):not(#_) {
margin: revert;
transition: revert;
position: revert;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-carousel__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-family: revert;
font-style: 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-carousel__controls:not(#_),
.kodza-carousel__count:not(#_),
.kodza-carousel__num:not(#_),
.kodza-carousel__dash:not(#_),
.kodza-carousel__total:not(#_),
.kodza-carousel__media:not(#_),
.kodza-carousel__body:not(#_),
.kodza-carousel__name:not(#_),
.kodza-carousel__price:not(#_),
.kodza-carousel__text:not(#_),
.kodza-carousel__progress:not(#_),
.kodza-carousel__thumb:not(#_),
.kodza-carousel__status:not(#_),
.kodza-carousel__avatar:not(#_),
.kodza-carousel__who:not(#_),
.kodza-carousel__dots:not(#_),
.kodza-carousel__pill:not(#_),
.kodza-carousel__num span:where(:not([class*="kodza-"])):not(#_) {
box-sizing: revert;
}
.kodza-carousel__prev:not(#_),
.kodza-carousel__next:not(#_) {
text-decoration: revert;
list-style: revert;
float: revert;
appearance: revert;
max-width: revert;
max-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
white-space: revert;
min-height: revert;
}
.kodza-carousel__viewport:not(#_) {
box-shadow: revert;
}
.kodza-carousel__card:not(#_) {
margin: revert;
padding: 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;
transition-property: revert;
transition-timing-function: revert;
transition-delay: revert;
}
.kodza-carousel__quote:not(#_) {
outline: revert;
text-decoration: revert;
list-style: revert;
float: revert;
position: 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;
transition-delay: revert;
}
.kodza-carousel__says:not(#_) {
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-family: 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-carousel__dot:not(#_) {
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
box-shadow: 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;
white-space: revert;
min-height: revert;
}
.kodza-carousel__prev svg:where(:not([class*="kodza-"])):not(#_),
.kodza-carousel__next svg:where(:not([class*="kodza-"])):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-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-carousel__media img:where(:not([class*="kodza-"])):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-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-carousel__quote figcaption:where(:not([class*="kodza-"]):not(.kodza-carousel__quote [class*="kodza-"] *)):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;
font-family: revert;
font-weight: 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-carousel__who b:where(:not([class*="kodza-"])):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;
font-family: revert;
font-style: revert;
line-height: 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-carousel__title:not(#_):where(:not([hidden])),
.kodza-carousel__says:not(#_):where(:not([hidden])),
.kodza-carousel__who b:where(:not([class*="kodza-"])):not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-carousel__title:not(#_):where([hidden]),
.kodza-carousel__says:not(#_):where([hidden]),
.kodza-carousel__who b:where(:not([class*="kodza-"])):not(#_):where([hidden]) {
display: none;
}
.kodza-carousel:not(#_)::after,
.kodza-carousel__viewport:not(#_)::after,
.kodza-carousel__says:not(#_)::before,
.kodza-carousel__says:not(#_)::after,
.kodza-carousel__dot:not(#_)::before {
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-carousel__title:not(#_) {
margin: 0;
color: var(--kz-carousel-fg);
font-size: 26px;
font-weight: 600;
line-height: 1.15;
letter-spacing: -0.035em;
}
.kodza-carousel__prev:not(#_),
.kodza-carousel__next:not(#_) {
position: relative;
display: grid;
flex-shrink: 0;
place-items: center;
box-sizing: border-box;
width: var(--kz-carousel-control);
height: var(--kz-carousel-control);
margin: 0;
padding: 0;
overflow: hidden;
border: 0;
border-radius: 999px;
background: transparent;
box-shadow: inset 0 0 0 1px var(--kz-carousel-line);
color: var(--kz-carousel-fg);
font: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: background-color 0.25s ease, color 0.25s ease, transform 0.2s var(--kz-carousel-ease);
}
.kodza-carousel__prev:not(#_),
.kodza-carousel__next:not(#_) {
background: var(--kz-carousel-ink);
box-shadow: none;
color: var(--kz-carousel-on-ink);
}
.kodza-carousel__prev:hover:not(#_),
.kodza-carousel__next:hover:not(#_) {
background: var(--kz-carousel-ink-hover);
}
.kodza-carousel__prev:active:not(#_),
.kodza-carousel__next:active:not(#_) {
transform: scale(0.94);
}
.kodza-carousel__prev[aria-disabled="true"]:not(#_),
.kodza-carousel__next[aria-disabled="true"]:not(#_) {
background: var(--kz-carousel-soft);
color: var(--kz-carousel-dim);
cursor: default;
transform: none;
}
.kodza-carousel__prev svg:not(#_),
.kodza-carousel__next svg:not(#_),
.kodza-carousel__toggle svg:not(#_) {
display: block;
}
.kodza-carousel__prev:focus-visible:not(#_),
.kodza-carousel__next:focus-visible:not(#_),
.kodza-carousel__dot:focus-visible:not(#_) {
outline: 2px solid var(--kz-carousel-fg);
outline-offset: 2px;
}
.kodza-carousel__viewport:focus-visible:not(#_) {
outline: none;
}
@supports not selector(:has(*)) {
.kodza-carousel__viewport:focus-visible:not(#_) {
outline: 2px solid var(--kz-carousel-fg);
outline-offset: -2px;
}
}
@container kodza-carousel (max-width: 420px) {
.kodza-carousel__title:not(#_) {
font-size: 22px;
}
}
.kodza-carousel__card:not(#_) {
display: flex;
box-sizing: border-box;
border: 1px solid var(--kz-carousel-border);
border-radius: var(--kz-carousel-radius);
background: var(--kz-carousel-card);
color: var(--kz-carousel-fg);
text-decoration: none;
}
.kodza-carousel__card:focus-visible:not(#_) {
outline: 2px solid var(--kz-carousel-fg);
outline-offset: -2px;
}
.kodza-carousel__media img:not(#_) {
display: block;
max-width: none;
transition: transform 0.9s var(--kz-carousel-ease);
}
.kodza-carousel__quote:not(#_) {
display: flex;
box-sizing: border-box;
margin: 0;
padding: 28px;
border: 1px solid var(--kz-carousel-border);
border-radius: var(--kz-carousel-radius);
background: var(--kz-carousel-card);
color: var(--kz-carousel-fg);
}
.kodza-carousel__says:not(#_) {
margin: 0;
padding: 0;
border: 0;
color: var(--kz-carousel-fg);
font-size: 20px;
font-style: normal;
font-weight: 500;
line-height: 1.45;
letter-spacing: -0.02em;
}
.kodza-carousel__quote figcaption:not(#_) {
display: flex;
margin: 0;
padding: 0;
color: var(--kz-carousel-muted);
font-size: 14px;
font-style: normal;
line-height: 1.4;
}
.kodza-carousel__who b:not(#_) {
color: var(--kz-carousel-fg);
font-size: 15px;
font-weight: 600;
letter-spacing: -0.01em;
}
.kodza-carousel__dot:not(#_) {
display: grid;
place-items: center;
width: 24px;
height: 24px;
margin: 0;
padding: 0;
border: 0;
border-radius: 999px;
background: none;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.kodza-carousel__dot:not(#_)::before {
width: 6px;
height: 6px;
border-radius: 999px;
background: var(--kz-carousel-dim);
content: "";
transition: background-color 0.25s ease, transform 0.25s var(--kz-carousel-ease);
}
.kodza-carousel__dot:hover:not(#_)::before {
background: var(--kz-carousel-muted);
transform: scale(1.35);
}
.kodza-carousel__toggle svg:not(#_) {
position: absolute;
margin: auto;
transition: opacity 0.25s ease, transform 0.35s var(--kz-carousel-ease);
}
@media (prefers-reduced-motion: reduce) {
.kodza-carousel *:not(#_),
.kodza-carousel *:not(#_)::before,
.kodza-carousel *: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 — Carousel
// The row scrolls natively and snaps (that is the CSS). On top of it: buttons that turn a page
// and dim at the ends, a count that rolls, a progress bar that follows the scroll, dots with an
// ink pill that stretches to the next one, mouse dragging with a throw that glides onto a slide
// (a small spring, in requestAnimationFrame), arrow keys, labels and polite announcements, and
// an optional autoplay. The vertical wheel is never taken. Positions are measured in the row's
// own pixels, so a scaled parent does not shift them.
(() => {
const SELECTOR = '.kodza-carousel';
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const ease = 'cubic-bezier(0.22, 1, 0.36, 1)';
const STIFF = 9; // how quickly a glide settles (per second)
const THROW = 0.3; // how far a throw carries: this many seconds of its speed
const PILL = 18; // the dot pill, in px
const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
const pad = (n) => String(n).padStart(2, '0');
const fill = (template, values) => template.replace(/\{(\w+)\}/g, (_, key) => values[key]);
const make = (tag, className) => Object.assign(document.createElement(tag), { className });
// Where an element is right now, even mid-animation, to start its next move from there
const now = (el, prop) => getComputedStyle(el)[prop];
// Past an end, a drag meets more and more resistance
const rubber = (d) => (d * 0.55 * 220) / (220 + d * 0.55);
// Old number up, new one from below (the other way round when going back)
const roll = (el, text, dir) => {
const old = el.lastElementChild;
if (old?.textContent === text) return;
const next = Object.assign(document.createElement('span'), { textContent: text });
el.append(next);
if (!old) return;
if (!dir || reduceMotion) return [...el.children].slice(0, -1).forEach((child) => child.remove());
const timing = { duration: 520, easing: ease };
const from = now(old, 'transform');
old.getAnimations().forEach((a) => a.cancel());
old.animate([{ transform: from }, { transform: `translateY(${-110 * dir}%)` }], { ...timing, fill: 'forwards' })
.finished.then(() => old.remove(), () => {});
next.animate([{ transform: `translateY(${110 * dir}%)` }, { transform: 'none' }], timing);
};
// A critically damped spring from `from` (moving at `velocity` px/s) to `to`. From rest it eases
// in and out; thrown at omega × distance, it is a plain ease-out that keeps the hand's speed
const spring = (from, to, velocity, omega, onFrame, onDone) => {
const x0 = from - to;
const b = velocity + omega * x0;
const start = performance.now();
let raf = 0;
const state = { x: from, v: velocity, stop: () => cancelAnimationFrame(raf) };
const step = (time) => {
const t = Math.max(0, (time - start) / 1000);
const decay = Math.exp(-omega * t);
state.x = to + (x0 + b * t) * decay;
state.v = (velocity - omega * b * t) * decay;
const done = (Math.abs(state.x - to) < 1 && Math.abs(state.v) < 30) || t > 3;
if (done) state.x = to;
onFrame(state.x);
if (done) onDone();
else raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
return state;
};
// One instance. Everything it starts is tied to `signal`, so kodza:destroy stops it all
function mount(root, signal) {
const viewport = root.querySelector('.kodza-carousel__viewport');
const slides = viewport ? [...viewport.children].filter((el) => el.classList.contains('kodza-carousel__slide')) : [];
if (!slides.length) return;
const prev = root.querySelector('.kodza-carousel__prev');
const next = root.querySelector('.kodza-carousel__next');
const count = root.querySelector('.kodza-carousel__count');
const progress = root.querySelector('.kodza-carousel__progress');
const thumb = progress?.querySelector('.kodza-carousel__thumb');
const dotsBox = root.querySelector('.kodza-carousel__dots');
const total = slides.length;
const text = {
slide: root.dataset.slideLabel || '{n} of {total}',
one: root.dataset.statusOne || 'Slide {from} of {total}',
many: root.dataset.statusMany || 'Slides {from} to {to} of {total}',
dot: root.dataset.dotLabel || 'Go to slide {n}',
pause: root.dataset.pauseLabel || 'Pause autoplay',
play: root.dataset.playLabel || 'Play autoplay',
};
slides.forEach((slide, i) => {
slide.setAttribute('role', 'group');
slide.setAttribute('aria-roledescription', 'slide');
slide.setAttribute('aria-label', fill(text.slide, { n: i + 1, total }));
});
const status = make('span', 'kodza-carousel__status');
status.setAttribute('aria-live', 'polite');
status.setAttribute('aria-atomic', 'true');
root.append(status);
// The count: first and last slide in view, over the total
let first = null;
let last = null;
if (count) {
first = make('span', 'kodza-carousel__num');
last = make('span', 'kodza-carousel__num');
count.replaceChildren(first, Object.assign(make('span', 'kodza-carousel__dash'), { textContent: '–' }), last,
Object.assign(make('span', 'kodza-carousel__total'), { textContent: `/ ${pad(total)}` }));
}
// Measured: where each slide lines up with the start, and what fits in view
let stops = [0]; // scroll positions a slide can line up at
let stopOf = []; // the stop of each slide
let starts = [];
let ends = [];
let max = 0;
let inset = 0;
let column = 0;
let scale = 1;
const measure = () => {
const box = viewport.getBoundingClientRect();
scale = box.width / viewport.offsetWidth || 1;
inset = parseFloat(getComputedStyle(viewport).paddingLeft) || 0;
max = Math.max(0, viewport.scrollWidth - viewport.clientWidth);
column = viewport.clientWidth - inset * 2;
const origin = viewport.scrollLeft - viewport.clientLeft;
starts = [];
ends = [];
slides.forEach((slide) => {
const r = slide.getBoundingClientRect();
starts.push((r.left - box.left) / scale + origin);
ends.push((r.right - box.left) / scale + origin);
});
stops = [];
stopOf = starts.map((start) => {
const at = clamp(start - inset, 0, max);
if (!stops.length || at - stops[stops.length - 1] > 1) stops.push(at);
return stops.length - 1;
});
root.classList.toggle('is-static', max < 1);
if (thumb) thumb.style.width = `${Math.max(24, progress.clientWidth * Math.min(1, column / (column + max)))}px`;
};
const nearest = (x) => stops.reduce((best, stop, i) => (Math.abs(stop - x) < Math.abs(stops[best] - x) ? i : best), 0);
// The slides fully in view at a scroll position
const range = (x) => {
let from = -1;
let to = -1;
starts.forEach((start, i) => {
if (start >= x + inset - 2 && ends[i] <= x + inset + column + 2) {
if (from < 0) from = i;
to = i;
}
});
if (from < 0) from = to = stopOf.indexOf(nearest(x));
return { from, to };
};
// Moving the row: a glide, or a drag past an end that springs back
let motion = null; // the running glide
let aim = 0; // the stop it is heading to
let pull = 0;
let pulling = null;
let stale = false; // resized while moving: measure again once it stops
const setPull = (px) => {
if (px === pull) return;
pull = px;
slides.forEach((slide) => { slide.style.translate = px ? `${px}px 0` : ''; });
};
// Snapping waits (is-gliding) until the glide and the spring back are both over
const settle = () => {
if (motion || pulling) return;
root.classList.remove('is-gliding');
if (stale) refresh();
announce();
};
const halt = () => {
if (!motion) return;
motion.stop();
motion = null;
settle();
};
const glide = (to, velocity) => {
to = clamp(to, 0, max);
const from = motion ? motion.x : viewport.scrollLeft;
if (reduceMotion || Math.abs(to - from) < 1) {
motion?.stop();
motion = null;
viewport.scrollLeft = to;
settle();
return;
}
let v = velocity;
let omega = STIFF;
if (v === undefined) {
// A button or a key: from rest, or from the speed of the glide it takes over
v = motion ? motion.v : 0;
} else if (Math.sign(v) === Math.sign(to - from)) {
// A throw: keep the hand's speed and ease out from there
omega = clamp(Math.abs(v / (to - from)), 7, 16);
}
motion?.stop();
root.classList.add('is-gliding');
motion = spring(from, to, v, omega, (x) => { viewport.scrollLeft = x; }, () => {
motion = null;
settle();
});
};
const go = (i) => {
aim = clamp(i, 0, stops.length - 1);
glide(stops[aim]);
render();
restartClock();
};
const here = () => (motion ? aim : nearest(viewport.scrollLeft));
// Buttons turn a page: the first slide after (or before) the ones in view
const page = (dir) => {
const { from, to } = range(motion ? stops[aim] : viewport.scrollLeft);
const size = to - from + 1;
const slide = clamp(dir > 0 ? to + 1 : from - size, 0, total - 1);
return stopOf[slide];
};
// The arrow leaves on the side it points to and comes back from the other one
const nudge = (button, dir) => {
const icon = button.querySelector('svg');
if (!icon || reduceMotion) return;
icon.getAnimations().forEach((a) => a.cancel());
icon.animate([
{ transform: 'none', easing: 'cubic-bezier(0.55, 0, 0.75, 0.2)' },
{ transform: `translateX(${dir * 30}px)`, offset: 0.42 },
{ transform: `translateX(${-dir * 30}px)`, offset: 0.4201, easing: ease },
{ transform: 'none' },
], { duration: 620 });
};
[[prev, -1], [next, 1]].forEach(([button, dir]) => {
button?.addEventListener('click', () => {
if (button.getAttribute('aria-disabled') === 'true') return;
nudge(button, dir);
go(page(dir));
}, { signal });
});
// Dots, one per stop, and the ink pill over the current one
let dots = [];
let pill = null;
let pillAt = -1;
const clip = (i) => {
const dot = dots[i];
const c = dot.offsetLeft + dot.offsetWidth / 2;
const y = (dotsBox.clientHeight - 6) / 2;
return `inset(${y}px ${dotsBox.clientWidth - c - PILL / 2}px ${y}px ${c - PILL / 2}px round 3px)`;
};
const placePill = (i, animate) => {
if (!pill || i === pillAt) return;
const to = clip(i);
const dir = Math.sign(i - pillAt);
if (animate && pillAt >= 0 && !reduceMotion) {
const from = now(pill, 'clipPath');
// Halfway, the leading edge is nearly there and the trailing one has barely left
const a = clip(pillAt).match(/[\d.-]+px/g).map(parseFloat);
const b = to.match(/[\d.-]+px/g).map(parseFloat);
const mid = dir > 0 ? [b[0], b[1] + (a[1] - b[1]) * 0.12, b[2], a[3] + (b[3] - a[3]) * 0.25]
: [b[0], a[1] + (b[1] - a[1]) * 0.25, b[2], b[3] + (a[3] - b[3]) * 0.12];
pill.getAnimations().forEach((anim) => anim.cancel());
pill.animate([
{ clipPath: from },
{ clipPath: `inset(${mid.map((v) => `${v}px`).join(' ')} round 3px)`, offset: 0.5 },
{ clipPath: to },
], { duration: 560, easing: 'cubic-bezier(0.45, 0, 0.2, 1)' });
}
pill.style.clipPath = to;
pillAt = i;
dots.forEach((dot, k) => (k === i ? dot.setAttribute('aria-current', 'true') : dot.removeAttribute('aria-current')));
};
const buildDots = () => {
if (!dotsBox || dots.length === stops.length) return;
dots = stops.map((_, i) => {
const dot = make('button', 'kodza-carousel__dot');
dot.type = 'button';
dot.setAttribute('aria-label', fill(text.dot, { n: stopOf.indexOf(i) + 1, total }));
dot.addEventListener('click', () => go(i), { signal });
return dot;
});
pill = make('span', 'kodza-carousel__pill');
pill.setAttribute('aria-hidden', 'true');
pillAt = -1;
dotsBox.replaceChildren(...(toggle ? [toggle] : []), ...dots, pill);
};
if (dotsBox) {
dotsBox.setAttribute('role', 'group');
dotsBox.setAttribute('aria-label', root.dataset.dotsLabel || 'Choose a slide');
}
// What the screen reader hears once the row comes to rest (not when the focus moved it: the
// slide's own label is read then)
let said = '';
let sayTimer = 0;
const message = ({ from, to }) => (from === to ? fill(text.one, { from: from + 1, total }) : fill(text.many, { from: from + 1, to: to + 1, total }));
const announce = () => {
clearTimeout(sayTimer);
sayTimer = setTimeout(() => {
if (motion || drag || pulling) return; // it speaks when it settles
const words = message(range(viewport.scrollLeft));
if (words === said) return;
said = words;
if (playing() || viewport.contains(document.activeElement) && document.activeElement !== viewport) return;
status.textContent = words;
}, 450);
};
// Everything that follows the scroll. The bar follows it to the pixel; the count, the dots and
// the buttons say where it is heading (the stop it glides to, or the nearest one), so they
// change once, as it leaves, rather than catching up with it
let frame = 0;
let shown = { from: -1, to: -1 };
const render = (animate = true) => {
frame = 0;
const x = viewport.scrollLeft;
if (thumb) {
const room = progress.clientWidth - thumb.offsetWidth;
thumb.style.transform = `translateX(${max > 0 ? (x / max) * room : 0}px)`;
}
const stop = motion ? aim : nearest(x);
const at = stops[stop];
if (prev) prev.setAttribute('aria-disabled', String(at <= 1));
if (next) next.setAttribute('aria-disabled', String(at >= max - 1));
placePill(stop, animate);
const view = range(at);
if (view.from !== shown.from || view.to !== shown.to) {
const dir = animate ? Math.sign(view.from - shown.from || view.to - shown.to) : 0;
if (count) {
count.classList.toggle('is-single', view.from === view.to);
roll(first, pad(view.from + 1), dir);
roll(last, pad(view.to + 1), dir);
}
if (!animate) said = message(view);
else announce();
shown = view;
}
};
viewport.addEventListener('scroll', () => {
if (!frame) frame = requestAnimationFrame(() => render());
}, { signal, passive: true });
// Mouse dragging; touch, pen and trackpads scroll natively
let drag = null;
let dragged = false;
viewport.addEventListener('pointerdown', (event) => {
if (event.pointerType !== 'mouse' || event.button !== 0 || max < 1) return;
if (event.target.closest('input, textarea, select, [contenteditable]')) return;
// Caught while gliding: it stops under the hand, and letting go settles it like a drag
const caught = !!(motion || pulling);
motion?.stop();
pulling?.stop();
motion = null;
pulling = null;
if (caught) {
root.classList.add('is-dragging');
try { viewport.setPointerCapture(event.pointerId); } catch {}
}
drag = { id: event.pointerId, x: event.clientX, left: viewport.scrollLeft - pull, start: viewport.scrollLeft, moved: caught, trail: [] };
}, { signal });
window.addEventListener('pointermove', (event) => {
if (!drag || event.pointerId !== drag.id) return;
const dx = (event.clientX - drag.x) / scale;
if (!drag.moved) {
if (Math.abs(dx) < 6) return;
// Starts from here, so the row does not jump by the threshold
drag.moved = true;
drag.x = event.clientX;
root.classList.add('is-dragging');
window.getSelection()?.removeAllRanges();
try { viewport.setPointerCapture(event.pointerId); } catch {}
return;
}
const left = drag.left - dx;
viewport.scrollLeft = clamp(left, 0, max);
setPull(left < 0 ? rubber(-left) : left > max ? -rubber(left - max) : 0);
drag.trail.push([event.timeStamp, left]);
while (drag.trail.length > 2 && event.timeStamp - drag.trail[0][0] > 90) drag.trail.shift();
}, { signal });
const release = (event) => {
if (!drag || event.pointerId !== drag.id) return;
const { moved, trail, start } = drag;
drag = null;
if (!moved) {
if (stale) refresh();
return;
}
// Snapping stays off in between: it would catch the row before the glide starts
root.classList.add('is-gliding');
root.classList.remove('is-dragging');
dragged = true;
setTimeout(() => { dragged = false; });
// The speed of the last moments; none if the hand stopped before letting go
const [t0, x0] = trail[0] || [0, 0];
const [t1, x1] = trail[trail.length - 1] || [0, 0];
const velocity = t1 > t0 && event.timeStamp - t1 < 60 ? ((x1 - x0) / (t1 - t0)) * 1000 : 0;
if (pull && reduceMotion) setPull(0);
else if (pull) {
pulling = spring(pull, 0, 0, 13, setPull, () => {
pulling = null;
settle();
});
}
const x = viewport.scrollLeft;
let i = nearest(x + velocity * THROW);
// A deliberate drag always turns at least one slide
const before = nearest(start);
if (i === before && Math.abs(x - start) > 40) i = clamp(before + Math.sign(x - start), 0, stops.length - 1);
aim = i;
glide(stops[i], velocity);
settle();
render();
restartClock();
};
window.addEventListener('pointerup', release, { signal });
window.addEventListener('pointercancel', release, { signal });
// A drag is not a click on the card under it, and pictures and links are not dragged away
viewport.addEventListener('click', (event) => {
if (!dragged) return;
event.preventDefault();
event.stopPropagation();
}, { signal, capture: true });
viewport.addEventListener('dragstart', (event) => event.preventDefault(), { signal });
// A sideways wheel or a finger takes over from a glide; the vertical wheel is left alone
viewport.addEventListener('wheel', (event) => {
if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) halt();
}, { signal, passive: true });
viewport.addEventListener('touchstart', halt, { signal, passive: true });
viewport.addEventListener('focusin', (event) => { if (event.target !== viewport) halt(); }, { signal });
// Arrow keys, Home and End when the row itself has the focus
viewport.addEventListener('keydown', (event) => {
if (event.target !== viewport || event.altKey || event.ctrlKey || event.metaKey) return;
const to = { ArrowLeft: here() - 1, ArrowRight: here() + 1, Home: 0, End: stops.length - 1 }[event.key];
if (to === undefined) return;
event.preventDefault();
go(to);
}, { signal });
// Autoplay, only when asked: stops while pointed at or focused, and under a pause button
const delay = parseFloat(root.dataset.autoplay);
let toggle = null;
let clock = null;
let wanted = false;
let hover = false;
let focus = false;
let seen = true;
const playing = () => !!clock && wanted && !hover && !focus && seen && !document.hidden;
const sync = () => {
if (!clock) return;
if (playing()) clock.play();
else clock.pause();
status.setAttribute('aria-live', playing() ? 'off' : 'polite');
toggle.classList.toggle('is-paused', !wanted);
toggle.setAttribute('aria-label', wanted ? text.pause : text.play);
};
function restartClock() {
if (!clock) return;
clock.currentTime = 0;
sync();
}
if (delay > 0) {
toggle = make('button', 'kodza-carousel__toggle');
toggle.type = 'button';
toggle.innerHTML = '<svg class="kodza-carousel__ring" viewBox="0 0 40 40" aria-hidden="true"><circle cx="20" cy="20" r="19.25" pathLength="1"/></svg>'
+ '<svg class="kodza-carousel__pause" viewBox="0 0 16 16" aria-hidden="true"><rect x="4" y="3" width="2.6" height="10" rx="1"/><rect x="9.4" y="3" width="2.6" height="10" rx="1"/></svg>'
+ '<svg class="kodza-carousel__play" viewBox="0 0 16 16" aria-hidden="true"><path d="M5 3.4v9.2a.6.6 0 0 0 .9.5l7.4-4.6a.6.6 0 0 0 0-1L5.9 2.9a.6.6 0 0 0-.9.5z"/></svg>';
const controls = root.querySelector('.kodza-carousel__controls');
if (controls) controls.insertBefore(toggle, prev || next || null);
else if (!dotsBox) viewport.after(toggle);
clock = toggle.querySelector('circle').animate([{ strokeDashoffset: 1 }, { strokeDashoffset: 0 }], { duration: delay, fill: 'forwards' });
clock.pause();
clock.onfinish = () => {
if (signal.aborted) return;
go(here() >= stops.length - 1 ? 0 : here() + 1);
};
wanted = !reduceMotion;
toggle.addEventListener('click', () => {
wanted = !wanted;
// Pressing play means it: play now, even with the pointer or the focus still here
hover = false;
focus = false;
sync();
}, { signal });
root.addEventListener('pointerenter', (event) => { if (event.pointerType === 'mouse') { hover = true; sync(); } }, { signal });
root.addEventListener('pointerleave', () => { hover = false; sync(); }, { signal });
// Keyboard focus only: a click on a button is not a reason to stop for good
root.addEventListener('focusin', (event) => {
if (event.target.matches(':focus-visible')) { focus = true; sync(); }
}, { signal });
root.addEventListener('focusout', (event) => {
if (!root.contains(event.relatedTarget)) { focus = false; sync(); }
}, { signal });
document.addEventListener('visibilitychange', sync, { signal });
const onScreen = new IntersectionObserver(([entry]) => { seen = entry.isIntersecting; sync(); });
onScreen.observe(root);
signal.addEventListener('abort', () => { onScreen.disconnect(); clock.cancel(); });
}
function refresh() {
stale = false;
measure();
buildDots();
render(false);
}
refresh();
if (clock) sync();
const resize = new ResizeObserver(() => {
if (drag || motion) stale = true;
else refresh();
});
resize.observe(viewport);
signal.addEventListener('abort', () => {
resize.disconnect();
cancelAnimationFrame(frame);
clearTimeout(sayTimer);
motion?.stop();
pulling?.stop();
});
}
// Kodza lifecycle, the same in every component: `kodza:init` starts the instances inside
// event.detail.root (the whole page by default), `kodza:destroy` stops them and puts their
// markup back. For themes and apps that swap content without a page load.
const instances = new WeakMap();
const find = (scope = document) => [scope, ...scope.querySelectorAll(SELECTOR)].filter((el) => el.matches?.(SELECTOR));
const init = (scope) => find(scope).forEach((root) => {
if (root.classList.contains('is-ready')) return; // already running, maybe from another copy of this script
const controller = new AbortController();
instances.set(root, { controller, html: root.innerHTML, attrs: [...root.attributes].map((a) => [a.name, a.value]) });
mount(root, controller.signal);
root.classList.add('is-ready');
});
const destroy = (scope) => find(scope).forEach((root) => {
const instance = instances.get(root);
if (!instance) return;
instances.delete(root);
instance.controller.abort();
[...root.attributes].forEach((a) => root.removeAttribute(a.name));
instance.attrs.forEach(([name, value]) => root.setAttribute(name, value));
root.innerHTML = instance.html;
});
document.addEventListener('kodza:init', (event) => init(event.detail?.root));
document.addEventListener('kodza:destroy', (event) => destroy(event.detail?.root));
init();
// Loaded in <head>, the script runs before the markup exists: once more when it is parsed
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => init(), { once: true });
})();
'use client';
// Kodza — Carousel, for React. From https://kodza.dev/components/carousel/
// 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: carousel.css and carousel.js, the component's CSS and JS from Kodza, as they are.
// Yours to edit: the markup is plain JSX below.
// The script changes this markup once React has rendered it (it adds layers, splits text, moves
// nodes), so React must not patch it afterwards. When a prop changes, the key gives React a fresh
// element: kodza:destroy stops the script on the old one, kodza:init starts it on the new one.
import { useRef, useEffect, type ComponentPropsWithoutRef, type ComponentRef, type CSSProperties } from 'react';
import './carousel.css';
type Variant = "quotes";
export type CarouselProps = Omit<ComponentPropsWithoutRef<'section'>, "children" | "variant" | "vars"> & {
/** Modifier class(es): .kodza-carousel--quotes */
variant?: Variant | Variant[];
/** Settings, as CSS variables on the root */
vars?: {
/** slides in view; a decimal shows part of the next one — default: 3 */
"--kz-carousel-per-view"?: string | number;
/** carousel narrower than 860px — default: 2.2 */
"--kz-carousel-per-view-tablet"?: string | number;
/** carousel narrower than 560px — default: 1.1 */
"--kz-carousel-per-view-phone"?: string | number;
/** default: 16px */
"--kz-carousel-gap"?: string | number;
/** room before the first slide and after the last (full-width layouts) — default: 0px */
"--kz-carousel-inset"?: string | number;
/** default: #0a0a0a */
"--kz-carousel-fg"?: string | number;
/** default: rgba(10, 10, 10, 0.64) */
"--kz-carousel-muted"?: string | number;
/** default: rgba(10, 10, 10, 0.45) */
"--kz-carousel-dim"?: string | number;
/** default: rgba(10, 10, 10, 0.12) */
"--kz-carousel-line"?: string | number;
/** default: rgba(10, 10, 10, 0.06) */
"--kz-carousel-soft"?: string | number;
/** the buttons, the progress bar, the dot pill — default: #0a0a0a */
"--kz-carousel-ink"?: string | number;
/** default: #2e2e2e */
"--kz-carousel-ink-hover"?: string | number;
/** default: #ffffff */
"--kz-carousel-on-ink"?: string | number;
/** default: #ffffff */
"--kz-carousel-card"?: string | number;
/** default: rgba(10, 10, 10, 0.1) */
"--kz-carousel-border"?: string | number;
/** default: rgba(10, 10, 10, 0.05) */
"--kz-carousel-placeholder"?: string | number;
/** default: 16px */
"--kz-carousel-radius"?: string | number;
/** the picture frame of a card — default: 4 / 3 */
"--kz-carousel-ratio"?: string | number;
/** default: 40px */
"--kz-carousel-control"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-carousel-ease"?: string | number;
};
className?: string;
};
export function Carousel({ variant, vars, className, style, ...rest }: CarouselProps) {
const ref = useRef<ComponentRef<'section'>>(null);
const classes = ["kodza-carousel", ...([] as string[]).concat(variant ?? []).map((v) => `kodza-carousel--${v}`), className].filter(Boolean).join(' ');
// Everything the markup is made of: when it changes, React gets a fresh element (see the top)
const key = JSON.stringify([classes, vars, style, rest]);
// Starts the script on this element once React has rendered it, stops it when it goes
useEffect(() => {
const root = ref.current;
let live = true;
// @ts-ignore: a plain script, no types to find
import('./carousel.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-roledescription="carousel" aria-label="New prints" key={key} {...rest} className={classes} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<div className="kodza-carousel__head">
<h2 className="kodza-carousel__title">New prints</h2>
<div className="kodza-carousel__controls">
<span className="kodza-carousel__count" aria-hidden="true" />
{' '}
<button className="kodza-carousel__prev" type="button" aria-label="Previous slides">
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M13 8H3M7 4 3 8l4 4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{' '}
<button className="kodza-carousel__next" type="button" aria-label="Next slides">
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M3 8h10M9 4l4 4-4 4" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
</div>
<div className="kodza-carousel__viewport" tabIndex={0}>
<div className="kodza-carousel__slide" role="group" aria-roledescription="slide">
<a className="kodza-carousel__card" href="#">
<span className="kodza-carousel__media">
<img src="https://kodza.dev/media/kz-arch-1400.webp" width="1400" height="1400" alt="" loading="lazy" decoding="async" />
</span>
{' '}
<span className="kodza-carousel__body">
<span className="kodza-carousel__name">The Arch</span>
{' '}
<span className="kodza-carousel__price">€48</span>
{' '}
<span className="kodza-carousel__text">One way in. No side doors.</span>
</span>
</a>
</div>
<div className="kodza-carousel__slide" role="group" aria-roledescription="slide">
<a className="kodza-carousel__card" href="#">
<span className="kodza-carousel__media">
<img src="https://kodza.dev/media/kz-columns-1400.webp" width="1120" height="1400" alt="" loading="lazy" decoding="async" />
</span>
{' '}
<span className="kodza-carousel__body">
<span className="kodza-carousel__name">Seven Columns</span>
{' '}
<span className="kodza-carousel__price">€56</span>
{' '}
<span className="kodza-carousel__text">A grid, no framework.</span>
</span>
</a>
</div>
<div className="kodza-carousel__slide" role="group" aria-roledescription="slide">
<a className="kodza-carousel__card" href="#">
<span className="kodza-carousel__media">
<img src="https://kodza.dev/media/kz-float-1400.webp" width="1400" height="933" alt="" loading="lazy" decoding="async" />
</span>
{' '}
<span className="kodza-carousel__body">
<span className="kodza-carousel__name">Float</span>
{' '}
<span className="kodza-carousel__price">€42</span>
{' '}
<span className="kodza-carousel__text">Out of the flow, on purpose.</span>
</span>
</a>
</div>
<div className="kodza-carousel__slide" role="group" aria-roledescription="slide">
<a className="kodza-carousel__card" href="#">
<span className="kodza-carousel__media">
<img src="https://kodza.dev/media/kz-steps-1400.webp" width="1400" height="933" alt="" loading="lazy" decoding="async" />
</span>
{' '}
<span className="kodza-carousel__body">
<span className="kodza-carousel__name">Stairs to Prod</span>
{' '}
<span className="kodza-carousel__price">€64</span>
{' '}
<span className="kodza-carousel__text">Never deployed on a Friday.</span>
</span>
</a>
</div>
<div className="kodza-carousel__slide" role="group" aria-roledescription="slide">
<a className="kodza-carousel__card" href="#">
<span className="kodza-carousel__media">
<img src="https://kodza.dev/media/kz-stack-1400.webp" width="1400" height="1050" alt="" loading="lazy" decoding="async" />
</span>
{' '}
<span className="kodza-carousel__body">
<span className="kodza-carousel__name">The Stack</span>
{' '}
<span className="kodza-carousel__price">€52</span>
{' '}
<span className="kodza-carousel__text">z-index: 9999, still behind.</span>
</span>
</a>
</div>
<div className="kodza-carousel__slide" role="group" aria-roledescription="slide">
<a className="kodza-carousel__card" href="#">
<span className="kodza-carousel__media">
<img src="https://kodza.dev/media/kz-block-1400.webp" width="1120" height="1400" alt="" loading="lazy" decoding="async" />
</span>
{' '}
<span className="kodza-carousel__body">
<span className="kodza-carousel__name">Block</span>
{' '}
<span className="kodza-carousel__price">€39</span>
{' '}
<span className="kodza-carousel__text">display: block. Done. Ship it.</span>
</span>
</a>
</div>
</div>
<div className="kodza-carousel__progress" aria-hidden="true">
<span className="kodza-carousel__thumb" />
</div>
</section>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.