Page Lift
Between two real pages, the one you leave recedes as the next rises over it on a rounded edge. The header stays, back plays it in reverse. Chrome, Edge, Safari; Firefox loads plainly for now. No GSAP.
View codeA preloader that counts the files your hero really needs while its images rise onto a pile. At 100 the hero image comes up last and grows into place, then the title rises. Click or any key skips. No GSAP.
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 Landing Reveal to my page.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<section class="kodza-landing-reveal">
<div class="kodza-landing-reveal__loader" aria-hidden="true">
<p class="kodza-landing-reveal__brand">off<span>·</span>by<span>·</span>one</p>
<p class="kodza-landing-reveal__tally"><span class="kodza-landing-reveal__done">0</span>/<span class="kodza-landing-reveal__total">0</span> files</p>
<div class="kodza-landing-reveal__deck">
<img class="kodza-landing-reveal__card" src="https://kodza.dev/media/kz-block-1400.webp" alt="" data-caption="display: block" width="1120" height="1400">
<img class="kodza-landing-reveal__card" src="https://kodza.dev/media/kz-columns-1400.webp" alt="" data-caption="display: grid" width="1120" height="1400">
<img class="kodza-landing-reveal__card" src="https://kodza.dev/media/kz-stack-1400.webp" alt="" data-caption="z-index: 9999" width="1400" height="1050">
<img class="kodza-landing-reveal__card" src="https://kodza.dev/media/kz-float-1400.webp" alt="" data-caption="float: left" width="1400" height="933">
<img class="kodza-landing-reveal__card" src="https://kodza.dev/media/kz-arch-1400.webp" alt="" data-caption="border-radius" width="1400" height="1400">
</div>
<p class="kodza-landing-reveal__caption"></p>
<p class="kodza-landing-reveal__count">000</p>
<p class="kodza-landing-reveal__note">Loading the good bits</p>
</div>
<header class="kodza-landing-reveal__bar">
<a class="kodza-landing-reveal__logo" href="#">off<span>·</span>by<span>·</span>one</a>
<nav class="kodza-landing-reveal__nav" aria-label="Main" data-enter="down">
<a href="#">Work</a>
<a href="#">Studio</a>
<a href="#">Journal</a>
</nav>
<a class="kodza-landing-reveal__button kodza-landing-reveal__button--small" href="#" data-enter="down">Start a project</a>
</header>
<div class="kodza-landing-reveal__hero">
<p class="kodza-landing-reveal__eyebrow" data-enter>Studio · est. 2019, give or take</p>
<div class="kodza-landing-reveal__grid">
<h1 class="kodza-landing-reveal__title">
<span class="kodza-landing-reveal__line">Hello,</span>
<span class="kodza-landing-reveal__line">first paint.</span>
</h1>
<div class="kodza-landing-reveal__side">
<p class="kodza-landing-reveal__lead" data-enter>Small studio, big deploys. Sites that load fast, look sharp and survive the client's nephew with admin access.</p>
<p class="kodza-landing-reveal__actions" data-enter>
<a class="kodza-landing-reveal__button" href="#">See the work</a>
<a class="kodza-landing-reveal__button kodza-landing-reveal__button--ghost" href="#">Book a call</a>
</p>
</div>
</div>
<figure class="kodza-landing-reveal__media">
<img class="kodza-landing-reveal__image" src="https://kodza.dev/media/kz-steps-dark-2400.webp" alt="Concrete steps climbing out of the dark" width="2400" height="1600" data-caption="steps(5, end)">
<figcaption data-enter><span>The last image on the pile</span><span>steps(5, end)</span></figcaption>
</figure>
</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 — Landing Reveal
A preloader that counts the files your hero really needs, while its images rise one by one
onto a pile, each with its caption (data-caption). At 100 the hero image comes up last and grows
until it fills the hero, the title rises from its mask, then the rest comes in. The logo is there
from the first frame, where the hero has it. A click or any key skips it. Mark what comes in
after the title with data-enter (data-enter="down" for what drops from the top); add data-once
to play it only once per visit. Everything is tuned with the --kz-landing-reveal-* variables. */
.kodza-landing-reveal {
--kz-landing-reveal-bg: #ffffff; /* the loader's paper: your page colour, so the hand-over is seamless */
--kz-landing-reveal-fg: #0a0a0a; /* the loader's counter and labels */
--kz-landing-reveal-muted: rgba(10, 10, 10, 0.62);
--kz-landing-reveal-line: rgba(10, 10, 10, 0.1);
--kz-landing-reveal-text: #f2f2f0; /* the hero, over its image */
--kz-landing-reveal-text-muted: rgba(242, 242, 240, 0.74);
--kz-landing-reveal-veil: rgba(10, 10, 10, 0.6); /* under the text, so it reads on any image */
--kz-landing-reveal-button: #f2f2f0;
--kz-landing-reveal-on-button: #0a0a0a;
--kz-landing-reveal-card: clamp(148px, 26vmin, 260px); /* width of the pile */
--kz-landing-reveal-radius: 0px; /* the hero image: 0 = edge to edge */
--kz-landing-reveal-height: 100svh; /* the hero; take your header off if it sits above */
--kz-landing-reveal-side: 24px;
--kz-landing-reveal-min: 2.2s; /* 100 never comes sooner, even with every file cached */
--kz-landing-reveal-max: 2.8s; /* nor later: past it, the hero comes in without the late files */
--kz-landing-reveal-ease: cubic-bezier(0.65, 0, 0.35, 1); /* the hero image growing into place */
position: relative;
isolation: isolate;
display: flex;
flex-direction: column;
box-sizing: border-box;
min-height: var(--kz-landing-reveal-height);
color: var(--kz-landing-reveal-text);
text-align: left;
}
/* ---------- The loader: over the whole screen, from the very first paint ---------- */
/* Its rules never depend on the hero: while it plays, it waits at the end of the page */
.kodza-landing-reveal__loader {
position: fixed;
inset: 0;
z-index: 100000;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-rows: auto 1fr auto;
align-items: end;
gap: 16px;
box-sizing: border-box;
margin: 0;
padding: 20px var(--kz-landing-reveal-side, 24px);
overflow: hidden;
color: var(--kz-landing-reveal-fg, #0a0a0a);
font-size: 15px;
line-height: 1.4;
-webkit-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
/* The paper, apart from what lies on it, so it can lift while the last card grows */
.kodza-landing-reveal__loader::before {
content: "";
position: absolute;
inset: 0;
background: var(--kz-landing-reveal-bg, #ffffff);
}
.kodza-landing-reveal__loader[hidden] {
display: none;
}
/* No script (blocked, or very late): the page is never kept behind the loader */
.kodza-landing-reveal:not(.is-ready) .kodza-landing-reveal__loader {
animation: kodza-landing-reveal-failsafe 0.5s 3s both;
}
/* data-once, already seen this visit: the script says so before the first paint */
[data-kodza-landing-reveal="seen"] .kodza-landing-reveal[data-once] .kodza-landing-reveal__loader {
display: none;
}
/* A hero that comes after the first one (an app's next page, a theme's Ajax content): straight
on the hero, without a flash of loader. The script says so once the first one has been through */
[data-kodza-landing-reveal="played"] .kodza-landing-reveal:not(.is-ready) .kodza-landing-reveal__loader {
display: none;
}
@keyframes kodza-landing-reveal-failsafe {
to {
visibility: hidden;
opacity: 0;
}
}
.kodza-landing-reveal__caption,
.kodza-landing-reveal__tally,
.kodza-landing-reveal__note,
.kodza-landing-reveal__eyebrow,
.kodza-landing-reveal__media figcaption {
font-family: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
font-weight: 400;
line-height: 1.4;
letter-spacing: 0;
}
.kodza-landing-reveal__tally,
.kodza-landing-reveal__note,
.kodza-landing-reveal__count {
position: relative;
z-index: 1; /* above the cards on their way in */
margin: 0;
}
/* The hero's logo, where the hero has it, from the very first frame (the script puts it on the
pixel). White in difference: dark on the paper, light once the hero image is under it */
.kodza-landing-reveal__brand {
position: absolute;
top: 28px;
left: var(--kz-landing-reveal-side, 24px);
z-index: 3;
margin: 0;
color: #ffffff;
font-size: 20px;
font-weight: 600;
line-height: normal;
letter-spacing: -0.04em;
white-space: nowrap;
mix-blend-mode: difference;
}
.kodza-landing-reveal__brand span {
opacity: 0.6;
}
.kodza-landing-reveal__tally {
grid-column: 2;
grid-row: 1;
align-self: start;
justify-self: end;
color: var(--kz-landing-reveal-muted, rgba(10, 10, 10, 0.62));
font-variant-numeric: tabular-nums;
}
/* What the script brings in waits out of sight until it starts counting */
.kodza-landing-reveal__loader:not(.is-counting) .kodza-landing-reveal__tally,
.kodza-landing-reveal__loader:not(.is-counting) .kodza-landing-reveal__count,
.kodza-landing-reveal__loader:not(.is-counting) .kodza-landing-reveal__note,
.kodza-landing-reveal__loader:not(.is-counting) .kodza-landing-reveal__caption {
visibility: hidden;
}
/* Under the pile, the caption of the image on top: each new one rolls in from below */
.kodza-landing-reveal__caption {
position: absolute;
top: calc(50% + var(--kz-landing-reveal-card, 220px) * 0.625 + 14px);
left: calc(50% - var(--kz-landing-reveal-card, 220px) / 2);
z-index: 1;
display: grid;
width: var(--kz-landing-reveal-card, 220px);
margin: 0;
overflow: hidden;
color: var(--kz-landing-reveal-muted, rgba(10, 10, 10, 0.62));
}
.kodza-landing-reveal__caption span {
grid-area: 1 / 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transform: translateY(110%);
}
.kodza-landing-reveal__note {
grid-column: 2;
grid-row: 3;
padding-bottom: 0.5em;
color: var(--kz-landing-reveal-muted, rgba(10, 10, 10, 0.62));
text-align: right;
}
/* 000 to 100: one wheel per digit, rolled like an odometer */
.kodza-landing-reveal__count {
grid-column: 1;
grid-row: 3;
display: flex;
font-size: clamp(80px, 13vw, 176px);
font-weight: 500;
line-height: 1;
letter-spacing: -0.06em;
font-variant-numeric: tabular-nums;
}
.kodza-landing-reveal__wheel {
display: block;
height: 1em;
overflow: hidden;
overflow: visible clip; /* the roll is masked, never the side of a digit (tight spacing) */
}
.kodza-landing-reveal__digits {
display: block;
white-space: pre;
will-change: transform;
}
/* The pile, in the middle of the screen: the images rise one over the other from an invisible
edge, like lines of text from their mask */
.kodza-landing-reveal__deck {
position: absolute;
top: 50%;
left: 50%;
width: var(--kz-landing-reveal-card, 220px);
aspect-ratio: 4 / 5;
translate: -50% -50%;
}
.kodza-landing-reveal__card {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
max-width: none;
margin: 0;
border-radius: 10px;
object-fit: cover;
background: var(--kz-landing-reveal-bg, #ffffff); /* opaque even before the image paints */
clip-path: inset(0 0 100% 0); /* below its edge until the script brings it up */
}
/* The hero image, copied into the loader: it comes up last onto the pile, then grows into place */
.kodza-landing-reveal__last {
position: absolute;
display: block;
max-width: none;
margin: 0;
object-fit: cover;
background: var(--kz-landing-reveal-bg, #ffffff);
transform-origin: 50% 50%;
}
.kodza-landing-reveal__last[hidden] {
display: none;
}
/* ---------- The hero: its image fills it, the text sits on it ---------- */
.kodza-landing-reveal__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 20px var(--kz-landing-reveal-side);
}
.kodza-landing-reveal__logo {
border-radius: 4px;
color: inherit;
font-size: 20px;
font-weight: 600;
letter-spacing: -0.04em;
text-decoration: none;
white-space: nowrap;
}
.kodza-landing-reveal__logo span {
color: var(--kz-landing-reveal-text-muted);
}
.kodza-landing-reveal__nav {
display: flex;
gap: 2px;
}
.kodza-landing-reveal__nav a {
display: inline-flex;
align-items: center;
height: 40px;
padding: 0 14px;
border-radius: 999px;
color: inherit;
font-size: 15px;
font-weight: 500;
letter-spacing: -0.01em;
text-decoration: none;
transition: background-color 0.2s ease;
}
.kodza-landing-reveal__nav a:hover {
background: color-mix(in srgb, currentColor 14%, transparent);
}
.kodza-landing-reveal__button {
display: inline-flex;
align-items: center;
height: 48px;
padding: 0 22px;
border-radius: 999px;
background: var(--kz-landing-reveal-button);
color: var(--kz-landing-reveal-on-button);
font-size: 15px;
font-weight: 500;
letter-spacing: -0.01em;
text-decoration: none;
white-space: nowrap;
transition: opacity 0.2s ease;
}
.kodza-landing-reveal__button:hover {
opacity: 0.86;
}
.kodza-landing-reveal__button--small {
height: 40px;
padding: 0 18px;
}
.kodza-landing-reveal__button--ghost {
background: transparent;
color: inherit;
box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 40%, transparent);
}
.kodza-landing-reveal__logo:focus-visible,
.kodza-landing-reveal__nav a:focus-visible,
.kodza-landing-reveal__button:focus-visible {
outline: 2px solid currentColor;
outline-offset: 3px;
}
/* The text at the bottom, above the caption row */
.kodza-landing-reveal__hero {
flex: 1;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 32px var(--kz-landing-reveal-side) 96px;
}
.kodza-landing-reveal__eyebrow {
margin: 0 0 20px;
color: var(--kz-landing-reveal-text-muted);
}
.kodza-landing-reveal__grid {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
align-items: end;
gap: 28px 64px;
}
.kodza-landing-reveal__title {
margin: 0;
font-size: clamp(44px, min(8vw, 14vh), 116px); /* the hero fits a short screen too */
font-weight: 600;
line-height: 0.92;
letter-spacing: -0.055em;
}
/* Each line rises from behind its own edge */
.kodza-landing-reveal__line {
display: block;
}
.kodza-landing-reveal__side {
display: grid;
justify-items: start;
gap: 24px;
padding-bottom: 4px;
}
.kodza-landing-reveal__lead {
max-width: 30em;
margin: 0;
color: var(--kz-landing-reveal-text-muted);
font-size: clamp(16px, 1.4vw, 18px);
line-height: 1.5;
text-wrap: pretty;
}
.kodza-landing-reveal__actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 0;
}
/* The image fills the whole hero, under a veil that keeps the text legible */
.kodza-landing-reveal__media {
position: absolute;
inset: 0;
z-index: -1;
margin: 0;
}
.kodza-landing-reveal__media::after {
content: "";
position: absolute;
inset: 0;
border-radius: var(--kz-landing-reveal-radius);
background:
linear-gradient(to top, var(--kz-landing-reveal-veil), color-mix(in srgb, var(--kz-landing-reveal-veil) 75%, transparent) 38%, transparent 74%),
linear-gradient(to bottom, var(--kz-landing-reveal-veil), transparent 22%);
pointer-events: none;
}
.kodza-landing-reveal__image {
display: block;
width: 100%;
height: 100%;
max-width: none;
margin: 0;
border-radius: var(--kz-landing-reveal-radius);
object-fit: cover;
}
/* Hidden while its copy plays the last card, shown the moment the copy lands on it */
.kodza-landing-reveal.is-intro:not(.is-landed) .kodza-landing-reveal__image {
visibility: hidden;
}
.kodza-landing-reveal__media figcaption {
position: absolute;
right: var(--kz-landing-reveal-side);
bottom: 20px;
left: var(--kz-landing-reveal-side);
z-index: 1;
display: flex;
justify-content: space-between;
gap: 16px;
padding-top: 12px;
border-top: 1px solid color-mix(in srgb, currentColor 22%, transparent);
color: var(--kz-landing-reveal-text-muted);
}
@media (max-width: 860px) {
.kodza-landing-reveal__grid {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 640px) {
.kodza-landing-reveal__nav {
display: none;
}
.kodza-landing-reveal__media figcaption span + span {
display: none;
}
}
/* Reduced motion: no intro, the hero straight away */
@media (prefers-reduced-motion: reduce) {
.kodza-landing-reveal__loader {
display: none;
}
.kodza-landing-reveal__nav a,
.kodza-landing-reveal__button {
transition-duration: 0.01ms !important;
}
}
/* Theming example — on a dark site, the loader's paper turns black:
.dark-site .kodza-landing-reveal {
--kz-landing-reveal-bg: #0a0a0a;
--kz-landing-reveal-fg: #f2f2f0;
--kz-landing-reveal-muted: rgba(242, 242, 240, 0.62);
--kz-landing-reveal-line: rgba(242, 242, 240, 0.14);
}
*/
/* kodza-guard a4777eb8b884 — 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-landing-reveal__logo):not(#_) {
outline: revert;
outline-offset: revert;
}
:where(.kodza-landing-reveal__button):not(#_) {
outline: revert;
box-shadow: revert;
outline-offset: revert;
}
:where(.kodza-landing-reveal__nav a:where(:not([class*="kodza-"]))):not(#_) {
outline: revert;
background-color: revert;
background-image: revert;
outline-offset: revert;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-landing-reveal__bar:not(#_) {
border: revert;
margin: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-landing-reveal__logo:not(#_) {
border: revert;
margin: revert;
padding: 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-style: revert;
line-height: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-landing-reveal__nav:not(#_) {
border: revert;
margin: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-landing-reveal__button:not(#_) {
border: revert;
margin: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: 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;
}
.kodza-landing-reveal__hero:not(#_),
.kodza-landing-reveal__grid:not(#_),
.kodza-landing-reveal__line:not(#_),
.kodza-landing-reveal__side:not(#_),
.kodza-landing-reveal__done:not(#_),
.kodza-landing-reveal__total:not(#_),
.kodza-landing-reveal__deck:not(#_),
.kodza-landing-reveal__wheel:not(#_),
.kodza-landing-reveal__digits:not(#_),
.kodza-landing-reveal__logo span:where(:not([class*="kodza-"])):not(#_),
.kodza-landing-reveal__media span:where(:not([class*="kodza-"]):not(.kodza-landing-reveal__media [class*="kodza-"] *)):not(#_),
.kodza-landing-reveal__brand span:where(:not([class*="kodza-"])):not(#_),
.kodza-landing-reveal__caption span:where(:not([class*="kodza-"])):not(#_) {
box-sizing: revert;
}
.kodza-landing-reveal__eyebrow: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-style: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-landing-reveal__title: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;
color: 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;
}
.kodza-landing-reveal__lead: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-height: revert;
font-family: revert;
font-weight: revert;
font-style: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-landing-reveal__actions: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;
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-landing-reveal__media:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-landing-reveal__image:not(#_) {
border: revert;
padding: 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-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-landing-reveal__brand:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
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;
}
.kodza-landing-reveal__tally:not(#_),
.kodza-landing-reveal__caption:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-style: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-landing-reveal__card:not(#_) {
border: revert;
padding: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: 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-landing-reveal__count:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-style: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-landing-reveal__note:not(#_) {
border: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
padding-top: revert;
padding-right: revert;
padding-left: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-style: revert;
text-transform: revert;
text-underline-offset: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-landing-reveal__last:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: 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-landing-reveal__nav a:where(:not([class*="kodza-"])):not(#_) {
border: revert;
margin: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
box-shadow: 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;
}
.kodza-landing-reveal__media figcaption:where(:not([class*="kodza-"]):not(.kodza-landing-reveal__media [class*="kodza-"] *)):not(#_) {
margin: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
padding-right: revert;
padding-bottom: revert;
padding-left: 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;
font-style: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-landing-reveal__logo:not(#_):where(:not([hidden])),
.kodza-landing-reveal__eyebrow:not(#_):where(:not([hidden])),
.kodza-landing-reveal__title:not(#_):where(:not([hidden])),
.kodza-landing-reveal__lead:not(#_):where(:not([hidden])),
.kodza-landing-reveal__media:not(#_):where(:not([hidden])),
.kodza-landing-reveal__brand:not(#_):where(:not([hidden])),
.kodza-landing-reveal__tally:not(#_):where(:not([hidden])),
.kodza-landing-reveal__note:not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-landing-reveal__logo:not(#_):where([hidden]),
.kodza-landing-reveal__eyebrow:not(#_):where([hidden]),
.kodza-landing-reveal__title:not(#_):where([hidden]),
.kodza-landing-reveal__lead:not(#_):where([hidden]),
.kodza-landing-reveal__media:not(#_):where([hidden]),
.kodza-landing-reveal__brand:not(#_):where([hidden]),
.kodza-landing-reveal__tally:not(#_):where([hidden]),
.kodza-landing-reveal__note:not(#_):where([hidden]) {
display: none;
}
.kodza-landing-reveal__loader:not(#_)::before,
.kodza-landing-reveal__media:not(#_)::after {
box-sizing: revert;
}
/* The component's own rules, one step stronger: real buttons and fields, what it sets on tags themes style (links, lists, headings, images…), and the root width (a theme may narrow it, never widen it) */
.kodza-landing-reveal__caption:not(#_),
.kodza-landing-reveal__tally:not(#_),
.kodza-landing-reveal__note:not(#_),
.kodza-landing-reveal__eyebrow:not(#_),
.kodza-landing-reveal__media figcaption:not(#_) {
font-family: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
font-weight: 400;
line-height: 1.4;
letter-spacing: 0;
}
.kodza-landing-reveal__tally:not(#_),
.kodza-landing-reveal__note:not(#_),
.kodza-landing-reveal__count:not(#_) {
position: relative;
margin: 0;
}
.kodza-landing-reveal__brand:not(#_) {
position: absolute;
margin: 0;
color: #ffffff;
font-size: 20px;
font-weight: 600;
line-height: normal;
letter-spacing: -0.04em;
}
.kodza-landing-reveal__tally:not(#_) {
color: var(--kz-landing-reveal-muted, rgba(10, 10, 10, 0.62));
}
.kodza-landing-reveal__caption:not(#_) {
position: absolute;
display: grid;
margin: 0;
color: var(--kz-landing-reveal-muted, rgba(10, 10, 10, 0.62));
}
.kodza-landing-reveal__note:not(#_) {
padding-bottom: 0.5em;
color: var(--kz-landing-reveal-muted, rgba(10, 10, 10, 0.62));
text-align: right;
}
.kodza-landing-reveal__count:not(#_) {
display: flex;
font-size: clamp(80px, 13vw, 176px);
font-weight: 500;
line-height: 1;
letter-spacing: -0.06em;
}
.kodza-landing-reveal__card:not(#_) {
position: absolute;
display: block;
max-width: none;
margin: 0;
border-radius: 10px;
background: var(--kz-landing-reveal-bg, #ffffff);
}
.kodza-landing-reveal__last:not(#_) {
position: absolute;
display: block;
max-width: none;
margin: 0;
background: var(--kz-landing-reveal-bg, #ffffff);
}
.kodza-landing-reveal__last[hidden]:not(#_) {
display: none;
}
.kodza-landing-reveal__bar:not(#_) {
display: flex;
padding: 20px var(--kz-landing-reveal-side);
}
.kodza-landing-reveal__logo:not(#_) {
border-radius: 4px;
color: inherit;
font-size: 20px;
font-weight: 600;
letter-spacing: -0.04em;
text-decoration: none;
}
.kodza-landing-reveal__nav:not(#_) {
display: flex;
}
.kodza-landing-reveal__nav a:not(#_) {
display: inline-flex;
padding: 0 14px;
border-radius: 999px;
color: inherit;
font-size: 15px;
font-weight: 500;
letter-spacing: -0.01em;
text-decoration: none;
transition: background-color 0.2s ease;
}
.kodza-landing-reveal__nav a:hover:not(#_) {
background: color-mix(in srgb, currentColor 14%, transparent);
}
.kodza-landing-reveal__button:not(#_) {
display: inline-flex;
padding: 0 22px;
border-radius: 999px;
background: var(--kz-landing-reveal-button);
color: var(--kz-landing-reveal-on-button);
font-size: 15px;
font-weight: 500;
letter-spacing: -0.01em;
text-decoration: none;
transition: opacity 0.2s ease;
}
.kodza-landing-reveal__button--small:not(#_) {
padding: 0 18px;
}
.kodza-landing-reveal__button--ghost:not(#_) {
background: transparent;
color: inherit;
box-shadow: inset 0 0 0 1px color-mix(in srgb, currentColor 40%, transparent);
}
.kodza-landing-reveal__logo:focus-visible:not(#_),
.kodza-landing-reveal__nav a:focus-visible:not(#_),
.kodza-landing-reveal__button:focus-visible:not(#_) {
outline: 2px solid currentColor;
outline-offset: 3px;
}
.kodza-landing-reveal__eyebrow:not(#_) {
margin: 0 0 20px;
color: var(--kz-landing-reveal-text-muted);
}
.kodza-landing-reveal__title:not(#_) {
margin: 0;
font-size: clamp(44px, min(8vw, 14vh), 116px);
font-weight: 600;
line-height: 0.92;
letter-spacing: -0.055em;
}
.kodza-landing-reveal__lead:not(#_) {
max-width: 30em;
margin: 0;
color: var(--kz-landing-reveal-text-muted);
font-size: clamp(16px, 1.4vw, 18px);
line-height: 1.5;
}
.kodza-landing-reveal__actions:not(#_) {
display: flex;
margin: 0;
}
.kodza-landing-reveal__media:not(#_) {
position: absolute;
margin: 0;
}
.kodza-landing-reveal__image:not(#_) {
display: block;
max-width: none;
margin: 0;
border-radius: var(--kz-landing-reveal-radius);
}
.kodza-landing-reveal__media figcaption:not(#_) {
position: absolute;
display: flex;
padding-top: 12px;
border-top: 1px solid color-mix(in srgb, currentColor 22%, transparent);
color: var(--kz-landing-reveal-text-muted);
}
@media (max-width: 640px) {
.kodza-landing-reveal__nav:not(#_) {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.kodza-landing-reveal__nav a:not(#_),
.kodza-landing-reveal__button:not(#_) {
transition-duration: 0.01ms !important;
}
}
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — Landing Reveal
// A preloader for the first visit: it counts the files the hero really needs (its images,
// decoded, and the fonts) while images come up one by one onto a pile. At 100 the hero image
// comes up last and grows into its place, the title rises from its mask, then the rest comes in.
// A click or any key skips it; data-once plays it once per visit; "kodza:replay" plays it again.
// A script that comes after the page (Next) still plays it; the heroes that come later (an app's
// next page) open straight on the hero.
// Built in three parts, so the other reveals of the family only swap the middle one:
// 1. counting — real progress, paced between a minimum and a maximum time
// 2. hand-over — this reveal's own move (here: the pile, then its last image into the hero)
// 3. entrance — the title lines, then everything marked data-enter
(() => {
const SELECTOR = '.kodza-landing-reveal';
const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
const RISE = 'cubic-bezier(0.22, 1, 0.36, 1)';
const SWING = 'cubic-bezier(0.65, 0, 0.35, 1)';
const SEEN = 'kodza-landing-reveal'; // sessionStorage key, for data-once
const VARS = ['bg', 'fg', 'muted', 'line', 'card', 'side']; // what the loader takes along when it leaves the hero
const HOLD_KEYS = [' ', 'PageUp', 'PageDown', 'Home', 'End', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'];
const BEAT = 600; // one image every 0.6 s: each one is seen, then covered
const LAST = 820; // the hero image rises onto the pile while the count ends: 100 as it settles
const inOut = (p) => (p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2); // SWING, in JS
let playing = null; // one intro at a time on a page
// Seen already this visit: the loader of a data-once hero must not show, not even for a frame
try {
if (sessionStorage.getItem(SEEN)) document.documentElement.setAttribute('data-kodza-landing-reveal', 'seen');
} catch {
// storage blocked: the intro simply plays
}
const seconds = (value, fallback) => {
const n = parseFloat(value);
if (!isFinite(n)) return fallback;
return /ms$/.test(value) ? n : n * 1000;
};
// The counter: one wheel per digit (0 to 9, then 0 again), turned like an odometer: a digit
// only moves on when the one to its right goes from 9 to 0
const wheels = (count) => {
count.textContent = '';
return [0, 1, 2].map(() => {
const wheel = document.createElement('span');
const digits = document.createElement('span');
wheel.className = 'kodza-landing-reveal__wheel';
digits.className = 'kodza-landing-reveal__digits';
digits.textContent = '0\n1\n2\n3\n4\n5\n6\n7\n8\n9\n0';
wheel.append(digits);
count.append(wheel);
return digits;
});
};
const roll = (digits, value) => {
// Each number holds, then clicks over to the next one: legible even while it counts
const whole = Math.floor(value);
const step = Math.max(0, (value - whole - 0.6) / 0.4);
const ones = (whole + step * step * (3 - 2 * step)) % 10;
const carry = Math.max(0, ones - 9);
const turns = [value >= 100 ? 1 : Math.max(0, value - 99), (Math.floor(value / 10) % 10) + carry, ones];
digits.forEach((strip, i) => {
strip.style.transform = `translateY(${(-turns[i]).toFixed(3)}em)`;
});
};
// One instance. Everything it starts is tied to `signal`, so kodza:destroy stops it all
function mount(root, signal) {
const loader = root.querySelector('.kodza-landing-reveal__loader');
if (!loader) return;
const image = root.querySelector('.kodza-landing-reveal__image');
const media = root.querySelector('.kodza-landing-reveal__media');
const deck = loader.querySelector('.kodza-landing-reveal__deck');
const cards = [...loader.querySelectorAll('.kodza-landing-reveal__card')];
const count = loader.querySelector('.kodza-landing-reveal__count');
const done = loader.querySelector('.kodza-landing-reveal__done');
const total = loader.querySelector('.kodza-landing-reveal__total');
const small = [...loader.querySelectorAll('.kodza-landing-reveal__tally, .kodza-landing-reveal__note, .kodza-landing-reveal__caption')];
const brand = loader.querySelector('.kodza-landing-reveal__brand');
const logo = root.querySelector('.kodza-landing-reveal__logo');
const caption = loader.querySelector('.kodza-landing-reveal__caption');
const lines = [...root.querySelectorAll('.kodza-landing-reveal__line')];
const items = [...root.querySelectorAll('[data-enter]')].filter((el) => !loader.contains(el));
const digits = count ? wheels(count) : [];
// One caption per image of the pile (data-caption), the hero image's last
const notes = caption ? [...cards, image].map((img) => {
const span = document.createElement('span');
span.textContent = img?.dataset.caption || '';
caption.append(span);
return span;
}) : [];
const home = [loader.parentNode, loader.nextSibling];
const css = (name) => getComputedStyle(root).getPropertyValue(`--kz-landing-reveal-${name}`).trim();
// The last card is the hero image itself, copied: same file, same crop, so the hand-over
// ends exactly on it
let last = null;
if (image) {
last = image.cloneNode(false);
last.className = 'kodza-landing-reveal__last';
last.alt = '';
last.removeAttribute('loading');
last.hidden = true;
loader.append(last);
}
let run = null; // the intro being played
// The page stays where it is while the loader is up: the wheel, a swipe or the scroll keys
// move nothing. A dragged scrollbar is put back
loader.addEventListener('wheel', (event) => event.preventDefault(), { passive: false, signal });
loader.addEventListener('touchmove', (event) => event.preventDefault(), { passive: false, signal });
loader.addEventListener('click', () => skip(), { signal });
window.addEventListener('scroll', () => {
if (run?.hold && (scrollX !== run.hold[0] || scrollY !== run.hold[1])) window.scrollTo({ left: run.hold[0], top: run.hold[1], behavior: 'instant' });
}, { signal });
// Any key skips it (shortcuts with Ctrl, Cmd or Alt still work, and Tab still moves on)
document.addEventListener('keydown', (event) => {
if (!run || event.ctrlKey || event.metaKey || event.altKey || /^(Shift|Control|Alt|Meta|CapsLock|Fn)$/.test(event.key)) return;
if (run.hold && HOLD_KEYS.includes(event.key)) event.preventDefault();
skip();
}, { signal });
// Once the loader is gone, a click anywhere hurries the entrance along
document.addEventListener('pointerdown', () => run?.phase === 'enter' && skip(), { signal });
root.addEventListener('kodza:replay', () => !motion.matches && start(true), { signal });
// Back from another page (bfcache) halfway through: the hero as it ends up
window.addEventListener('pageshow', (event) => event.persisted && run && end(), { signal });
// ---------- 1. Counting ----------
// What the hero really waits for: its images (decoded if they are still coming, so they
// paint at once) and the fonts. A file counts once it is in, or once it failed: a broken one
// never holds the page
const track = () => {
const jobs = [...cards, image].filter(Boolean).map((img) => (img.complete && img.naturalWidth ? Promise.resolve() : img.decode()));
const token = run;
if (document.fonts) {
jobs.push(document.fonts.ready);
document.fonts.ready.then(() => run === token && fit()); // the logo moves with its font
}
jobs.forEach((job) => job.then(() => {}, () => {}).then(() => {
if (run === token) run.done++;
}));
if (total) total.textContent = String(jobs.length);
if (done) done.textContent = '0';
return jobs.length;
};
// The number follows the files and is never ahead of them. Once they are all in (and not
// before the minimum, nor after the maximum), the hero image rises onto the pile and the
// count ends with it: 100 the moment it settles
const tick = (now) => {
if (!run || (run.phase !== 'load' && run.phase !== 'last')) return;
run.t0 = run.t0 || now; // a tab opened in the background starts counting when it is shown
const t = now - run.t0;
const dt = Math.min(64, now - (run.at || now));
run.at = now;
if (run.phase === 'load') {
const pace = 1 - Math.pow(1 - Math.min(1, t / run.min), 2);
const target = Math.floor(Math.min(run.done / run.total, pace) * 100) / 100;
run.value += (target - run.value) * (1 - Math.exp(-dt / 110));
if (Math.abs(target - run.value) < 0.004) run.value = target; // waiting for a file: a whole number, not a digit half rolled
const ready = run.done >= run.total && t >= run.min - LAST;
// (never on top of an image still rising, unless the maximum is reached)
if ((ready && now >= run.beat - BEAT * 0.25) || t >= run.max - LAST) {
if (!rise(now)) return;
} else if (now >= run.beat) {
reveal(cards[run.next % cards.length]);
run.beat = now + BEAT;
}
} else {
const p = Math.min(1, (now - run.from[1]) / LAST);
run.value = run.from[0] + (1 - run.from[0]) * inOut(p);
}
roll(digits, run.value * 100);
// The files shown keep up with the number, so the two never contradict each other
const shown = Math.min(run.done, Math.floor(run.value * run.total + 1e-6));
if (done && shown !== run.shown) done.textContent = String((run.shown = shown));
if (run.phase === 'last' && run.value >= 1) {
handover();
return;
}
run.frame = requestAnimationFrame(tick);
};
// ---------- 2. Hand-over: the pile ----------
const stack = () => run.order.forEach((card, i) => (card.style.zIndex = String(run.order.length - i)));
// One pile: each image rises over the one before from behind its own bottom edge, like a line
// of text from its mask (no frame, nothing shows before it). `lead`: a head start to wait for
const reveal = (card, lead = 0) => {
if (!card) return;
run.order = [card, ...run.order.filter((other) => other !== card)]; // top first
stack();
run.next++;
say(cards.indexOf(card), lead);
// (the edge it rises from is as round as the image: the pile below keeps its corners)
const round = `round ${parseFloat(getComputedStyle(card).borderTopLeftRadius) || 0}px`;
run.anims.push(card.animate([
{ transform: 'translateY(100%)', clipPath: `inset(0% 0% 100% 0% ${round})` },
{ transform: 'translateY(0%)', clipPath: `inset(0% 0% 0% 0% ${round})` },
], { duration: 800, delay: lead, easing: SWING, fill: 'both' }));
};
const add = (el, frames, delay, duration, easing = RISE, extra = {}) => {
const animation = el.animate(frames, { delay, duration, easing, fill: 'both', ...extra });
run.anims.push(animation);
return animation;
};
// The caption under the pile rolls with the image: the old one up, the new one from below
const say = (i, lead = 0) => {
const next = notes[i];
if (!next || !next.textContent) return;
const before = notes[run.said];
if (before && before !== next) add(before, [{ transform: 'translateY(0%)' }, { transform: 'translateY(-110%)' }], lead, 800, SWING);
add(next, [{ transform: 'translateY(110%)' }, { transform: 'translateY(0%)' }], lead, 800, SWING);
run.said = i;
};
// The loader's own entrance: the digits rise from their masks one by one, then the small print
const arrive = (lead = 0) => {
const up = (el, delay) => el && add(el, [
{ transform: 'translateY(100%)', clipPath: 'inset(0% -30% 100% -30%)' },
{ transform: 'translateY(0%)', clipPath: 'inset(0% -30% 0% -30%)' },
], lead + delay, 900);
digits.forEach((strip, i) => up(strip.parentNode, 80 + i * 70));
small.filter((el) => el !== caption).forEach((el, i) => up(el, 240 + i * 80));
};
// The hero's logo, on the pixel where the hero has it: the header is there from the start
const fit = () => {
if (!brand || !logo) return;
brand.innerHTML = logo.innerHTML;
const box = logo.getBoundingClientRect();
const frame = loader.getBoundingClientRect();
const k = loader.offsetWidth ? frame.width / loader.offsetWidth : 1;
const style = getComputedStyle(logo);
Object.assign(brand.style, {
left: `${(box.left - frame.left) / k}px`,
top: `${(box.top - frame.top) / k}px`,
fontFamily: style.fontFamily,
fontSize: style.fontSize,
fontWeight: style.fontWeight,
letterSpacing: style.letterSpacing,
});
};
// The last image of the pile is the hero image itself: its copy sits on the hero image,
// scaled down and cut to the size of the frame (the same crop as object-fit: cover there),
// and rises like the others while the count ends
const rise = (now) => {
const box = image?.getBoundingClientRect();
if (!last || !box || !box.width || !box.height) {
skip();
return false;
}
run.phase = 'last';
run.from = [run.value, now];
const frame = loader.getBoundingClientRect();
const k = loader.offsetWidth ? frame.width / loader.offsetWidth : 1; // a scaled page
const spot = deck.getBoundingClientRect();
const w = box.width / k;
const h = box.height / k;
const cw = spot.width / k;
const ch = spot.height / k;
const s = Math.max(cw / w, ch / h);
const dx = (spot.left + spot.width / 2 - box.left - box.width / 2) / k;
const dy = (spot.top + spot.height / 2 - box.top - box.height / 2) / k;
const round = parseFloat(getComputedStyle(cards[0] || deck).borderTopLeftRadius) || 0;
Object.assign(last.style, {
left: `${(box.left - frame.left) / k}px`,
top: `${(box.top - frame.top) / k}px`,
width: `${w}px`,
height: `${h}px`,
objectPosition: getComputedStyle(image).objectPosition,
zIndex: '1',
});
last.hidden = false;
// On the pile, `below` (0 to 1) of its height still under the edge it rises from, like the
// other images: moved down, and cut at that edge
const fh = ch / s;
const side = ((w - cw / s) / 2).toFixed(2);
const edge = (h - fh) / 2;
const r = (round / s).toFixed(2);
const piled = (below) => ({
transform: `translate(${dx.toFixed(2)}px, ${(dy + below * ch).toFixed(2)}px) scale(${s.toFixed(4)})`,
clipPath: `inset(${edge.toFixed(2)}px ${side}px ${(edge + below * fh).toFixed(2)}px ${side}px round ${r}px)`,
});
// When the growing image will be under the logo, as a share of the growth (the paper must
// stay until then: the logo is drawn in difference, it would vanish on the bare page)
let cover = 0.3;
if (brand) {
const b = brand.getBoundingClientRect();
const need = (from, to, edge) => (from === to ? (edge ? 0 : 1) : Math.min(1, Math.max(0, (from - edge) / (from - to))));
const e = Math.max(
b.left < box.left || b.top < box.top || b.right > box.right || b.bottom > box.bottom ? 1 : 0,
spot.left > b.left ? need(spot.left, box.left, b.left) : 0,
spot.top > b.top ? need(spot.top, box.top, b.top) : 0,
spot.right < b.right ? need(spot.right, box.right, b.right) : 0,
spot.bottom < b.bottom ? need(spot.bottom, box.bottom, b.bottom) : 0,
);
// the time it takes on the growth's curve (in-out cubic), with a little margin
cover = Math.min(1, (e < 0.5 ? Math.cbrt(e / 4) : 1 - Math.cbrt(2 * (1 - e)) / 2) + 0.04);
}
run.geo = { dx, dy, cover, settled: piled(0) };
say(notes.length - 1);
add(last, [piled(1), run.geo.settled], 0, LAST, SWING);
return true;
};
// At 100: the counter rolls away while the hero image grows into its place in the page, the
// pile riding under it and the paper lifting. Then the entrance
const handover = () => {
run.phase = 'handover';
const { dx, dy, cover, settled } = run.geo;
const radius = parseFloat(getComputedStyle(image).borderTopLeftRadius) || 0;
const ease = css('ease') || SWING;
const land = 300; // a beat on 100, the hero image settled on the pile
const grow = 900; // then the hero image grows until it fills the hero
// (cut above and below only: a digit may spill a little sideways)
digits.forEach((strip, i) => add(strip.parentNode, [{ transform: 'translateY(0%)', clipPath: 'inset(0% -30% 0% -30%)' }, { transform: 'translateY(-100%)', clipPath: 'inset(100% -30% 0% -30%)' }], land + i * 30, 500, SWING));
small.forEach((el, i) => add(el, [{ transform: 'translateY(0%)', clipPath: 'inset(0% 0% 0% 0%)' }, { transform: 'translateY(-100%)', clipPath: 'inset(100% 0% 0% 0%)' }], land + 40 + i * 40, 480, SWING));
// (filled forwards only: until it starts, the rise still shows)
const growth = add(last, [settled, { transform: 'translate(0px, 0px) scale(1)', clipPath: `inset(0px 0px 0px 0px round ${radius}px)` }], land, grow, ease, { fill: 'forwards' });
// The paper goes once the image is under the logo, by the time the image has landed
const lift = Math.max(0.3, cover);
add(loader, [{ opacity: 1 }, { opacity: 0 }], land + grow * lift, Math.max(1, grow * Math.min(0.45, 1 - lift)), 'ease', { pseudoElement: '::before' });
// The pile rides under the growing image and lets go of it: nothing is left to take away
add(deck, [
{ transform: 'translate(0px, 0px)', opacity: 1 },
{ opacity: 0, offset: 0.6 },
{ transform: `translate(${(-dx).toFixed(2)}px, ${(-dy).toFixed(2)}px)`, opacity: 0 },
], land, grow, ease, { fill: 'forwards' });
// The image fills the hero: the text and its veil come in once it has landed, on top of it
const enterAt = land + grow;
run.entrance = media ? [add(media, [{ opacity: 0 }, { opacity: 1 }], enterAt, 500, 'ease', { pseudoElement: '::after' })] : [];
lines.forEach((line, i) => run.entrance.push(add(line, [
{ transform: 'translateY(105%)', clipPath: 'inset(-20% -10% 100% -10%)' },
{ transform: 'translateY(0%)', clipPath: 'inset(-20% -10% -30% -10%)' },
], enterAt + 120 + i * 110, 1000)));
const after = enterAt + 280;
items.forEach((el, i) => {
const y = el.dataset.enter === 'down' ? -12 : 14;
run.entrance.push(add(el, [{ opacity: 0, transform: `translateY(${y}px)` }, { opacity: 1, transform: 'translateY(0px)' }], after + i * 50, 600));
});
// The copy has landed on the hero image: the real one shows, the loader goes
const token = run;
growth.finished.then(() => {
if (run !== token || run.phase !== 'handover') return;
run.phase = 'enter';
run.hold = null;
root.classList.add('is-landed');
putBack();
}, () => {});
Promise.all([growth, ...run.entrance].map((animation) => animation.finished)).then(() => run === token && end(), () => {});
};
// ---------- 3. Entrance, and the way out ----------
// The loader leaves the end of the page and goes back into the hero, hidden
const putBack = () => {
loader.getAnimations({ subtree: true }).forEach((animation) => animation.cancel());
loader.hidden = true;
loader.classList.remove('is-counting');
loader.removeAttribute('style');
if (last) {
last.hidden = true;
last.removeAttribute('style');
}
cards.forEach((card) => card.removeAttribute('style'));
brand?.removeAttribute('style');
roll(digits, 0);
digits.forEach((strip) => strip.removeAttribute('style'));
if (loader.parentNode !== home[0]) home[0].insertBefore(loader, home[1]?.parentNode === home[0] ? home[1] : null);
};
// Everything as it ends up, at once
const end = () => {
if (!run) return;
cancelAnimationFrame(run.frame);
run.anims.forEach((animation) => animation.cancel());
run = null;
if (playing === end) playing = null;
putBack();
root.classList.remove('is-intro', 'is-landed');
root.removeAttribute('aria-busy');
};
// Skipped: the hero is set as it ends up, the loader stops where it is and lifts off it like
// a curtain. During the entrance, the rest just plays faster
const skip = () => {
if (!run || run.phase === 'skip') return;
if (run.phase === 'enter') {
run.entrance.forEach((animation) => animation.updatePlaybackRate(6));
return;
}
cancelAnimationFrame(run.frame);
run.phase = 'skip';
run.hold = null;
run.entrance.forEach((animation) => animation.cancel());
run.anims.forEach((animation) => animation.playState !== 'idle' && animation.pause());
root.classList.add('is-landed');
loader.style.pointerEvents = 'none';
const token = run;
// From where the curtain is (halfway down when the replay is skipped): none would not move
const from = getComputedStyle(loader).clipPath;
const lift = loader.animate([{ clipPath: from.startsWith('inset') ? from : 'inset(0% 0% 0% 0%)' }, { clipPath: 'inset(0% 0% 100% 0%)' }], { duration: 620, easing: SWING, fill: 'forwards' });
run.anims.push(lift);
lift.finished.then(() => run === token && end(), () => {});
};
const start = (replay) => {
if (playing && playing !== end) playing();
end();
if (!image || !last) return;
if (root.hasAttribute('data-once')) {
try {
sessionStorage.setItem(SEEN, '1');
} catch {
// storage blocked: it plays on every page
}
}
run = {
phase: 'load', anims: [], entrance: [], order: [], next: 0, said: -1, value: 0, done: 0, shown: -1, total: 0, t0: 0, at: 0, beat: 0, frame: 0, hold: null,
min: seconds(css('min'), 2200),
max: 0,
};
run.max = Math.max(run.min + 300, seconds(css('max'), 2800));
playing = end;
// The loader goes to the end of the page while it plays: a transformed parent cannot trap
// it, the styles of a theme's content cannot reach it. It takes the hero's settings along
VARS.forEach((name) => loader.style.setProperty(`--kz-landing-reveal-${name}`, css(name)));
loader.style.fontFamily = getComputedStyle(root).fontFamily;
document.body.append(loader);
loader.hidden = false;
fit();
root.setAttribute('aria-busy', 'true');
run.hold = [scrollX, scrollY];
roll(digits, 0);
run.total = track();
loader.classList.add('is-counting');
const token = run;
const begin = () => {
if (run !== token || run.phase !== 'load') return;
root.classList.add('is-intro');
// Played again from further down: the hero comes back to the top, behind the loader
if (replay) {
const top = root.getBoundingClientRect().top + scrollY;
window.scrollTo({ left: 0, top: top < innerHeight ? 0 : top, behavior: 'instant' });
run.hold = [scrollX, scrollY];
fit();
}
if (!replay) {
arrive(0);
reveal(cards[0], 60);
run.beat = performance.now() + 60 + BEAT;
}
run.frame = requestAnimationFrame(tick);
};
if (!replay) {
begin();
return;
}
// Played again: the paper first comes down over the page, like a curtain, and the first
// image is on its way up as it lands
const drop = loader.animate([{ clipPath: 'inset(0% 0% 100% 0%)' }, { clipPath: 'inset(0% 0% 0% 0%)' }], { duration: 650, easing: SWING });
run.anims.push(drop);
arrive(360);
reveal(cards[0], 420);
run.beat = performance.now() + 420 + BEAT;
drop.finished.then(begin, () => {});
};
// When it plays: its loader is still up on the screen, painted with the page and not yet let
// go by the stylesheet's failsafe (3 s without a script). However late the script comes (an
// app like Next loads it once the page is in), the show starts on the paper already there.
// For a hero at the top of the page, unless the visitor came for a #section further down
const up = () => {
if (!loader.getClientRects().length) return false;
const failsafe = loader.getAnimations().find((animation) => animation.animationName === 'kodza-landing-reveal-failsafe');
return !failsafe || failsafe.currentTime < failsafe.effect.getTiming().delay;
};
const ahead = () => {
try {
const target = location.hash && document.getElementById(decodeURIComponent(location.hash.slice(1)));
return !target || root.contains(target);
} catch {
return true;
}
};
const seen = () => {
try {
return root.hasAttribute('data-once') && !!sessionStorage.getItem(SEEN);
} catch {
return false;
}
};
const top = root.getBoundingClientRect().top + scrollY < innerHeight * 0.5;
if (up() && !motion.matches && top && ahead() && !seen()) start(false);
else loader.hidden = true;
// One hero has been through: those that come later (an app's next page, a theme's Ajax
// content, kodza:init again) open straight on the hero, their loader hidden by the
// stylesheet before it paints. Once this pass is over, so the other heroes of the page are
// judged like this one
queueMicrotask(() => document.documentElement.setAttribute('data-kodza-landing-reveal', 'played'));
// Stopped halfway (kodza:destroy): the hero as it ends up, the loader back in place
signal.addEventListener('abort', end);
}
// 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 — Landing Reveal, for React. From https://kodza.dev/components/landing-reveal/
// 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: landing-reveal.css and landing-reveal.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 './landing-reveal.css';
export type LandingRevealProps = Omit<ComponentPropsWithoutRef<'section'>, "children" | "vars"> & {
/** Settings, as CSS variables on the root */
vars?: {
/** the loader's paper: your page colour, so the hand-over is seamless — default: #ffffff */
"--kz-landing-reveal-bg"?: string | number;
/** the loader's counter and labels — default: #0a0a0a */
"--kz-landing-reveal-fg"?: string | number;
/** default: rgba(10, 10, 10, 0.62) */
"--kz-landing-reveal-muted"?: string | number;
/** default: rgba(10, 10, 10, 0.1) */
"--kz-landing-reveal-line"?: string | number;
/** the hero, over its image — default: #f2f2f0 */
"--kz-landing-reveal-text"?: string | number;
/** default: rgba(242, 242, 240, 0.74) */
"--kz-landing-reveal-text-muted"?: string | number;
/** under the text, so it reads on any image — default: rgba(10, 10, 10, 0.6) */
"--kz-landing-reveal-veil"?: string | number;
/** default: #f2f2f0 */
"--kz-landing-reveal-button"?: string | number;
/** default: #0a0a0a */
"--kz-landing-reveal-on-button"?: string | number;
/** width of the pile — default: clamp(148px, 26vmin, 260px) */
"--kz-landing-reveal-card"?: string | number;
/** the hero image: 0 = edge to edge — default: 0px */
"--kz-landing-reveal-radius"?: string | number;
/** the hero; take your header off if it sits above — default: 100svh */
"--kz-landing-reveal-height"?: string | number;
/** default: 24px */
"--kz-landing-reveal-side"?: string | number;
/** 100 never comes sooner, even with every file cached — default: 2.2s */
"--kz-landing-reveal-min"?: string | number;
/** nor later: past it, the hero comes in without the late files — default: 2.8s */
"--kz-landing-reveal-max"?: string | number;
/** the hero image growing into place — default: cubic-bezier(0.65, 0, 0.35, 1) */
"--kz-landing-reveal-ease"?: string | number;
};
className?: string;
};
export function LandingReveal({ vars, className, style, ...rest }: LandingRevealProps) {
const ref = useRef<ComponentRef<'section'>>(null);
const classes = ["kodza-landing-reveal", 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('./landing-reveal.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 key={key} {...rest} className={classes} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<div className="kodza-landing-reveal__loader" aria-hidden="true">
<p className="kodza-landing-reveal__brand">off<span>·</span>by<span>·</span>one</p>
<p className="kodza-landing-reveal__tally"><span className="kodza-landing-reveal__done">0</span>/<span className="kodza-landing-reveal__total">0</span> files</p>
<div className="kodza-landing-reveal__deck">
<img className="kodza-landing-reveal__card" src="https://kodza.dev/media/kz-block-1400.webp" alt="" data-caption="display: block" width="1120" height="1400" />
{' '}
<img className="kodza-landing-reveal__card" src="https://kodza.dev/media/kz-columns-1400.webp" alt="" data-caption="display: grid" width="1120" height="1400" />
{' '}
<img className="kodza-landing-reveal__card" src="https://kodza.dev/media/kz-stack-1400.webp" alt="" data-caption="z-index: 9999" width="1400" height="1050" />
{' '}
<img className="kodza-landing-reveal__card" src="https://kodza.dev/media/kz-float-1400.webp" alt="" data-caption="float: left" width="1400" height="933" />
{' '}
<img className="kodza-landing-reveal__card" src="https://kodza.dev/media/kz-arch-1400.webp" alt="" data-caption="border-radius" width="1400" height="1400" />
</div>
<p className="kodza-landing-reveal__caption" />
<p className="kodza-landing-reveal__count">000</p>
<p className="kodza-landing-reveal__note">Loading the good bits</p>
</div>
<header className="kodza-landing-reveal__bar">
<a className="kodza-landing-reveal__logo" href="#">off<span>·</span>by<span>·</span>one</a>
<nav className="kodza-landing-reveal__nav" aria-label="Main" data-enter="down">
<a href="#">Work</a>
{' '}
<a href="#">Studio</a>
{' '}
<a href="#">Journal</a>
</nav>
<a className="kodza-landing-reveal__button kodza-landing-reveal__button--small" href="#" data-enter="down">Start a project</a>
</header>
<div className="kodza-landing-reveal__hero">
<p className="kodza-landing-reveal__eyebrow" data-enter="">Studio · est. 2019, give or take</p>
<div className="kodza-landing-reveal__grid">
<h1 className="kodza-landing-reveal__title">
<span className="kodza-landing-reveal__line">Hello,</span>
{' '}
<span className="kodza-landing-reveal__line">first paint.</span>
</h1>
<div className="kodza-landing-reveal__side">
<p className="kodza-landing-reveal__lead" data-enter="">Small studio, big deploys. Sites that load fast, look sharp and survive the client's nephew with admin access.</p>
<p className="kodza-landing-reveal__actions" data-enter="">
<a className="kodza-landing-reveal__button" href="#">See the work</a>
{' '}
<a className="kodza-landing-reveal__button kodza-landing-reveal__button--ghost" href="#">Book a call</a>
</p>
</div>
</div>
<figure className="kodza-landing-reveal__media">
<img className="kodza-landing-reveal__image" src="https://kodza.dev/media/kz-steps-dark-2400.webp" alt="Concrete steps climbing out of the dark" width="2400" height="1600" data-caption="steps(5, end)" />
<figcaption data-enter="">
<span>The last image on the pile</span>
<span>steps(5, end)</span>
</figcaption>
</figure>
</div>
</section>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.