Hero Expand
A 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.
View codeA centred title in two big lines. On scroll the scene holds, the lines part, a picture opens between them and tightens into a card, then the next section slides over it.
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 Exit to my page.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<section class="kodza-hero-exit">
<div class="kodza-hero-exit__track">
<div class="kodza-hero-exit__scene">
<div class="kodza-hero-exit__inner">
<p class="kodza-hero-exit__eyebrow"><span class="kodza-hero-exit__rise">Launch week · day one</span></p>
<h1 class="kodza-hero-exit__title"><span class="kodza-hero-exit__line"><span class="kodza-hero-exit__rise">Enter big.</span></span> <span class="kodza-hero-exit__slot" aria-hidden="true"></span> <span class="kodza-hero-exit__line"><span class="kodza-hero-exit__rise">Leave clean.</span></span></h1>
<div class="kodza-hero-exit__foot">
<p class="kodza-hero-exit__lead">The title parts, the picture opens, the next section walks on. One paste, no library.</p>
<div class="kodza-hero-exit__actions">
<a class="kodza-hero-exit__button kodza-hero-exit__button--ink" href="#">Get the code<svg class="kodza-hero-exit__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>
<a class="kodza-hero-exit__button kodza-hero-exit__button--soft" href="#">See it live</a>
</div>
</div>
<p class="kodza-hero-exit__hint" aria-hidden="true"><span class="kodza-hero-exit__rise"><span class="kodza-hero-exit__cue"></span>Scroll to exit</span></p>
</div>
<div class="kodza-hero-exit__media">
<div class="kodza-hero-exit__window"><img src="https://kodza.dev/media/kz-arch-2400.webp" alt="A concrete wall with a round-topped doorway, sunlight pouring through it" width="2400" height="2400" /><div class="kodza-hero-exit__shade"></div></div>
<div class="kodza-hero-exit__card"></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 Exit
A centred title in two big lines. On scroll the scene holds for a moment and takes itself
apart: the top line rises, the bottom line drops, a picture opens in the gap between them and
tightens into a card that steps back, then the next section slides over it.
Without the script, or with reduced motion, nothing pins: the title, then the picture.
Everything is tuned with the --kz-hero-exit-* variables. */
.kodza-hero-exit {
--kz-hero-exit-ink: #0a0a0a;
--kz-hero-exit-paper: #f2f2f0;
--kz-hero-exit-dim: rgba(10, 10, 10, 0.62);
--kz-hero-exit-faint: rgba(10, 10, 10, 0.6);
--kz-hero-exit-soft: rgba(10, 10, 10, 0.07);
--kz-hero-exit-bg: transparent;
--kz-hero-exit-shade: #0a0a0a; /* the card darkens toward it as the next section covers it */
--kz-hero-exit-width: 1240px;
--kz-hero-exit-gutter: clamp(16px, 4vw, 40px);
--kz-hero-exit-pad-y: clamp(20px, 2.6vw, 36px);
--kz-hero-exit-size: clamp(44px, min(14cqi, 17svh), 168px); /* the title lines */
--kz-hero-exit-focus: 50% 42%; /* the part of the picture the card shows */
--kz-hero-exit-card: 0.74; /* the card's height, as a share of the screen */
--kz-hero-exit-ratio: 0.8; /* the card's width / height */
--kz-hero-exit-radius: 16px;
--kz-hero-exit-top: 0px; /* height of a fixed site header: the scene pins under it */
--kz-hero-exit-distance: 180svh; /* how long the scene holds before the next section comes */
--kz-hero-exit-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
--kz-hero-exit-ease: cubic-bezier(0.22, 1, 0.36, 1);
container: kodza-hero-exit / inline-size;
position: relative;
box-sizing: border-box;
width: 100%;
color: var(--kz-hero-exit-ink);
}
.kodza-hero-exit__scene {
position: relative;
background: var(--kz-hero-exit-bg);
}
.kodza-hero-exit__inner {
position: relative;
z-index: 1;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
max-width: calc(var(--kz-hero-exit-width) + 2 * var(--kz-hero-exit-gutter));
margin: 0 auto;
padding: clamp(72px, 9vw, 140px) max(var(--kz-hero-exit-gutter), env(safe-area-inset-right, 0px)) clamp(56px, 7vw, 104px) max(var(--kz-hero-exit-gutter), env(safe-area-inset-left, 0px));
text-align: center;
}
.kodza-hero-exit__eyebrow {
overflow: hidden;
margin: 0 0 clamp(18px, 2.2cqi, 30px);
font-family: var(--kz-hero-exit-mono);
font-size: 12.5px;
font-weight: 500;
line-height: 1.4;
letter-spacing: 0.02em;
color: var(--kz-hero-exit-faint);
}
.kodza-hero-exit__rise {
display: block;
}
.kodza-hero-exit__title {
margin: 0 0 clamp(26px, 3cqi, 40px);
font-size: var(--kz-hero-exit-size);
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-exit__line {
display: block;
overflow: hidden;
padding-bottom: 0.08em;
margin-bottom: -0.08em;
white-space: nowrap;
}
/* The seam between the lines, where the picture opens: the script measures it. It sits at the
foot of the first line's mask, under its descenders, and moves nothing */
.kodza-hero-exit__slot {
position: relative;
top: 0.08em;
display: block;
height: 0;
}
.kodza-hero-exit__foot {
display: flex;
flex-direction: column;
align-items: center;
gap: 28px;
}
.kodza-hero-exit__lead {
max-width: 34ch;
margin: 0;
font-size: clamp(16px, 1.3cqi, 18px);
line-height: 1.55;
color: var(--kz-hero-exit-dim);
text-wrap: balance;
}
.kodza-hero-exit__actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
}
.kodza-hero-exit__button {
position: relative;
isolation: isolate;
overflow: hidden;
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
height: 52px;
padding: 0 26px;
border: 0;
border-radius: 999px;
font-family: inherit;
font-size: 15px;
font-weight: 500;
line-height: 1;
white-space: nowrap;
text-decoration: none;
cursor: pointer;
}
.kodza-hero-exit__button:focus-visible {
outline: 2px solid var(--kz-hero-exit-ink);
outline-offset: 3px;
}
/* Solid: the arrow steps forward on hover */
.kodza-hero-exit__button--ink {
background: var(--kz-hero-exit-ink);
color: var(--kz-hero-exit-paper);
}
.kodza-hero-exit__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-exit-ease);
}
/* Soft: ink rises from the bottom edge on hover */
.kodza-hero-exit__button--soft {
background: var(--kz-hero-exit-soft);
color: var(--kz-hero-exit-ink);
transition: color 0.4s var(--kz-hero-exit-ease);
}
.kodza-hero-exit__button--soft::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
background: var(--kz-hero-exit-ink);
transform: translateY(101%);
transition: transform 0.4s var(--kz-hero-exit-ease);
}
@media (hover: hover) {
.kodza-hero-exit__button--ink:hover .kodza-hero-exit__arrow {
transform: translateX(4px);
}
.kodza-hero-exit__button--soft:hover {
color: var(--kz-hero-exit-paper);
}
.kodza-hero-exit__button--soft:hover::before {
transform: none;
}
}
.kodza-hero-exit__hint {
display: none;
overflow: hidden;
margin: 0;
font-family: var(--kz-hero-exit-mono);
font-size: 12.5px;
font-weight: 500;
line-height: 1.4;
letter-spacing: 0.02em;
white-space: nowrap;
color: var(--kz-hero-exit-faint);
}
.kodza-hero-exit__hint .kodza-hero-exit__rise {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
/* A short stroke runs down a hairline, over and over */
.kodza-hero-exit__cue {
position: relative;
overflow: hidden;
width: 1px;
height: 28px;
background: var(--kz-hero-exit-soft);
}
.kodza-hero-exit__cue::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 1px;
height: 10px;
background: currentColor;
animation: kodza-hero-exit-cue 1.8s cubic-bezier(0.65, 0, 0.35, 1) infinite;
}
@keyframes kodza-hero-exit-cue {
from {
transform: translateY(-100%);
}
to {
transform: translateY(280%);
}
}
/* The picture, without the script: a full-bleed band under the words */
.kodza-hero-exit__media {
position: relative;
overflow: hidden;
height: clamp(260px, 52cqi, 680px);
pointer-events: none;
}
.kodza-hero-exit__window {
position: absolute;
inset: 0;
}
.kodza-hero-exit__window img {
display: block;
width: 100%;
height: 100%;
max-width: none;
object-fit: cover;
object-position: var(--kz-hero-exit-focus);
}
.kodza-hero-exit__shade {
position: absolute;
inset: 0;
background: var(--kz-hero-exit-shade);
opacity: 0;
}
/* The card the picture tightens into: the script reads its box */
.kodza-hero-exit__card {
display: none;
position: absolute;
top: 50%;
left: 50%;
height: min(calc((100svh - var(--kz-hero-exit-top)) * var(--kz-hero-exit-card)), calc(min(100cqi - 2 * var(--kz-hero-exit-gutter), 72cqi) / var(--kz-hero-exit-ratio)));
aspect-ratio: var(--kz-hero-exit-ratio);
border-radius: var(--kz-hero-exit-radius);
transform: translate(-50%, -50%);
visibility: hidden;
}
/* With the script and motion: a tall track, the scene pinned inside it, the picture closed at the
seam. The track runs one screen past the section, which cuts it there (no margin involved, a
theme sets those): the next section slides over the scene */
@media (prefers-reduced-motion: no-preference) {
.kodza-hero-exit.is-ready {
overflow: clip;
height: calc(100svh - var(--kz-hero-exit-top) + var(--kz-hero-exit-distance));
clip-path: inset(0);
}
.kodza-hero-exit.is-ready .kodza-hero-exit__track {
height: calc(2 * (100svh - var(--kz-hero-exit-top)) + var(--kz-hero-exit-distance));
}
.kodza-hero-exit.is-ready .kodza-hero-exit__scene {
position: sticky;
top: var(--kz-hero-exit-top);
overflow: hidden;
overflow: clip;
height: calc(100svh - var(--kz-hero-exit-top));
}
.kodza-hero-exit.is-ready .kodza-hero-exit__inner {
justify-content: center;
height: 100%;
padding-top: var(--kz-hero-exit-pad-y);
padding-bottom: calc(var(--kz-hero-exit-pad-y) + 48px);
}
.kodza-hero-exit.is-ready .kodza-hero-exit__hint {
position: absolute;
bottom: var(--kz-hero-exit-pad-y);
left: 50%;
display: block;
translate: -50% 0;
}
.kodza-hero-exit.is-ready .kodza-hero-exit__media {
position: absolute;
inset: 0;
z-index: 0;
height: auto;
}
.kodza-hero-exit.is-ready .kodza-hero-exit__window {
clip-path: inset(50% 0 50% 0);
}
.kodza-hero-exit.is-ready .kodza-hero-exit__card {
display: block;
}
/* Waiting for its entrance: the script sets is-shown and plays the motion in the same frame */
.kodza-hero-exit.is-ready:not(.is-shown) .kodza-hero-exit__rise {
transform: translateY(105%);
}
.kodza-hero-exit.is-ready:not(.is-shown) .kodza-hero-exit__lead,
.kodza-hero-exit.is-ready:not(.is-shown) .kodza-hero-exit__actions {
opacity: 0;
}
}
/* A phone: the buttons share the row */
@container kodza-hero-exit (max-width: 520px) {
.kodza-hero-exit__foot {
align-self: stretch;
}
.kodza-hero-exit__button {
flex: 1 1 auto;
}
}
@media (prefers-reduced-motion: reduce) {
.kodza-hero-exit *,
.kodza-hero-exit *::before,
.kodza-hero-exit *::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}
/* kodza-guard 430cd1ac6bf9 — 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-exit__eyebrow):not(#_),
:where(.kodza-hero-exit__title):not(#_),
:where(.kodza-hero-exit__lead):not(#_),
:where(.kodza-hero-exit__window img:where(:not([class*="kodza-"]))):not(#_) {
transition-duration: revert;
}
:where(.kodza-hero-exit__button):not(#_) {
outline: revert;
transition: revert;
outline-offset: revert;
}
:where(.kodza-hero-exit__hint):not(#_) {
position: revert;
transition-duration: revert;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-hero-exit__track:not(#_),
.kodza-hero-exit__scene:not(#_),
.kodza-hero-exit__rise:not(#_),
.kodza-hero-exit__line:not(#_),
.kodza-hero-exit__slot:not(#_),
.kodza-hero-exit__foot:not(#_),
.kodza-hero-exit__actions:not(#_),
.kodza-hero-exit__cue:not(#_),
.kodza-hero-exit__media:not(#_),
.kodza-hero-exit__window:not(#_),
.kodza-hero-exit__shade:not(#_),
.kodza-hero-exit__card:not(#_) {
box-sizing: revert;
}
.kodza-hero-exit__eyebrow: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-exit__title:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
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-exit__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-exit__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;
}
.kodza-hero-exit__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-exit__hint:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
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-exit__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;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
transition-property: revert;
transition-timing-function: revert;
transition-delay: revert;
}
.kodza-hero-exit__eyebrow:not(#_):where(:not([hidden])),
.kodza-hero-exit__title:not(#_):where(:not([hidden])),
.kodza-hero-exit__lead:not(#_):where(:not([hidden])),
.kodza-hero-exit__arrow:not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-hero-exit__eyebrow:not(#_):where([hidden]),
.kodza-hero-exit__title:not(#_):where([hidden]),
.kodza-hero-exit__lead:not(#_):where([hidden]),
.kodza-hero-exit__arrow:not(#_):where([hidden]) {
display: none;
}
.kodza-hero-exit__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-exit__eyebrow:not(#_) {
margin: 0 0 clamp(18px, 2.2cqi, 30px);
font-family: var(--kz-hero-exit-mono);
font-size: 12.5px;
font-weight: 500;
line-height: 1.4;
letter-spacing: 0.02em;
color: var(--kz-hero-exit-faint);
}
.kodza-hero-exit__title:not(#_) {
margin: 0 0 clamp(26px, 3cqi, 40px);
font-size: var(--kz-hero-exit-size);
font-weight: 700;
line-height: 1;
letter-spacing: -0.055em;
}
.kodza-hero-exit__lead:not(#_) {
max-width: 34ch;
margin: 0;
font-size: clamp(16px, 1.3cqi, 18px);
line-height: 1.55;
color: var(--kz-hero-exit-dim);
}
.kodza-hero-exit__button:not(#_) {
position: relative;
box-sizing: border-box;
display: inline-flex;
padding: 0 26px;
border: 0;
border-radius: 999px;
font-family: inherit;
font-size: 15px;
font-weight: 500;
line-height: 1;
text-decoration: none;
}
.kodza-hero-exit__button:focus-visible:not(#_) {
outline: 2px solid var(--kz-hero-exit-ink);
outline-offset: 3px;
}
.kodza-hero-exit__button--ink:not(#_) {
background: var(--kz-hero-exit-ink);
color: var(--kz-hero-exit-paper);
}
.kodza-hero-exit__arrow:not(#_) {
transition: transform 0.3s var(--kz-hero-exit-ease);
}
.kodza-hero-exit__button--soft:not(#_) {
background: var(--kz-hero-exit-soft);
color: var(--kz-hero-exit-ink);
transition: color 0.4s var(--kz-hero-exit-ease);
}
@media (hover: hover) {
.kodza-hero-exit__button--soft:hover:not(#_) {
color: var(--kz-hero-exit-paper);
}
}
.kodza-hero-exit__hint:not(#_) {
display: none;
margin: 0;
font-family: var(--kz-hero-exit-mono);
font-size: 12.5px;
font-weight: 500;
line-height: 1.4;
letter-spacing: 0.02em;
color: var(--kz-hero-exit-faint);
}
.kodza-hero-exit__window img:not(#_) {
display: block;
max-width: none;
}
@media (prefers-reduced-motion: no-preference) {
.kodza-hero-exit.is-ready .kodza-hero-exit__hint:not(#_) {
position: absolute;
display: block;
}
}
@media (prefers-reduced-motion: reduce) {
.kodza-hero-exit *:where(p, h1, a, svg, img):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 Exit
// Plays the entrance of Hero Split once the section is in view (the label and title lines rise
// out of their masks, the rest follows), then hands over to the scroll: the scene pins and takes
// itself apart, the top line rising and the bottom one dropping out of the screen while a
// picture opens in the gap between them and tightens into a card that steps back; the next
// section then slides over it. 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-exit';
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.45, 0, 0.55, 1)'; // the moves on the scroll: gentle, a mouse wheel goes by notches
const decoded = (root) => Promise.all([...root.querySelectorAll('img')].map((img) => img.decode().catch(() => {})));
// An easing curve as a function, to shape several edges of one clip-path apart
const bezier = (x1, y1, x2, y2) => (t) => {
let u = t;
for (let i = 0; i < 8; i++) {
const x = 3 * (1 - u) ** 2 * u * x1 + 3 * (1 - u) * u ** 2 * x2 + u ** 3 - t;
const dx = 3 * (1 - u) ** 2 * x1 + 6 * (1 - u) * u * (x2 - x1) + 3 * u ** 2 * (1 - x2);
if (Math.abs(x) < 1e-5 || !dx) break;
u = Math.min(1, Math.max(0, u - x / dx));
}
return 3 * (1 - u) ** 2 * u * y1 + 3 * (1 - u) * u ** 2 * y2 + u ** 3;
};
const travel = bezier(0.45, 0, 0.55, 1);
const part = (t, from, to) => travel(Math.min(1, Math.max(0, (t - from) / (to - from))));
// ---------- 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 track = q('.kodza-hero-exit__track');
const scene = q('.kodza-hero-exit__scene');
const slot = q('.kodza-hero-exit__slot');
const win = q('.kodza-hero-exit__window');
const img = q('.kodza-hero-exit__window img');
const card = q('.kodza-hero-exit__card');
const lines = [...root.querySelectorAll('.kodza-hero-exit__line')];
const above = (el) => el.compareDocumentPosition(slot) & Node.DOCUMENT_POSITION_FOLLOWING;
// 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; the window cuts it to nothing at the seam between the
// lines (the slot), opens there as wide as the scene, then closes in on the card.
const measure = () => {
const W = scene.clientWidth;
const H = scene.clientHeight;
const seam = within(slot).y;
const to = within(card);
const round = parseFloat(getComputedStyle(card).borderTopLeftRadius) || 0;
// t from 0 (shut) to 1 (the card): the height opens first, the sides follow. Under 40px
// tall the band fades with its height, so it never shrinks to a hairline across the screen
const shape = (t) => {
const v = part(t, 0, 0.78);
const h = part(t, 0.3, 1);
const top = seam + (to.y - seam) * v;
const bottom = H - seam - (to.y + to.h - seam) * v;
return {
clipPath: `inset(${top}px ${(W - to.x - to.w) * h}px ${bottom}px ${to.x * h}px round ${round * h}px)`,
opacity: Math.min(1, (to.h * v) / 40),
};
};
win.style.clipPath = shape(0).clipPath;
return { H, shape };
};
// The scroll part, in progress. The track is one screen longer than the hold: from `cover`
// on, the next section slides over the scene, and the card sinks under it.
const timeline = () => {
const { H, shape } = measure();
const run = track.offsetHeight - scene.offsetHeight;
const cover = run > 0 ? Math.max(0.2, (run - scene.offsetHeight) / run) : 1;
// Everything above the seam rises by one distance, everything below drops by one: the
// small print leaves a little ahead of the lines and never gets caught up
const words = [q('.kodza-hero-exit__eyebrow'), ...lines, q('.kodza-hero-exit__foot'), q('.kodza-hero-exit__hint')].filter(Boolean);
const lift = Math.max(...words.filter(above).map((el) => within(el)).map(({ y, h }) => y + h + 24));
const drop = Math.max(...words.filter((el) => !above(el)).map((el) => H - within(el).y + 24));
const away = (el, start, end) => [el, [
{ transform: 'translateY(0px)', easing: TRAVEL },
{ transform: `translateY(${above(el) ? -lift : drop}px)` },
], start * cover, end * cover];
return [
// The lines part, the small print just ahead of them
away(q('.kodza-hero-exit__eyebrow'), 0, 0.62),
...lines.map((line) => away(line, 0, 0.76)),
away(q('.kodza-hero-exit__foot'), 0, 0.62),
away(q('.kodza-hero-exit__hint'), 0, 0.4),
// The picture opens in the gap they leave, settling from a slight zoom, then becomes a card
[win, Array.from({ length: 41 }, (_, i) => shape(i / 40)), 0, 0.9 * cover],
[img, [{ transform: 'scale(1.18)', easing: TRAVEL }, { transform: 'scale(1)' }], 0, 0.9 * cover],
// It steps back, then sinks under the next section
[win, [
{ transform: 'translateY(0px) scale(1)', easing: TRAVEL },
{ transform: 'translateY(0px) scale(0.94)', offset: (cover * 0.3) / (1 - cover * 0.7), easing: TRAVEL },
{ transform: 'translateY(-9%) scale(0.86)' },
], cover * 0.7, 1],
[q('.kodza-hero-exit__shade'), [{ opacity: 0 }, { opacity: 0.45 }], cover * 0.9, 1],
];
};
const pin = scrub(track, scene, signal, timeline, [q('.kodza-hero-exit__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');
measure();
rise(q('.kodza-hero-exit__eyebrow .kodza-hero-exit__rise'), 0, 1000);
lines.forEach((l, i) => rise(l.querySelector(':scope > .kodza-hero-exit__rise'), 110 + i * 125, 1170));
rise(q('.kodza-hero-exit__hint .kodza-hero-exit__rise'), 760, 1000);
[q('.kodza-hero-exit__lead'), q('.kodza-hero-exit__actions')].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 back to a button scrolls back to where it shows: the hero, whole
q('.kodza-hero-exit__foot').addEventListener('focusin', () => {
if (pin.progress() <= 0) return;
const box = root.getBoundingClientRect();
const k = box.height / root.offsetHeight || 1;
window.scrollBy(0, box.top - (parseFloat(getComputedStyle(scene).top) || 0) * k);
}, { 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 Exit, for React. From https://kodza.dev/components/hero-exit/
// 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-exit.css and hero-exit.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-exit.css';
export type HeroExitProps = Omit<ComponentPropsWithoutRef<'section'>, "children" | "vars"> & {
/** Settings, as CSS variables on the root */
vars?: {
/** default: #0a0a0a */
"--kz-hero-exit-ink"?: string | number;
/** default: #f2f2f0 */
"--kz-hero-exit-paper"?: string | number;
/** default: rgba(10, 10, 10, 0.62) */
"--kz-hero-exit-dim"?: string | number;
/** default: rgba(10, 10, 10, 0.6) */
"--kz-hero-exit-faint"?: string | number;
/** default: rgba(10, 10, 10, 0.07) */
"--kz-hero-exit-soft"?: string | number;
/** default: transparent */
"--kz-hero-exit-bg"?: string | number;
/** the card darkens toward it as the next section covers it — default: #0a0a0a */
"--kz-hero-exit-shade"?: string | number;
/** default: 1240px */
"--kz-hero-exit-width"?: string | number;
/** default: clamp(16px, 4vw, 40px) */
"--kz-hero-exit-gutter"?: string | number;
/** default: clamp(20px, 2.6vw, 36px) */
"--kz-hero-exit-pad-y"?: string | number;
/** the title lines — default: clamp(44px, min(14cqi, 17svh), 168px) */
"--kz-hero-exit-size"?: string | number;
/** the part of the picture the card shows — default: 50% 42% */
"--kz-hero-exit-focus"?: string | number;
/** the card's height, as a share of the screen — default: 0.74 */
"--kz-hero-exit-card"?: string | number;
/** the card's width / height — default: 0.8 */
"--kz-hero-exit-ratio"?: string | number;
/** default: 16px */
"--kz-hero-exit-radius"?: string | number;
/** height of a fixed site header: the scene pins under it — default: 0px */
"--kz-hero-exit-top"?: string | number;
/** how long the scene holds before the next section comes — default: 180svh */
"--kz-hero-exit-distance"?: string | number;
/** default: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace */
"--kz-hero-exit-mono"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-hero-exit-ease"?: string | number;
};
className?: string;
};
export function HeroExit({ vars, className, style, ...rest }: HeroExitProps) {
const ref = useRef<ComponentRef<'section'>>(null);
const classes = ["kodza-hero-exit", 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-exit.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-exit__track">
<div className="kodza-hero-exit__scene">
<div className="kodza-hero-exit__inner">
<p className="kodza-hero-exit__eyebrow">
<span className="kodza-hero-exit__rise">Launch week · day one</span>
</p>
<h1 className="kodza-hero-exit__title">
<span className="kodza-hero-exit__line">
<span className="kodza-hero-exit__rise">Enter big.</span>
</span>
{' '}
<span className="kodza-hero-exit__slot" aria-hidden="true" />
{' '}
<span className="kodza-hero-exit__line">
<span className="kodza-hero-exit__rise">Leave clean.</span>
</span>
</h1>
<div className="kodza-hero-exit__foot">
<p className="kodza-hero-exit__lead">The title parts, the picture opens, the next section walks on. One paste, no library.</p>
<div className="kodza-hero-exit__actions">
<a className="kodza-hero-exit__button kodza-hero-exit__button--ink" href="#">Get the code<svg className="kodza-hero-exit__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>
{' '}
<a className="kodza-hero-exit__button kodza-hero-exit__button--soft" href="#">See it live</a>
</div>
</div>
<p className="kodza-hero-exit__hint" aria-hidden="true">
<span className="kodza-hero-exit__rise"><span className="kodza-hero-exit__cue" />Scroll to exit</span>
</p>
</div>
<div className="kodza-hero-exit__media">
<div className="kodza-hero-exit__window">
<img src="https://kodza.dev/media/kz-arch-2400.webp" alt="A concrete wall with a round-topped doorway, sunlight pouring through it" width="2400" height="2400" />
<div className="kodza-hero-exit__shade" />
</div>
<div className="kodza-hero-exit__card" />
</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.