Hero Split
A split hero, words on the left and a 4:5 picture on the right: the title lines rise out of their masks while the picture opens from the top edge down.
View codeA big title with a small picture set between two words. On scroll the scene pins, the picture opens to full screen and pushes the words aside, then a caption and a button rise. Scroll back up: it rewinds.
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 Hero Expand to my page.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<section class="kodza-hero-expand">
<div class="kodza-hero-expand__scene">
<div class="kodza-hero-expand__inner">
<div class="kodza-hero-expand__head">
<p class="kodza-hero-expand__eyebrow"><span class="kodza-hero-expand__rise">Kodza Studio · est. last sprint</span></p>
<h1 class="kodza-hero-expand__title"><span class="kodza-hero-expand__line"><span class="kodza-hero-expand__rise">No framework</span></span> <span class="kodza-hero-expand__line"><span class="kodza-hero-expand__rise"><span class="kodza-hero-expand__word">was</span> <span class="kodza-hero-expand__slot" aria-hidden="true"></span> <span class="kodza-hero-expand__word">harmed.</span></span></span></h1>
</div>
<div class="kodza-hero-expand__foot">
<p class="kodza-hero-expand__lead">Your client calls it magic. You call it position: sticky.</p>
<p class="kodza-hero-expand__hint" aria-hidden="true"><span class="kodza-hero-expand__cue"></span>Scroll to enter</p>
</div>
</div>
<div class="kodza-hero-expand__media">
<div class="kodza-hero-expand__window"><img src="https://kodza.dev/media/kz-arch-2400.webp" alt="A concrete wall with a round-topped opening, sunlight pouring through it" width="2400" height="2400" /></div>
<div class="kodza-hero-expand__veil"></div>
<div class="kodza-hero-expand__panel">
<div class="kodza-hero-expand__copy">
<p class="kodza-hero-expand__fig"><span class="kodza-hero-expand__rise">fig. 02 — One entry point. Zero dependencies.</span></p>
<p class="kodza-hero-expand__say"><span class="kodza-hero-expand__mask"><span class="kodza-hero-expand__rise">Step inside.</span></span> <span class="kodza-hero-expand__mask"><span class="kodza-hero-expand__rise">The rest is one paste.</span></span></p>
</div>
<div class="kodza-hero-expand__action"><span class="kodza-hero-expand__rise"><a class="kodza-hero-expand__button" href="#">Get the code<svg class="kodza-hero-expand__arrow" viewBox="0 0 16 16" aria-hidden="true"><path d="M2.5 8h10.5M9 3.5 13.5 8 9 12.5" /></svg></a></span></div>
</div>
</div>
</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 — Hero Expand
A big centred title with a small picture set between two words. On scroll the scene pins, the
picture leaves the line and grows to fill the screen, pushing the words to the edges; full
screen, a caption and a button rise on a readability veil, then the page carries on.
Without the script, or with reduced motion, nothing pins: the title, then the picture as it ends.
Everything is tuned with the --kz-hero-expand-* variables. */
.kodza-hero-expand {
--kz-hero-expand-ink: #0a0a0a;
--kz-hero-expand-dim: rgba(10, 10, 10, 0.62);
--kz-hero-expand-faint: rgba(10, 10, 10, 0.6);
--kz-hero-expand-soft: rgba(10, 10, 10, 0.07);
--kz-hero-expand-bg: transparent;
--kz-hero-expand-light: #f2f2f0; /* words and button over the picture, both themes */
--kz-hero-expand-dark: #0a0a0a; /* the button's label */
--kz-hero-expand-shade: rgba(10, 10, 10, 0.72); /* the readability veil, at its darkest */
--kz-hero-expand-width: 1240px;
--kz-hero-expand-gutter: clamp(16px, 4vw, 40px);
--kz-hero-expand-pad-y: clamp(24px, 3.4vw, 44px);
--kz-hero-expand-top: 0px; /* height of a fixed site header: the scene pins under it */
--kz-hero-expand-distance: 200svh; /* how long the scene stays pinned */
--kz-hero-expand-crop: 16%; /* on a tall screen, how far the picture rises above it: its top (sky) stays out of view */
--kz-hero-expand-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
--kz-hero-expand-ease: cubic-bezier(0.22, 1, 0.36, 1);
container: kodza-hero-expand / inline-size;
position: relative;
box-sizing: border-box;
width: 100%;
color: var(--kz-hero-expand-ink);
}
.kodza-hero-expand__scene {
position: relative;
background: var(--kz-hero-expand-bg);
}
.kodza-hero-expand__inner {
position: relative;
z-index: 1;
box-sizing: border-box;
max-width: calc(var(--kz-hero-expand-width) + 2 * var(--kz-hero-expand-gutter));
margin: 0 auto;
padding: clamp(72px, 9vw, 140px) max(var(--kz-hero-expand-gutter), env(safe-area-inset-right, 0px)) clamp(48px, 6vw, 88px) max(var(--kz-hero-expand-gutter), env(safe-area-inset-left, 0px));
}
.kodza-hero-expand__head {
text-align: center;
}
.kodza-hero-expand__eyebrow {
overflow: hidden;
margin: 0 0 clamp(18px, 2.2cqi, 30px);
font-family: var(--kz-hero-expand-mono);
font-size: 12.5px;
font-weight: 500;
line-height: 1.4;
letter-spacing: 0.02em;
color: var(--kz-hero-expand-faint);
}
.kodza-hero-expand__rise {
display: block;
}
.kodza-hero-expand__title {
margin: 0;
font-size: clamp(32px, calc(9.4cqi + 6px), 148px);
font-weight: 700;
line-height: 1;
letter-spacing: -0.055em;
}
/* Each line is a mask: the words rise out of it on arrival */
.kodza-hero-expand__line {
display: block;
overflow: hidden;
padding-bottom: 0.08em;
margin-bottom: -0.08em;
white-space: nowrap;
}
.kodza-hero-expand__word {
display: inline-block;
}
/* The picture's place in the line, cap height tall: the script lays the picture over it */
.kodza-hero-expand__slot {
display: none;
width: 1.9em;
height: 0.72em;
border-radius: 0.16em;
vertical-align: baseline;
}
.kodza-hero-expand__foot {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 20px 48px;
margin-top: clamp(40px, 5cqi, 72px);
}
.kodza-hero-expand__lead {
max-width: 30ch;
margin: 0;
font-size: clamp(16px, 1.4cqi, 19px);
line-height: 1.55;
color: var(--kz-hero-expand-dim);
text-wrap: pretty;
}
.kodza-hero-expand__hint {
display: none;
align-items: center;
gap: 12px;
margin: 0;
font-family: var(--kz-hero-expand-mono);
font-size: 12.5px;
font-weight: 500;
line-height: 1.4;
letter-spacing: 0.02em;
white-space: nowrap;
color: var(--kz-hero-expand-faint);
}
/* A short stroke runs down a hairline, over and over */
.kodza-hero-expand__cue {
position: relative;
overflow: hidden;
width: 1px;
height: 28px;
background: var(--kz-hero-expand-soft);
}
.kodza-hero-expand__cue::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 1px;
height: 10px;
background: currentColor;
animation: kodza-hero-expand-cue 1.8s cubic-bezier(0.65, 0, 0.35, 1) infinite;
}
@keyframes kodza-hero-expand-cue {
from {
transform: translateY(-100%);
}
to {
transform: translateY(280%);
}
}
/* The picture, as it ends: full bleed, a veil, the caption and the button */
.kodza-hero-expand__media {
position: relative;
z-index: 2;
overflow: hidden;
overflow: clip; /* clip: focusing the button never scrolls it into a mask */
height: 100svh;
pointer-events: none;
}
.kodza-hero-expand__window {
position: absolute;
inset: 0;
}
.kodza-hero-expand__window img {
position: absolute;
bottom: 0;
left: 0;
display: block;
width: 100%;
height: 100%;
max-width: none;
object-fit: cover;
}
/* A tall screen shows a wide picture's whole height: it rises, its top cut off */
@media (orientation: portrait) {
.kodza-hero-expand__window img {
height: calc(100% + var(--kz-hero-expand-crop));
}
}
.kodza-hero-expand__veil {
position: absolute;
inset: 0;
background: linear-gradient(to top, var(--kz-hero-expand-shade), var(--kz-hero-expand-shade) 16%, transparent 72%);
}
.kodza-hero-expand__panel {
position: absolute;
right: 0;
bottom: 0;
left: 0;
box-sizing: border-box;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
gap: 28px 48px;
max-width: calc(var(--kz-hero-expand-width) + 2 * var(--kz-hero-expand-gutter));
margin: 0 auto;
padding: 0 max(var(--kz-hero-expand-gutter), env(safe-area-inset-right, 0px)) clamp(28px, 4.4cqi, 64px) max(var(--kz-hero-expand-gutter), env(safe-area-inset-left, 0px));
color: var(--kz-hero-expand-light);
}
.kodza-hero-expand__fig {
overflow: hidden;
margin: 0 0 16px;
font-family: var(--kz-hero-expand-mono);
font-size: 12.5px;
font-weight: 500;
line-height: 1.4;
letter-spacing: 0.02em;
text-wrap: balance;
}
.kodza-hero-expand__say {
margin: 0;
font-size: clamp(30px, 4.6cqi, 64px);
font-weight: 600;
line-height: 1.02;
letter-spacing: -0.045em;
}
.kodza-hero-expand__mask {
display: block;
overflow: hidden;
padding-bottom: 0.08em;
margin-bottom: -0.08em;
}
/* Room for the focus ring inside the button's mask */
.kodza-hero-expand__action {
overflow: hidden;
overflow: clip;
margin: -6px;
padding: 6px;
}
.kodza-hero-expand__button {
position: relative;
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: 10px;
height: 52px;
padding: 0 26px;
border: 0;
border-radius: 999px;
background: var(--kz-hero-expand-light);
color: var(--kz-hero-expand-dark);
font-family: inherit;
font-size: 15px;
font-weight: 500;
line-height: 1;
white-space: nowrap;
text-decoration: none;
cursor: pointer;
pointer-events: auto;
}
.kodza-hero-expand__button:focus-visible {
outline: 2px solid var(--kz-hero-expand-light);
outline-offset: 3px;
}
.kodza-hero-expand__arrow {
flex: none;
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
transition: transform 0.3s var(--kz-hero-expand-ease);
}
@media (hover: hover) {
.kodza-hero-expand__button:hover .kodza-hero-expand__arrow {
transform: translateX(4px);
}
}
/* With the script and motion: a tall track, the scene pinned inside it, the picture in its slot */
@media (prefers-reduced-motion: no-preference) {
.kodza-hero-expand.is-ready {
height: calc(100svh - var(--kz-hero-expand-top) + var(--kz-hero-expand-distance));
}
.kodza-hero-expand.is-ready .kodza-hero-expand__scene {
position: sticky;
top: var(--kz-hero-expand-top);
overflow: hidden;
overflow: clip;
height: calc(100svh - var(--kz-hero-expand-top));
}
.kodza-hero-expand.is-ready .kodza-hero-expand__inner {
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
height: 100%;
padding-top: var(--kz-hero-expand-pad-y);
padding-bottom: var(--kz-hero-expand-pad-y);
}
.kodza-hero-expand.is-ready .kodza-hero-expand__head {
align-self: center;
}
.kodza-hero-expand.is-ready .kodza-hero-expand__foot {
margin-top: 0;
}
.kodza-hero-expand.is-ready .kodza-hero-expand__slot {
display: inline-block;
}
.kodza-hero-expand.is-ready .kodza-hero-expand__hint {
display: flex;
}
.kodza-hero-expand.is-ready .kodza-hero-expand__media {
position: absolute;
inset: 0;
height: auto;
}
.kodza-hero-expand.is-ready .kodza-hero-expand__veil {
opacity: 0;
}
.kodza-hero-expand.is-ready .kodza-hero-expand__panel .kodza-hero-expand__rise {
transform: translateY(calc(105% + 6px));
}
/* Once arrived, the words may leave their line: they travel to the edges */
.kodza-hero-expand.is-live .kodza-hero-expand__line {
overflow: visible;
}
/* Waiting for its entrance: the script sets is-shown and plays the motion in the same frame */
.kodza-hero-expand.is-ready:not(.is-shown) .kodza-hero-expand__head .kodza-hero-expand__rise {
transform: translateY(105%);
}
.kodza-hero-expand.is-ready:not(.is-shown) .kodza-hero-expand__lead,
.kodza-hero-expand.is-ready:not(.is-shown) .kodza-hero-expand__hint {
opacity: 0;
transform: translateY(20px);
}
.kodza-hero-expand.is-ready:not(.is-shown) .kodza-hero-expand__window {
opacity: 0;
}
}
/* One column: the hint under the intro, the button under the caption */
@container kodza-hero-expand (max-width: 700px) {
.kodza-hero-expand__foot {
flex-direction: column;
align-items: flex-start;
}
.kodza-hero-expand__panel {
grid-template-columns: minmax(0, 1fr);
}
}
@media (prefers-reduced-motion: reduce) {
.kodza-hero-expand *,
.kodza-hero-expand *::before,
.kodza-hero-expand *::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}
/* kodza-guard 8bf2b756c97c — 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-hero-expand__eyebrow):not(#_),
:where(.kodza-hero-expand__title):not(#_),
:where(.kodza-hero-expand__lead):not(#_),
:where(.kodza-hero-expand__hint):not(#_),
:where(.kodza-hero-expand__fig):not(#_),
:where(.kodza-hero-expand__say):not(#_),
:where(.kodza-hero-expand__window img:where(:not([class*="kodza-"]))):not(#_) {
transition-duration: revert;
}
:where(.kodza-hero-expand__button):not(#_) {
outline: revert;
outline-offset: revert;
transition-duration: revert;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-hero-expand__scene:not(#_),
.kodza-hero-expand__head:not(#_),
.kodza-hero-expand__rise:not(#_),
.kodza-hero-expand__line:not(#_),
.kodza-hero-expand__word:not(#_),
.kodza-hero-expand__slot:not(#_),
.kodza-hero-expand__foot:not(#_),
.kodza-hero-expand__cue:not(#_),
.kodza-hero-expand__media:not(#_),
.kodza-hero-expand__window:not(#_),
.kodza-hero-expand__veil:not(#_),
.kodza-hero-expand__copy:not(#_),
.kodza-hero-expand__mask:not(#_),
.kodza-hero-expand__action:not(#_) {
box-sizing: revert;
}
.kodza-hero-expand__eyebrow:not(#_),
.kodza-hero-expand__hint:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-style: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
transition-property: revert;
transition-timing-function: revert;
transition-delay: revert;
}
.kodza-hero-expand__title:not(#_),
.kodza-hero-expand__say: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;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
transition-property: revert;
transition-timing-function: revert;
transition-delay: revert;
}
.kodza-hero-expand__lead:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-height: revert;
font-family: revert;
font-weight: revert;
font-style: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
transition-property: revert;
transition-timing-function: revert;
transition-delay: revert;
}
.kodza-hero-expand__fig: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-style: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
transition-property: revert;
transition-timing-function: revert;
transition-delay: revert;
}
.kodza-hero-expand__button:not(#_) {
margin: revert;
list-style: revert;
float: revert;
box-shadow: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-style: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
transition-property: revert;
transition-timing-function: revert;
transition-delay: revert;
}
.kodza-hero-expand__arrow:not(#_) {
border: revert;
margin: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-hero-expand__window 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;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
transition-property: revert;
transition-timing-function: revert;
transition-delay: revert;
}
.kodza-hero-expand__eyebrow:not(#_):where(:not([hidden])),
.kodza-hero-expand__title:not(#_):where(:not([hidden])),
.kodza-hero-expand__lead:not(#_):where(:not([hidden])),
.kodza-hero-expand__fig:not(#_):where(:not([hidden])),
.kodza-hero-expand__say:not(#_):where(:not([hidden])),
.kodza-hero-expand__arrow:not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-hero-expand__eyebrow:not(#_):where([hidden]),
.kodza-hero-expand__title:not(#_):where([hidden]),
.kodza-hero-expand__lead:not(#_):where([hidden]),
.kodza-hero-expand__fig:not(#_):where([hidden]),
.kodza-hero-expand__say:not(#_):where([hidden]),
.kodza-hero-expand__arrow:not(#_):where([hidden]) {
display: none;
}
.kodza-hero-expand__cue:not(#_)::before {
box-sizing: revert;
}
/* The component's own rules, one step stronger: real buttons and fields, what it sets on tags themes style (links, lists, headings, images…), and the root width (a theme may narrow it, never widen it) */
.kodza-hero-expand__eyebrow:not(#_) {
margin: 0 0 clamp(18px, 2.2cqi, 30px);
font-family: var(--kz-hero-expand-mono);
font-size: 12.5px;
font-weight: 500;
line-height: 1.4;
letter-spacing: 0.02em;
color: var(--kz-hero-expand-faint);
}
.kodza-hero-expand__title:not(#_) {
margin: 0;
font-size: clamp(32px, calc(9.4cqi + 6px), 148px);
font-weight: 700;
line-height: 1;
letter-spacing: -0.055em;
}
.kodza-hero-expand__lead:not(#_) {
max-width: 30ch;
margin: 0;
font-size: clamp(16px, 1.4cqi, 19px);
line-height: 1.55;
color: var(--kz-hero-expand-dim);
}
.kodza-hero-expand__hint:not(#_) {
display: none;
margin: 0;
font-family: var(--kz-hero-expand-mono);
font-size: 12.5px;
font-weight: 500;
line-height: 1.4;
letter-spacing: 0.02em;
color: var(--kz-hero-expand-faint);
}
.kodza-hero-expand__window img:not(#_) {
position: absolute;
display: block;
max-width: none;
}
.kodza-hero-expand__fig:not(#_) {
margin: 0 0 16px;
font-family: var(--kz-hero-expand-mono);
font-size: 12.5px;
font-weight: 500;
line-height: 1.4;
letter-spacing: 0.02em;
}
.kodza-hero-expand__say:not(#_) {
margin: 0;
font-size: clamp(30px, 4.6cqi, 64px);
font-weight: 600;
line-height: 1.02;
letter-spacing: -0.045em;
}
.kodza-hero-expand__button:not(#_) {
position: relative;
box-sizing: border-box;
display: inline-flex;
padding: 0 26px;
border: 0;
border-radius: 999px;
background: var(--kz-hero-expand-light);
color: var(--kz-hero-expand-dark);
font-family: inherit;
font-size: 15px;
font-weight: 500;
line-height: 1;
text-decoration: none;
}
.kodza-hero-expand__button:focus-visible:not(#_) {
outline: 2px solid var(--kz-hero-expand-light);
outline-offset: 3px;
}
.kodza-hero-expand__arrow:not(#_) {
transition: transform 0.3s var(--kz-hero-expand-ease);
}
@media (prefers-reduced-motion: no-preference) {
.kodza-hero-expand.is-ready .kodza-hero-expand__hint:not(#_) {
display: flex;
}
}
@media (prefers-reduced-motion: reduce) {
.kodza-hero-expand *:where(p, h1, img, a, svg):not(#_) {
transition-duration: 0.01ms !important;
}
}
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — Hero Expand
// Plays the entrance of Hero Split once the section is in view (the label and title lines rise
// out of their masks, the picture in its slot with them), then hands over to the scroll: the
// scene pins, the picture opens to the whole screen and pushes the words beside it to the edges,
// the rest of the title fades, and a caption and a button rise on a veil before the page carries
// on. Every move is a paused animation set from the scroll: scrolling back up plays it in
// reverse. "kodza:replay" plays the entrance again.
(() => {
const SELECTOR = '.kodza-hero-expand';
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const GLIDE = 'cubic-bezier(0.6, 0, 0.1, 1)'; // slow start, long settle
const SOFT = 'cubic-bezier(0.25, 1, 0.5, 1)';
const TRAVEL = 'cubic-bezier(0.65, 0, 0.35, 1)'; // the big moves on the scroll
const RISE = 'cubic-bezier(0.22, 1, 0.36, 1)';
const decoded = (root) => Promise.all([...root.querySelectorAll('img')].map((img) => img.decode().catch(() => {})));
// ---------- Scroll engine (begin) ----------
// The same block in every hero that plays on scroll: copy it as is, write only the timeline.
// `root` is a tall track and `scene` sticks inside it (position: sticky, in the CSS); the
// progress runs from 0, the scene just pinned, to 1, about to unpin. `timeline()` returns
// tracks, [element, keyframes, from, to] with from and to in progress; each becomes a paused
// Web Animation whose time follows the scroll, smoothed by a requestAnimationFrame loop that
// sleeps once it has caught up. The tracks are built again when `scene` or an element of
// `watch` changes size, so `timeline()` measures there.
function scrub(root, scene, signal, timeline, watch = []) {
const SPAN = 1000;
let tracks = [];
let live = false;
let now = 0;
let target = 0;
let frame = 0;
let last = 0;
const geometry = () => {
const box = root.getBoundingClientRect();
return {
top: box.top,
scale: box.height / root.offsetHeight || 1, // a parent scaled by transform
run: root.offsetHeight - scene.offsetHeight,
stick: parseFloat(getComputedStyle(scene).top) || 0,
};
};
const progress = () => {
const { top, scale, run, stick } = geometry();
return run > 0 ? Math.min(1, Math.max(0, (stick - top / scale) / run)) : 0;
};
const seek = () => tracks.forEach((a) => { a.currentTime = now * SPAN; });
const tick = (time) => {
const dt = last ? Math.min(time - last, 64) : 16;
last = time;
now += (target - now) * (1 - Math.exp(-dt / 70));
if (Math.abs(target - now) < 0.0004) now = target;
seek();
frame = now === target ? 0 : requestAnimationFrame(tick);
if (!frame) last = 0;
};
const update = () => {
if (!live) return;
target = progress();
if (!frame) frame = requestAnimationFrame(tick);
};
const clear = () => {
tracks.forEach((a) => a.cancel());
tracks = [];
};
const build = () => {
clear();
if (!live) return;
tracks = timeline().filter(([el]) => el).map(([el, keyframes, from, to]) => {
const a = el.animate(keyframes, { duration: (to - from) * SPAN, delay: from * SPAN, endDelay: (1 - to) * SPAN, fill: 'both' });
a.pause();
return a;
});
seek();
update();
};
const stop = () => {
live = false;
cancelAnimationFrame(frame);
frame = last = now = 0;
clear();
};
// Scrolls the page so the progress is at least `p` (a focused control must show)
const reveal = (p) => {
const { top, scale, run, stick } = geometry();
if (run > 0 && progress() < p) window.scrollBy(0, top - (stick - p * run) * scale);
};
document.addEventListener('scroll', update, { passive: true, capture: true, signal });
window.addEventListener('resize', update, { passive: true, signal });
let resized = 0;
const ro = new ResizeObserver(() => {
cancelAnimationFrame(resized);
resized = requestAnimationFrame(build);
});
[scene, ...watch].forEach((el) => el && ro.observe(el));
signal.addEventListener('abort', () => {
ro.disconnect();
cancelAnimationFrame(resized);
stop();
});
return {
start() {
live = true;
build();
},
stop,
progress,
reveal,
};
}
// ---------- Scroll engine (end) ----------
// One instance, with its own state: two copies on a page never interfere. Everything it
// starts is tied to `signal` (listeners get { signal }, observers and timers stop on its
// 'abort' event), so kodza:destroy stops it all.
function mount(root, signal) {
if (reduceMotion) return root.classList.add('is-shown');
const q = (s) => root.querySelector(s);
const scene = q('.kodza-hero-expand__scene');
const slot = q('.kodza-hero-expand__slot');
const win = q('.kodza-hero-expand__window');
const img = q('.kodza-hero-expand__window img');
const line = slot.closest('.kodza-hero-expand__line');
const lines = [...root.querySelectorAll('.kodza-hero-expand__line')];
const words = [...line.querySelectorAll('.kodza-hero-expand__word')];
// A box in the scene's own pixels, whatever scales the page
const within = (el) => {
const box = scene.getBoundingClientRect();
const k = box.width / scene.offsetWidth || 1;
const r = el.getBoundingClientRect();
return { x: (r.left - box.left) / k, y: (r.top - box.top) / k, w: r.width / k, h: r.height / k };
};
// The picture covers the whole scene (on a tall screen it rises above it, its top out of
// view); laid over the slot, it is scaled to cover it, centred on it and cut to its shape.
// Opening it is only these three numbers going back to 0.
const measure = () => {
const W = scene.clientWidth;
const H = scene.clientHeight;
const { x, y, w, h } = within(slot);
const round = parseFloat(getComputedStyle(slot).borderTopLeftRadius) || 0;
const iw = img.offsetWidth;
const ih = img.offsetHeight;
const s = Math.max(w / iw, h / ih);
const cx = x + w / 2 - (img.offsetLeft + iw / 2);
const dy = y + h / 2 - (img.offsetTop + ih / 2);
const cy = y + h / 2 - H / 2;
const clip = (top, bottom) => `inset(${top}px ${W - x - w}px ${H - bottom}px ${x}px round ${round}px)`;
const picture = (zoom) => `translate(${cx}px, ${dy}px) scale(${s * zoom})`;
win.style.clipPath = clip(y, y + h);
img.style.transform = picture(1);
return { W, x, y, w, h, cy, clip, picture };
};
// The scroll part, in progress: the picture is full screen at 0.55, the panel is up at 0.84
const timeline = () => {
const { W, x, y, w, h, cy, clip, picture } = measure();
return [
[win, [{ clipPath: clip(y, y + h), easing: TRAVEL }, { clipPath: 'inset(0px 0px 0px 0px round 0px)' }], 0, 0.55],
[img, [
{ transform: picture(1), easing: TRAVEL },
{ transform: 'translate(0px, 0px) scale(1)', offset: 0.55, easing: 'cubic-bezier(0.45, 0, 0.55, 1)' },
{ transform: 'translate(0px, 0px) scale(1.08)' },
], 0, 1],
// The words beside it ride its edges out of the screen, on its centre line
...words.map((word) => {
const before = word.compareDocumentPosition(slot) & Node.DOCUMENT_POSITION_FOLLOWING;
return [word, [{ transform: 'translate(0px, 0px)', easing: TRAVEL }, { transform: `translate(${before ? -x : W - x - w}px, ${-cy}px)` }], 0, 0.55];
}),
...lines.filter((l) => l !== line).map((l) => [l, [{ opacity: 1, transform: 'none' }, { opacity: 0, transform: 'translateY(-0.2em)' }], 0.02, 0.28]),
[q('.kodza-hero-expand__eyebrow'), [{ opacity: 1, transform: 'none' }, { opacity: 0, transform: 'translateY(-12px)' }], 0, 0.3],
[q('.kodza-hero-expand__foot'), [{ opacity: 1, transform: 'none' }, { opacity: 0, transform: 'translateY(12px)' }], 0, 0.3],
[q('.kodza-hero-expand__veil'), [{ opacity: 0 }, { opacity: 1 }], 0.45, 0.62],
// 6px more: the button's mask leaves room for the focus ring
...[...root.querySelectorAll('.kodza-hero-expand__panel .kodza-hero-expand__rise')].map((el, i) => (
[el, [{ transform: 'translateY(calc(105% + 6px))', easing: RISE }, { transform: 'none' }], 0.55 + i * 0.035, 0.73 + i * 0.035]
)),
];
};
const pin = scrub(root, scene, signal, timeline, [q('.kodza-hero-expand__title')]);
let running = [];
const stop = () => {
running.forEach((a) => a.cancel());
running = [];
};
const animate = (el, keyframes, options) => {
if (el) running.push(el.animate(keyframes, { easing: GLIDE, fill: 'backwards', ...options }));
};
const rise = (el, delay, duration) => animate(el, [{ transform: 'translateY(105%)' }, { transform: 'none' }], { duration, delay });
// Scrolling during the entrance hurries it, so the scroll takes over without a wait
const hurry = () => {
if (running.length && !root.classList.contains('is-live') && pin.progress() > 0) running.forEach((a) => a.updatePlaybackRate(4));
};
const play = async () => {
await decoded(root);
if (signal.aborted) return;
stop();
pin.stop();
root.classList.remove('is-live');
root.classList.add('is-shown');
const { y, h, clip, picture } = measure();
rise(q('.kodza-hero-expand__eyebrow .kodza-hero-expand__rise'), 0, 1000);
lines.forEach((l, i) => rise(l.querySelector(':scope > .kodza-hero-expand__rise'), 110 + i * 125, 1170));
// The picture rises with its line, cut at the bottom of the line's mask like the words
const travel = line.querySelector(':scope > .kodza-hero-expand__rise').offsetHeight * 1.05;
const { y: top, h: height } = within(line);
const edge = top + height;
const shown = (k) => Math.min(y + h, Math.max(y, edge - travel * (1 - k)));
const stops = [0, 1 - (edge - y) / travel, 1 - (edge - y - h) / travel, 1].map((k) => Math.min(1, Math.max(0, k)));
animate(win, stops.map((k) => ({ offset: k, transform: `translateY(${travel * (1 - k)}px)`, clipPath: clip(y, shown(k)) })), { duration: 1170, delay: 110 + lines.indexOf(line) * 125 });
animate(img, [{ transform: picture(1.2) }, { transform: picture(1) }], { duration: 1600, delay: 200, easing: SOFT });
[q('.kodza-hero-expand__lead'), q('.kodza-hero-expand__hint')].forEach((el, i) => {
animate(el, [{ opacity: 0, transform: 'translateY(20px)' }, { opacity: 1, transform: 'none' }], { duration: 900, delay: 700 + i * 110, easing: SOFT });
});
hurry();
const entrance = running;
Promise.all(entrance.map((a) => a.finished)).then(() => {
if (entrance !== running || signal.aborted) return;
root.classList.add('is-live');
pin.start();
}, () => {});
};
root.addEventListener('kodza:replay', play, { signal });
document.addEventListener('scroll', hurry, { passive: true, capture: true, signal });
// Tabbing to the button scrolls to where it shows
q('.kodza-hero-expand__panel')?.addEventListener('focusin', () => pin.reveal(0.9), { signal });
signal.addEventListener('abort', stop);
const io = new IntersectionObserver((entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return;
io.disconnect();
play();
}, { rootMargin: '0px 0px -15% 0px' });
signal.addEventListener('abort', () => io.disconnect());
Promise.all([document.fonts?.ready, decoded(root)]).then(() => {
if (!signal.aborted) io.observe(root);
});
}
// 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 — Hero Expand, for React. From https://kodza.dev/components/hero-expand/
// 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: hero-expand.css and hero-expand.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 './hero-expand.css';
export type HeroExpandProps = Omit<ComponentPropsWithoutRef<'section'>, "children" | "vars"> & {
/** Settings, as CSS variables on the root */
vars?: {
/** default: #0a0a0a */
"--kz-hero-expand-ink"?: string | number;
/** default: rgba(10, 10, 10, 0.62) */
"--kz-hero-expand-dim"?: string | number;
/** default: rgba(10, 10, 10, 0.6) */
"--kz-hero-expand-faint"?: string | number;
/** default: rgba(10, 10, 10, 0.07) */
"--kz-hero-expand-soft"?: string | number;
/** default: transparent */
"--kz-hero-expand-bg"?: string | number;
/** words and button over the picture, both themes — default: #f2f2f0 */
"--kz-hero-expand-light"?: string | number;
/** the button's label — default: #0a0a0a */
"--kz-hero-expand-dark"?: string | number;
/** the readability veil, at its darkest — default: rgba(10, 10, 10, 0.72) */
"--kz-hero-expand-shade"?: string | number;
/** default: 1240px */
"--kz-hero-expand-width"?: string | number;
/** default: clamp(16px, 4vw, 40px) */
"--kz-hero-expand-gutter"?: string | number;
/** default: clamp(24px, 3.4vw, 44px) */
"--kz-hero-expand-pad-y"?: string | number;
/** height of a fixed site header: the scene pins under it — default: 0px */
"--kz-hero-expand-top"?: string | number;
/** how long the scene stays pinned — default: 200svh */
"--kz-hero-expand-distance"?: string | number;
/** on a tall screen, how far the picture rises above it: its top (sky) stays out of view — default: 16% */
"--kz-hero-expand-crop"?: string | number;
/** default: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace */
"--kz-hero-expand-mono"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-hero-expand-ease"?: string | number;
};
className?: string;
};
export function HeroExpand({ vars, className, style, ...rest }: HeroExpandProps) {
const ref = useRef<ComponentRef<'section'>>(null);
const classes = ["kodza-hero-expand", 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('./hero-expand.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-hero-expand__scene">
<div className="kodza-hero-expand__inner">
<div className="kodza-hero-expand__head">
<p className="kodza-hero-expand__eyebrow">
<span className="kodza-hero-expand__rise">Kodza Studio · est. last sprint</span>
</p>
<h1 className="kodza-hero-expand__title">
<span className="kodza-hero-expand__line">
<span className="kodza-hero-expand__rise">No framework</span>
</span>
{' '}
<span className="kodza-hero-expand__line">
<span className="kodza-hero-expand__rise">
<span className="kodza-hero-expand__word">was</span>
{' '}
<span className="kodza-hero-expand__slot" aria-hidden="true" />
{' '}
<span className="kodza-hero-expand__word">harmed.</span>
</span>
</span>
</h1>
</div>
<div className="kodza-hero-expand__foot">
<p className="kodza-hero-expand__lead">Your client calls it magic. You call it position: sticky.</p>
<p className="kodza-hero-expand__hint" aria-hidden="true"><span className="kodza-hero-expand__cue" />Scroll to enter</p>
</div>
</div>
<div className="kodza-hero-expand__media">
<div className="kodza-hero-expand__window">
<img src="https://kodza.dev/media/kz-arch-2400.webp" alt="A concrete wall with a round-topped opening, sunlight pouring through it" width="2400" height="2400" />
</div>
<div className="kodza-hero-expand__veil" />
<div className="kodza-hero-expand__panel">
<div className="kodza-hero-expand__copy">
<p className="kodza-hero-expand__fig">
<span className="kodza-hero-expand__rise">fig. 02 — One entry point. Zero dependencies.</span>
</p>
<p className="kodza-hero-expand__say">
<span className="kodza-hero-expand__mask">
<span className="kodza-hero-expand__rise">Step inside.</span>
</span>
{' '}
<span className="kodza-hero-expand__mask">
<span className="kodza-hero-expand__rise">The rest is one paste.</span>
</span>
</p>
</div>
<div className="kodza-hero-expand__action">
<span className="kodza-hero-expand__rise">
<a className="kodza-hero-expand__button" href="#">Get the code<svg className="kodza-hero-expand__arrow" viewBox="0 0 16 16" aria-hidden="true"> <path d="M2.5 8h10.5M9 3.5 13.5 8 9 12.5" /> </svg></a>
</span>
</div>
</div>
</div>
</div>
</section>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.