Back to Top
A round button that shows up a screen down, its ring filling as you read. Its label unrolls on hover, the arrow takes off when you click.
View codePlaceholders the size of a post shimmer in, then turn into the posts without a jump. Two loads on their own, then a button, so the footer stays reachable.
With your AI
Say it to your AI. It gets the exact code below, not its best guess, and knows where it goes.
Load more blog posts with Kodza Infinite Scroll.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<section class="kodza-feed" aria-label="Journal" data-batch="3" data-auto="2" data-delay="700">
<ol class="kodza-feed__list">
<li class="kodza-feed__item"><a class="kodza-feed__link" href="#"><span class="kodza-feed__media"><img src="https://kodza.dev/media/kz-steps-1400.webp" alt="" loading="lazy"></span><span class="kodza-feed__body"><span class="kodza-feed__meta">Oct 16 · 4 min</span><span class="kodza-feed__title">We deleted our config files</span><span class="kodza-feed__text">Nothing broke. Two people cried. Here is what we kept.</span></span></a></li>
<li class="kodza-feed__item"><a class="kodza-feed__link" href="#"><span class="kodza-feed__media"><img src="https://kodza.dev/media/kz-columns-1400.webp" alt="" loading="lazy"></span><span class="kodza-feed__body"><span class="kodza-feed__meta">Oct 12 · 6 min</span><span class="kodza-feed__title">Seven columns, zero frameworks</span><span class="kodza-feed__text">The grid you wanted in 2015, now in plain CSS.</span></span></a></li>
<li class="kodza-feed__item"><a class="kodza-feed__link" href="#"><span class="kodza-feed__media"><img src="https://kodza.dev/media/kz-block-1400.webp" alt="" loading="lazy"></span><span class="kodza-feed__body"><span class="kodza-feed__meta">Oct 09 · 3 min</span><span class="kodza-feed__title">Not everything needs React</span><span class="kodza-feed__text">A button is a button. It was fine. It is still fine.</span></span></a></li>
</ol>
<template class="kodza-feed__more">
<li class="kodza-feed__item"><a class="kodza-feed__link" href="#"><span class="kodza-feed__media"><img src="https://kodza.dev/media/kz-stack-1400.webp" alt="" loading="lazy"></span><span class="kodza-feed__body"><span class="kodza-feed__meta">Oct 05 · 5 min</span><span class="kodza-feed__title">The case for boring CSS</span><span class="kodza-feed__text">Boring ships. Clever gets rewritten on a Friday.</span></span></a></li>
<li class="kodza-feed__item"><a class="kodza-feed__link" href="#"><span class="kodza-feed__media"><img src="https://kodza.dev/media/kz-float-1400.webp" alt="" loading="lazy"></span><span class="kodza-feed__body"><span class="kodza-feed__meta">Oct 02 · 4 min</span><span class="kodza-feed__title">Our build step, a eulogy</span><span class="kodza-feed__text">It compiled nothing for three years. We miss it a little.</span></span></a></li>
<li class="kodza-feed__item"><a class="kodza-feed__link" href="#"><span class="kodza-feed__media"><img src="https://kodza.dev/media/kz-arch-1400.webp" alt="" loading="lazy"></span><span class="kodza-feed__body"><span class="kodza-feed__meta">Sep 28 · 2 min</span><span class="kodza-feed__title">Dark mode in eight lines</span><span class="kodza-feed__text">Six variables and a media query. The rest was meetings.</span></span></a></li>
<li class="kodza-feed__item"><a class="kodza-feed__link" href="#"><span class="kodza-feed__media"><img src="https://kodza.dev/media/kz-steps-1400.webp" alt="" loading="lazy"></span><span class="kodza-feed__body"><span class="kodza-feed__meta">Sep 24 · 5 min</span><span class="kodza-feed__title">Animations that respect you</span><span class="kodza-feed__text">Reduced motion is not a setting, it is a person.</span></span></a></li>
<li class="kodza-feed__item"><a class="kodza-feed__link" href="#"><span class="kodza-feed__media"><img src="https://kodza.dev/media/kz-columns-1400.webp" alt="" loading="lazy"></span><span class="kodza-feed__body"><span class="kodza-feed__meta">Sep 20 · 3 min</span><span class="kodza-feed__title">Why we test on a 2016 phone</span><span class="kodza-feed__text">If it runs there, it runs. If it does not, it is on us.</span></span></a></li>
<li class="kodza-feed__item"><a class="kodza-feed__link" href="#"><span class="kodza-feed__media"><img src="https://kodza.dev/media/kz-block-1400.webp" alt="" loading="lazy"></span><span class="kodza-feed__body"><span class="kodza-feed__meta">Sep 17 · 1 min</span><span class="kodza-feed__title">Copy, paste, ship</span><span class="kodza-feed__text">The whole tutorial, in three words. You are welcome.</span></span></a></li>
<li class="kodza-feed__item"><a class="kodza-feed__link" href="#"><span class="kodza-feed__media"><img src="https://kodza.dev/media/kz-stack-1400.webp" alt="" loading="lazy"></span><span class="kodza-feed__body"><span class="kodza-feed__meta">Sep 13 · 4 min</span><span class="kodza-feed__title">Naming things, again</span><span class="kodza-feed__text">We renamed a class four times. It is still called card.</span></span></a></li>
<li class="kodza-feed__item"><a class="kodza-feed__link" href="#"><span class="kodza-feed__media"><img src="https://kodza.dev/media/kz-float-1400.webp" alt="" loading="lazy"></span><span class="kodza-feed__body"><span class="kodza-feed__meta">Sep 09 · 2 min</span><span class="kodza-feed__title">The footer nobody reads</span><span class="kodza-feed__text">Except you. Hello. There is nothing down here, honest.</span></span></a></li>
<li class="kodza-feed__item"><a class="kodza-feed__link" href="#"><span class="kodza-feed__media"><img src="https://kodza.dev/media/kz-arch-1400.webp" alt="" loading="lazy"></span><span class="kodza-feed__body"><span class="kodza-feed__meta">Sep 05 · 3 min</span><span class="kodza-feed__title">Works on my machine</span><span class="kodza-feed__text">A love letter to the sentence behind every bug.</span></span></a></li>
</template>
<div class="kodza-feed__foot">
<p class="kodza-feed__status" aria-live="polite">Showing <span class="kodza-feed__count">3</span> of <span class="kodza-feed__total">12</span></p>
<button class="kodza-feed__button" type="button">Load more</button>
<p class="kodza-feed__end">That is all of it. Go outside.</p>
</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 — Infinite Scroll
A list that grows as the visitor reaches its end: placeholders the exact size of a post
shimmer in, then turn into the posts (pictures unveiled, lines rising), so nothing jumps.
After a couple of automatic loads it hands over to a "Load more" button, so the footer stays
reachable. The next posts wait in a template element (print them all from WordPress, they come in
by batches), or come from your own loader: root.kodzaLoad = async () => [li, li, …].
Settings: data-batch (posts per load), data-auto (automatic loads), data-delay (ms).
Everything is tuned with the --kz-feed-* variables. */
.kodza-feed {
--kz-feed-fg: #0a0a0a;
--kz-feed-muted: rgba(10, 10, 10, 0.62);
--kz-feed-line: rgba(10, 10, 10, 0.1);
--kz-feed-soft: rgba(10, 10, 10, 0.06);
--kz-feed-shine: rgba(10, 10, 10, 0.11);
--kz-feed-ink: #0a0a0a;
--kz-feed-on-ink: #ffffff;
--kz-feed-media: 112px;
--kz-feed-radius: 12px;
--kz-feed-ease: cubic-bezier(0.22, 1, 0.36, 1);
width: 100%;
max-width: 760px;
color: var(--kz-feed-fg);
}
.kodza-feed__list {
margin: 0;
padding: 0;
border-top: 1px solid var(--kz-feed-line);
list-style: none;
}
/* A post and its placeholder share one cell: the swap never moves anything */
.kodza-feed__item {
display: grid;
border-bottom: 1px solid var(--kz-feed-line);
}
.kodza-feed__item > * {
grid-area: 1 / 1;
}
.kodza-feed__link,
.kodza-feed__ghost {
display: flex;
align-items: center;
gap: 24px;
padding: 20px 0;
}
.kodza-feed__link {
border-radius: 6px;
color: inherit;
text-decoration: none;
}
.kodza-feed__link:focus-visible {
outline: 2px solid var(--kz-feed-fg);
outline-offset: 4px;
}
.kodza-feed__media {
flex: 0 0 var(--kz-feed-media);
height: var(--kz-feed-media);
overflow: hidden;
border-radius: var(--kz-feed-radius);
background: var(--kz-feed-soft);
}
.kodza-feed__media img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.7s var(--kz-feed-ease);
}
.kodza-feed__link:hover .kodza-feed__media img {
transform: scale(1.06);
}
.kodza-feed__body {
display: grid;
gap: 6px;
min-width: 0;
}
.kodza-feed__meta {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
color: var(--kz-feed-muted);
}
.kodza-feed__title {
justify-self: start;
background: linear-gradient(currentColor, currentColor) 0 100% / 0 1px no-repeat;
font-size: 20px;
font-weight: 600;
line-height: 1.25;
letter-spacing: -0.025em;
transition: background-size 0.45s var(--kz-feed-ease);
}
.kodza-feed__link:hover .kodza-feed__title {
background-size: 100% 1px;
}
.kodza-feed__text {
font-size: 15px;
line-height: 1.5;
color: var(--kz-feed-muted);
text-wrap: pretty;
}
/* The placeholder: the same shapes, with a monochrome shimmer */
.kodza-feed__ghost-media,
.kodza-feed__ghost-lines span {
border-radius: 6px;
background: linear-gradient(90deg, var(--kz-feed-soft) 30%, var(--kz-feed-shine) 50%, var(--kz-feed-soft) 70%) 0 0 / 300% 100%;
animation: kodza-feed-shimmer 1.4s linear infinite;
}
.kodza-feed__ghost-media {
flex: 0 0 var(--kz-feed-media);
height: var(--kz-feed-media);
border-radius: var(--kz-feed-radius);
}
.kodza-feed__ghost-lines {
display: grid;
flex: 1;
gap: 10px;
}
.kodza-feed__ghost-lines span {
height: 12px;
}
.kodza-feed__ghost-lines span:nth-child(1) {
width: 22%;
}
.kodza-feed__ghost-lines span:nth-child(2) {
width: 58%;
height: 20px;
}
.kodza-feed__ghost-lines span:nth-child(3) {
width: 76%;
}
@keyframes kodza-feed-shimmer {
from {
background-position: 100% 0;
}
to {
background-position: 0 0;
}
}
/* Arriving: the placeholder steps back, the picture is unveiled, the lines rise */
.kodza-feed__item.is-arriving .kodza-feed__ghost {
animation: kodza-feed-out 0.35s ease both;
}
.kodza-feed__item.is-arriving .kodza-feed__media {
animation: kodza-feed-clip 0.8s cubic-bezier(0.65, 0, 0.35, 1) both;
animation-delay: calc(var(--i, 0) * 90ms);
}
.kodza-feed__item.is-arriving .kodza-feed__body > * {
animation: kodza-feed-rise 0.7s var(--kz-feed-ease) both;
animation-delay: calc(var(--i, 0) * 90ms + 120ms);
}
.kodza-feed__item.is-arriving .kodza-feed__body > :nth-child(2) {
animation-delay: calc(var(--i, 0) * 90ms + 180ms);
}
.kodza-feed__item.is-arriving .kodza-feed__body > :nth-child(3) {
animation-delay: calc(var(--i, 0) * 90ms + 240ms);
}
@keyframes kodza-feed-out {
to {
opacity: 0;
}
}
@keyframes kodza-feed-clip {
from {
clip-path: inset(0 0 100% 0 round var(--kz-feed-radius));
}
to {
clip-path: inset(0 0 0 0 round var(--kz-feed-radius));
}
}
@keyframes kodza-feed-rise {
from {
opacity: 0;
transform: translateY(12px);
}
}
/* The foot: the count on the left; the button, then the end, in one cell on the right */
.kodza-feed__foot {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 16px;
padding: 28px 0 0;
}
.kodza-feed__status {
margin: 0;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
color: var(--kz-feed-muted);
}
.kodza-feed__count {
display: inline-grid;
overflow: hidden;
color: var(--kz-feed-fg);
vertical-align: bottom;
}
.kodza-feed__count > span {
grid-area: 1 / 1;
}
.kodza-feed__button,
.kodza-feed__end {
grid-area: 1 / 2;
justify-self: end;
}
.kodza-feed__button {
height: 40px;
padding: 0 20px;
border: 0;
border-radius: 999px;
background: var(--kz-feed-ink);
color: var(--kz-feed-on-ink);
font: inherit;
font-size: 15px;
font-weight: 500;
cursor: pointer;
opacity: 0;
transform: translateY(8px);
visibility: hidden;
transition:
opacity 0.3s ease,
transform 0.5s var(--kz-feed-ease),
visibility 0s linear 0.5s;
}
.kodza-feed__button:focus-visible {
outline: 2px solid var(--kz-feed-fg);
outline-offset: 3px;
}
.kodza-feed__button:disabled {
cursor: progress;
}
/* The button label rolls between "Load more" and "Loading" */
.kodza-feed__roll {
display: inline-grid;
overflow: hidden;
vertical-align: top;
}
.kodza-feed__roll > span {
grid-area: 1 / 1;
}
.kodza-feed.is-manual .kodza-feed__button {
opacity: 1;
transform: none;
visibility: visible;
transition-delay: 0s;
}
.kodza-feed__end {
margin: 0;
font-size: 15px;
font-weight: 500;
opacity: 0;
transform: translateY(8px);
visibility: hidden;
}
.kodza-feed.is-done .kodza-feed__button {
opacity: 0;
transform: translateY(-8px);
visibility: hidden;
transition:
opacity 0.25s ease,
transform 0.4s ease-in,
visibility 0s linear 0.4s;
}
.kodza-feed.is-done .kodza-feed__end {
opacity: 1;
transform: none;
visibility: visible;
transition:
opacity 0.5s ease 0.2s,
transform 0.7s var(--kz-feed-ease) 0.2s;
}
.kodza-feed__sentinel {
display: block;
height: 1px;
}
@media (max-width: 560px) {
.kodza-feed {
--kz-feed-media: 84px;
}
.kodza-feed__link,
.kodza-feed__ghost {
gap: 16px;
}
.kodza-feed__title {
font-size: 17px;
}
.kodza-feed__text {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.kodza-feed__ghost-media,
.kodza-feed__ghost-lines span,
.kodza-feed__item.is-arriving .kodza-feed__ghost,
.kodza-feed__item.is-arriving .kodza-feed__media,
.kodza-feed__item.is-arriving .kodza-feed__body > * {
animation: none;
}
.kodza-feed__media img,
.kodza-feed__title,
.kodza-feed__button,
.kodza-feed__end {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
}
}
/* Theming example — on a dark background:
.dark-section .kodza-feed {
--kz-feed-fg: #f2f2f0;
--kz-feed-muted: rgba(242, 242, 240, 0.62);
--kz-feed-line: rgba(255, 255, 255, 0.1);
--kz-feed-soft: rgba(255, 255, 255, 0.06);
--kz-feed-shine: rgba(255, 255, 255, 0.12);
--kz-feed-ink: #f2f2f0;
--kz-feed-on-ink: #0a0a0a;
}
*/
/* kodza-guard e850cd6aeb8f — 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-feed__link):not(#_),
:where(.kodza-feed__button):not(#_) {
outline: revert;
outline-offset: revert;
}
:where(.kodza-feed__end):not(#_) {
transition: revert;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-feed:not(#_),
.kodza-feed__media:not(#_),
.kodza-feed__body:not(#_),
.kodza-feed__meta:not(#_),
.kodza-feed__title:not(#_),
.kodza-feed__text:not(#_),
.kodza-feed__ghost:not(#_),
.kodza-feed__ghost-media:not(#_),
.kodza-feed__ghost-lines:not(#_),
.kodza-feed__sentinel:not(#_),
.kodza-feed__more:not(#_),
.kodza-feed__foot:not(#_),
.kodza-feed__count:not(#_),
.kodza-feed__total:not(#_),
.kodza-feed__roll:not(#_),
.kodza-feed__ghost-lines span:where(:not([class*="kodza-"])):not(#_),
.kodza-feed__count span:where(:not([class*="kodza-"])):not(#_),
.kodza-feed__roll span:where(:not([class*="kodza-"])):not(#_) {
box-sizing: revert;
}
.kodza-feed__list:not(#_) {
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
box-sizing: revert;
float: revert;
position: revert;
border-right-width: revert;
border-bottom-width: revert;
border-left-width: revert;
border-right-style: revert;
border-bottom-style: revert;
border-left-style: revert;
border-right-color: revert;
border-bottom-color: revert;
border-left-color: 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-feed__item:not(#_) {
margin: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
border-top-width: revert;
border-right-width: revert;
border-left-width: revert;
border-top-style: revert;
border-right-style: revert;
border-left-style: revert;
border-top-color: revert;
border-right-color: revert;
border-left-color: 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-feed__link:not(#_) {
border: revert;
margin: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-feed__status:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
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-feed__button:not(#_) {
margin: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
box-shadow: revert;
appearance: revert;
max-width: revert;
max-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
white-space: revert;
min-height: revert;
}
.kodza-feed__end: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;
color: revert;
font-family: 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-feed__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-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-feed__list:not(#_):where(:not([hidden])),
.kodza-feed__status:not(#_):where(:not([hidden])),
.kodza-feed__button:not(#_):where(:not([hidden])),
.kodza-feed__end:not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-feed__list:not(#_):where([hidden]),
.kodza-feed__status:not(#_):where([hidden]),
.kodza-feed__button:not(#_):where([hidden]),
.kodza-feed__end:not(#_):where([hidden]) {
display: none;
}
/* The component's own rules, one step stronger: real buttons and fields, what it sets on tags themes style (links, lists, headings, images…), and the root width (a theme may narrow it, never widen it) */
.kodza-feed:not(#_) {
width: min(100%, 760px);
}
.kodza-feed__list:not(#_) {
margin: 0;
padding: 0;
border-top: 1px solid var(--kz-feed-line);
list-style: none;
}
.kodza-feed__item:not(#_) {
display: grid;
border-bottom: 1px solid var(--kz-feed-line);
}
.kodza-feed__link:not(#_) {
display: flex;
padding: 20px 0;
}
.kodza-feed__link:not(#_) {
border-radius: 6px;
color: inherit;
text-decoration: none;
}
.kodza-feed__link:focus-visible:not(#_) {
outline: 2px solid var(--kz-feed-fg);
outline-offset: 4px;
}
.kodza-feed__media img:not(#_) {
display: block;
transition: transform 0.7s var(--kz-feed-ease);
}
.kodza-feed__status:not(#_) {
margin: 0;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
color: var(--kz-feed-muted);
}
.kodza-feed__button:not(#_) {
grid-area: 1 / 2;
justify-self: end;
}
.kodza-feed__button:not(#_) {
height: 40px;
padding: 0 20px;
border: 0;
border-radius: 999px;
background: var(--kz-feed-ink);
color: var(--kz-feed-on-ink);
font: inherit;
font-size: 15px;
font-weight: 500;
cursor: pointer;
opacity: 0;
transform: translateY(8px);
visibility: hidden;
transition: opacity 0.3s ease,
transform 0.5s var(--kz-feed-ease),
visibility 0s linear 0.5s;
}
.kodza-feed__button:focus-visible:not(#_) {
outline: 2px solid var(--kz-feed-fg);
outline-offset: 3px;
}
.kodza-feed__button:disabled:not(#_) {
cursor: progress;
}
.kodza-feed.is-manual .kodza-feed__button:not(#_) {
opacity: 1;
transform: none;
visibility: visible;
transition-delay: 0s;
}
.kodza-feed__end:not(#_) {
margin: 0;
font-size: 15px;
font-weight: 500;
}
.kodza-feed.is-done .kodza-feed__button:not(#_) {
opacity: 0;
transform: translateY(-8px);
visibility: hidden;
transition: opacity 0.25s ease,
transform 0.4s ease-in,
visibility 0s linear 0.4s;
}
.kodza-feed.is-done .kodza-feed__end:not(#_) {
transition: opacity 0.5s ease 0.2s,
transform 0.7s var(--kz-feed-ease) 0.2s;
}
@media (prefers-reduced-motion: reduce) {
.kodza-feed__media img:not(#_),
.kodza-feed__end:not(#_) {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
}
}
@media (prefers-reduced-motion: reduce) {
.kodza-feed__button:not(#_) {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
}
}
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — Infinite Scroll
// Watches the end of the list: placeholders first, then the next posts (from the <template>,
// or from root.kodzaLoad, an async function returning <li> elements, empty when there is no
// more). After data-auto automatic loads, the "Load more" button takes over. The count rolls,
// and the end of the list says so. Fires "kodza:loaded" with { shown } on the section.
(() => {
const SELECTOR = '.kodza-feed';
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// A word or a number rolls to its new value inside its little window
const roll = (box, text) => {
const prev = box.lastElementChild;
if (prev && prev.textContent === String(text)) return;
const span = document.createElement('span');
span.textContent = text;
box.append(span);
if (!prev) return;
if (reduceMotion) return prev.remove();
const timing = { duration: 450, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' };
prev.animate([{ transform: 'none' }, { transform: 'translateY(-110%)' }], { ...timing, fill: 'forwards' }).finished.then(() => prev.remove());
span.animate([{ transform: 'translateY(110%)' }, { transform: 'none' }], timing);
};
const ghost = () => {
const li = document.createElement('li');
li.className = 'kodza-feed__item';
li.innerHTML = '<span class="kodza-feed__ghost" aria-hidden="true"><span class="kodza-feed__ghost-media"></span><span class="kodza-feed__ghost-lines"><span></span><span></span><span></span></span></span>';
return li;
};
// One instance. Everything it starts is tied to `signal`, so kodza:destroy stops it all
function mount(root, signal) {
const list = root.querySelector('.kodza-feed__list');
const template = root.querySelector('.kodza-feed__more');
const button = root.querySelector('.kodza-feed__button');
const count = root.querySelector('.kodza-feed__count');
const total = root.querySelector('.kodza-feed__total');
if (!list) return;
const batch = Number(root.dataset.batch) || 3;
const delay = Number(root.dataset.delay) || 0;
let autoLeft = root.dataset.auto !== undefined ? Number(root.dataset.auto) : 2;
const pending = template ? [...template.content.children] : [];
let shown = list.children.length;
let loading = false;
let done = false;
if (total && template) total.textContent = shown + pending.length;
if (count) count.replaceChildren(Object.assign(document.createElement('span'), { textContent: shown }));
const label = document.createElement('span');
label.className = 'kodza-feed__roll';
if (button) {
label.append(Object.assign(document.createElement('span'), { textContent: button.textContent.trim() }));
button.replaceChildren(label);
// The button keeps the width of its longest word
button.style.minWidth = `${button.offsetWidth + 24}px`;
}
const idle = label.textContent;
const finish = () => {
done = true;
root.classList.remove('is-manual');
root.classList.add('is-done');
};
const load = async () => {
if (loading || done) return;
loading = true;
root.setAttribute('aria-busy', 'true');
if (button) {
button.disabled = true;
roll(label, 'Loading');
}
const ghosts = Array.from({ length: root.kodzaLoad ? batch : Math.min(batch, pending.length) }, ghost);
list.append(...ghosts);
const items = root.kodzaLoad ? await root.kodzaLoad() : (await wait(reduceMotion ? 0 : delay), pending.splice(0, batch));
if (signal.aborted) return;
ghosts.forEach((li, i) => {
const item = items[i];
if (!item) return li.remove();
const link = item.querySelector?.('.kodza-feed__link') || item;
li.append(link);
li.style.setProperty('--i', i);
li.classList.add('is-arriving');
setTimeout(() => {
li.querySelector('.kodza-feed__ghost')?.remove();
li.classList.remove('is-arriving');
}, 1300 + i * 90);
});
shown += Math.min(items.length, ghosts.length);
if (count) roll(count, shown);
root.removeAttribute('aria-busy');
loading = false;
if (button) {
button.disabled = false;
roll(label, idle);
}
root.dispatchEvent(new CustomEvent('kodza:loaded', { detail: { shown }, bubbles: true }));
if ((!root.kodzaLoad && !pending.length) || !items.length) return finish();
if (autoLeft <= 0) root.classList.add('is-manual');
// Still at the end of the list after loading? Keep going while automatic loads remain
else requestAnimationFrame(() => !signal.aborted && sentinel.getBoundingClientRect().top < innerHeight + 240 && auto());
};
const auto = () => {
if (autoLeft <= 0 || loading || done) return;
autoLeft -= 1;
load();
};
const sentinel = document.createElement('span');
sentinel.className = 'kodza-feed__sentinel';
sentinel.setAttribute('aria-hidden', 'true');
list.after(sentinel);
if (autoLeft > 0 && 'IntersectionObserver' in window) {
const observer = new IntersectionObserver(([entry]) => entry.isIntersecting && auto(), { rootMargin: '0px 0px 240px 0px' });
observer.observe(sentinel);
signal.addEventListener('abort', () => observer.disconnect());
} else {
autoLeft = 0;
if (pending.length || root.kodzaLoad) root.classList.add('is-manual');
}
if (!pending.length && !root.kodzaLoad) finish();
button?.addEventListener('click', load, { signal });
}
// Kodza lifecycle, the same in every component: `kodza:init` starts the instances inside
// event.detail.root (the whole page by default), `kodza:destroy` stops them and puts their
// markup back. For themes and apps that swap content without a page load.
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 — Infinite Scroll, for React. From https://kodza.dev/components/infinite-scroll/
// 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: infinite-scroll.css and infinite-scroll.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 './infinite-scroll.css';
export type InfiniteScrollProps = Omit<ComponentPropsWithoutRef<'section'>, "children" | "vars" | "batch" | "auto" | "delay" | "onLoaded"> & {
/** data-batch on the root (default: 3) */
batch?: "3";
/** data-auto on the root (default: 2) */
auto?: "2";
/** data-delay on the root (default: 700) */
delay?: "700";
/** Settings, as CSS variables on the root */
vars?: {
/** default: #0a0a0a */
"--kz-feed-fg"?: string | number;
/** default: rgba(10, 10, 10, 0.62) */
"--kz-feed-muted"?: string | number;
/** default: rgba(10, 10, 10, 0.1) */
"--kz-feed-line"?: string | number;
/** default: rgba(10, 10, 10, 0.06) */
"--kz-feed-soft"?: string | number;
/** default: rgba(10, 10, 10, 0.11) */
"--kz-feed-shine"?: string | number;
/** default: #0a0a0a */
"--kz-feed-ink"?: string | number;
/** default: #ffffff */
"--kz-feed-on-ink"?: string | number;
/** default: 112px */
"--kz-feed-media"?: string | number;
/** default: 12px */
"--kz-feed-radius"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-feed-ease"?: string | number;
};
/** "kodza:loaded", fired by the script (bubbles from the root) */
onLoaded?: (event: CustomEvent) => void;
className?: string;
};
export function InfiniteScroll({ batch = "3", auto = "2", delay = "700", vars, onLoaded, className, style, ...rest }: InfiniteScrollProps) {
const ref = useRef<ComponentRef<'section'>>(null);
const classes = ["kodza-feed", 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, batch, auto, delay, 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('./infinite-scroll.js').then(() => {
if (live) document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root } }));
});
return () => {
live = false;
document.dispatchEvent(new CustomEvent('kodza:destroy', { detail: { root } }));
};
}, [key]);
useEffect(() => {
const root = ref.current;
if (!root || !onLoaded) return;
const listener = (event: Event) => onLoaded(event as CustomEvent);
root.addEventListener("kodza:loaded", listener);
return () => root.removeEventListener("kodza:loaded", listener);
}, [onLoaded, key]);
return (
<section aria-label="Journal" key={key} {...rest} className={classes} data-batch={batch} data-auto={auto} data-delay={delay} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<ol className="kodza-feed__list">
<li className="kodza-feed__item">
<a className="kodza-feed__link" href="#">
<span className="kodza-feed__media">
<img src="https://kodza.dev/media/kz-steps-1400.webp" alt="" loading="lazy" />
</span>
<span className="kodza-feed__body">
<span className="kodza-feed__meta">Oct 16 · 4 min</span>
<span className="kodza-feed__title">We deleted our config files</span>
<span className="kodza-feed__text">Nothing broke. Two people cried. Here is what we kept.</span>
</span>
</a>
</li>
<li className="kodza-feed__item">
<a className="kodza-feed__link" href="#">
<span className="kodza-feed__media">
<img src="https://kodza.dev/media/kz-columns-1400.webp" alt="" loading="lazy" />
</span>
<span className="kodza-feed__body">
<span className="kodza-feed__meta">Oct 12 · 6 min</span>
<span className="kodza-feed__title">Seven columns, zero frameworks</span>
<span className="kodza-feed__text">The grid you wanted in 2015, now in plain CSS.</span>
</span>
</a>
</li>
<li className="kodza-feed__item">
<a className="kodza-feed__link" href="#">
<span className="kodza-feed__media">
<img src="https://kodza.dev/media/kz-block-1400.webp" alt="" loading="lazy" />
</span>
<span className="kodza-feed__body">
<span className="kodza-feed__meta">Oct 09 · 3 min</span>
<span className="kodza-feed__title">Not everything needs React</span>
<span className="kodza-feed__text">A button is a button. It was fine. It is still fine.</span>
</span>
</a>
</li>
</ol>
<template className="kodza-feed__more" />
<div className="kodza-feed__foot">
<p className="kodza-feed__status" aria-live="polite">Showing <span className="kodza-feed__count">3</span> of <span className="kodza-feed__total">12</span></p>
<button className="kodza-feed__button" type="button">Load more</button>
<p className="kodza-feed__end">That is all of it. Go outside.</p>
</div>
</section>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.