Contact Brief
A project request section: tags for what is needed, a budget and a time frame picked on a sliding pill, name and email. A summary of the brief writes itself as you pick; sent, the form rolls into a receipt.
View codeA contact section: the email to copy, phone, address and a status worked out from the opening hours; a short form that rolls into the thanks. A variant for a place shows the week of hours.
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 Contact Split to my page.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<section class="kodza-contact-split" data-timezone="Europe/Paris" data-hours="mon-fri 09:00-18:00" data-reply="2 h">
<div class="kodza-contact-split__bar">
<p class="kodza-contact-split__eyebrow">Contact</p>
<p class="kodza-contact-split__status" hidden><span class="kodza-contact-split__dot" aria-hidden="true"></span><span class="kodza-contact-split__state"></span><span class="kodza-contact-split__time" data-text="{time} in Bordeaux"></span></p>
</div>
<div class="kodza-contact-split__head">
<h2 class="kodza-contact-split__title">Tell us what you’re building.</h2>
<p class="kodza-contact-split__lead">No ticket number, no sales sequence. A person reads it and writes back.</p>
</div>
<div class="kodza-contact-split__inner">
<div class="kodza-contact-split__ways">
<div class="kodza-contact-split__quick">
<a class="kodza-contact-split__big kodza-contact-split__big--call" href="tel:+33556000000">Call</a>
<a class="kodza-contact-split__big" href="mailto:hello@halyard.example">Email</a>
</div>
<dl class="kodza-contact-split__list">
<div class="kodza-contact-split__item kodza-contact-split__item--email">
<dt class="kodza-contact-split__key">Write to</dt>
<dd>
<button class="kodza-contact-split__copy" type="button" data-copy="hello@halyard.example">
<span class="kodza-contact-split__sr">Copy the address </span>
<span class="kodza-contact-split__roll"><span class="kodza-contact-split__said">hello@halyard.example</span></span>
<svg class="kodza-contact-split__glyph" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5" /><path d="M10.5 3.5v-.5A1.5 1.5 0 0 0 9 1.5H4A1.5 1.5 0 0 0 2.5 3v5A1.5 1.5 0 0 0 4 9.5h.5" /></svg>
</button>
<span class="kodza-contact-split__sr" role="status"></span>
</dd>
</div>
<div class="kodza-contact-split__item kodza-contact-split__item--phone">
<dt class="kodza-contact-split__key">Phone</dt>
<dd><a class="kodza-contact-split__link" href="tel:+33556000000">+33 5 56 00 00 00</a></dd>
</div>
<div class="kodza-contact-split__item">
<dt class="kodza-contact-split__key">Studio</dt>
<dd><address class="kodza-contact-split__address">12 quai des Chartrons<br>33000 Bordeaux</address></dd>
</div>
</dl>
</div>
<form class="kodza-contact-split__form" action="#" method="post">
<div class="kodza-contact-split__stage">
<div class="kodza-contact-split__panel">
<p class="kodza-contact-split__key">Or right here</p>
<div class="kodza-contact-split__fields">
<label class="kodza-contact-split__box">
<input class="kodza-contact-split__input" type="text" name="name" autocomplete="name" placeholder=" " required>
<span class="kodza-contact-split__label">Your name</span>
</label>
<label class="kodza-contact-split__box">
<input class="kodza-contact-split__input" type="email" name="email" autocomplete="email" placeholder=" " required>
<span class="kodza-contact-split__label">Email</span>
</label>
<label class="kodza-contact-split__box kodza-contact-split__box--area">
<textarea class="kodza-contact-split__input kodza-contact-split__area" name="message" rows="3" maxlength="1000" placeholder=" " required></textarea>
<span class="kodza-contact-split__label">What are you building?</span>
<span class="kodza-contact-split__count" aria-hidden="true"></span>
</label>
<label class="kodza-contact-split__trap" aria-hidden="true">Leave this empty <input type="text" name="website" tabindex="-1" autocomplete="off"></label>
</div>
<div class="kodza-contact-split__foot">
<p class="kodza-contact-split__note" aria-live="polite">
<span class="kodza-contact-split__help">Two lines is plenty. We ask the rest.</span>
<span class="kodza-contact-split__error"></span>
</p>
<button class="kodza-contact-split__send" type="submit"><span class="kodza-contact-split__word">Send message</span><span class="kodza-contact-split__spinner" aria-hidden="true"></span></button>
</div>
<p class="kodza-contact-split__privacy">Your details are used to answer you. Nothing else. <a href="/privacy">Privacy</a></p>
</div>
<div class="kodza-contact-split__done" hidden>
<p class="kodza-contact-split__key">Sent</p>
<p class="kodza-contact-split__thanks" data-text="Thanks, {name}.">Thanks.</p>
<p class="kodza-contact-split__next">We write back to <span data-value="email">your address</span>. From a person, not a sequence.</p>
<button class="kodza-contact-split__again" type="reset">Write another</button>
</div>
</div>
</form>
</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 — Contact Split
The contact section: a bar that says whether someone is there right now, a big title, the
address to copy in large type with the other ways under it, and a short form beside them.
The settings come first (the variables below); the markup sets the hours and the timezone
(data-hours, data-timezone, data-reply), the script sets the words. */
.kodza-contact-split {
--kz-contact-split-width: 1200px; /* widest the section gets */
--kz-contact-split-gap: 64px; /* between the two columns */
--kz-contact-split-field: var(--kz-color-paper, #ffffff); /* inside the fields */
--kz-contact-split-fg: var(--kz-color-ink, #0a0a0a);
--kz-contact-split-muted: color-mix(in srgb, var(--kz-contact-split-fg) 62%, transparent);
--kz-contact-split-line: color-mix(in srgb, var(--kz-contact-split-fg) 10%, transparent);
--kz-contact-split-soft: color-mix(in srgb, var(--kz-contact-split-fg) 5%, transparent); /* today's row in the week */
--kz-contact-split-halo: color-mix(in srgb, var(--kz-contact-split-fg) 6%, transparent); /* around a focused field */
--kz-contact-split-ink: var(--kz-color-accent, var(--kz-contact-split-fg)); /* the send button, focus rings */
--kz-contact-split-on-ink: var(--kz-color-on-accent, var(--kz-color-paper, #ffffff));
box-sizing: border-box;
width: 100%;
max-width: var(--kz-contact-split-width);
margin: 0 auto;
color: var(--kz-contact-split-fg);
font-family: inherit;
text-align: left;
container-type: inline-size;
}
/* The small mono labels: the eyebrow, the status, a field's name */
.kodza-contact-split__eyebrow,
.kodza-contact-split__key,
.kodza-contact-split__status {
margin: 0;
font-family: var(--kz-font-label, var(--kz-font-mono, ui-monospace, "SF Mono", Menlo, monospace));
font-size: var(--kz-font-label-size, 12px);
font-weight: var(--kz-font-label-weight, 400);
letter-spacing: var(--kz-font-label-tracking, 0);
text-transform: var(--kz-font-label-case, none);
line-height: var(--kz-font-label-leading, 1.4);
color: var(--kz-contact-split-muted);
}
.kodza-contact-split__bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px 24px;
padding-bottom: 16px;
border-bottom: 1px solid var(--kz-contact-split-line);
}
/* Open or not, worked out from data-hours in the place's timezone, and the time it is there */
.kodza-contact-split__status {
display: inline-flex;
align-items: center;
gap: 10px;
color: var(--kz-contact-split-fg);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.kodza-contact-split__dot {
position: relative;
flex: none;
width: 8px;
height: 8px;
border-radius: 50%;
box-shadow: inset 0 0 0 1.5px currentColor;
transition: background-color calc(0.3s * var(--kz-motion-scale, 1));
}
.kodza-contact-split.is-open .kodza-contact-split__dot {
background-color: currentColor;
}
/* Someone is there: a ring leaves the dot, now and then */
.kodza-contact-split.is-open .kodza-contact-split__dot::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: 0 0 0 1px currentColor;
opacity: 0;
animation: kodza-contact-split-ping calc(2.4s * var(--kz-motion-scale, 1)) cubic-bezier(0.22, 1, 0.36, 1) infinite;
}
@keyframes kodza-contact-split-ping {
0% {
opacity: 0.5;
transform: scale(1);
}
60%,
100% {
opacity: 0;
transform: scale(2.75);
}
}
/* A thin rule between the state and the time */
.kodza-contact-split__status:has(> .kodza-contact-split__time)::after {
content: "";
order: 2;
flex: none;
width: 1px;
height: 12px;
background: var(--kz-contact-split-line);
}
.kodza-contact-split__time {
order: 3;
color: var(--kz-contact-split-muted);
}
/* A line that rolls: the old words go up, the new ones come from below */
.kodza-contact-split__state,
.kodza-contact-split__time,
.kodza-contact-split__roll {
display: inline-grid;
align-items: center;
}
.kodza-contact-split__state > *,
.kodza-contact-split__time > *,
.kodza-contact-split__roll > * {
grid-area: 1 / 1;
justify-self: start;
white-space: nowrap;
}
.kodza-contact-split .is-rolling {
overflow: clip;
}
/* The title on the left, the lead on the right, over the two columns below */
.kodza-contact-split__head,
.kodza-contact-split__inner {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 24px var(--kz-contact-split-gap);
}
.kodza-contact-split__head {
align-items: end;
padding: 64px 0 56px;
}
.kodza-contact-split__title {
margin: 0;
font-family: var(--kz-font-display, inherit);
font-size: calc(clamp(40px, 6cqi, 72px) * var(--kz-font-display-scale, 1));
font-weight: var(--kz-font-display-weight, 600);
line-height: 0.98;
letter-spacing: var(--kz-font-display-tracking, -0.05em);
text-transform: var(--kz-font-display-case, none);
text-wrap: balance;
color: inherit;
}
.kodza-contact-split__lead {
max-width: 40ch;
margin: 0;
font-size: 17px;
line-height: 1.55;
text-wrap: balance;
color: var(--kz-contact-split-muted);
}
.kodza-contact-split__inner {
align-items: start;
padding-top: 40px;
border-top: 1px solid var(--kz-contact-split-line);
}
/* The ways to reach you */
.kodza-contact-split__ways {
min-width: 0;
}
.kodza-contact-split__list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 40px 24px;
margin: 0;
padding: 0;
}
.kodza-contact-split__item {
display: grid;
align-content: start;
gap: 10px;
min-width: 0;
}
.kodza-contact-split__item:not(.kodza-contact-split__item--email) {
padding-top: 16px;
border-top: 1px solid var(--kz-contact-split-line);
}
.kodza-contact-split__item--email {
grid-column: 1 / -1;
}
.kodza-contact-split__item dd {
min-width: 0;
margin: 0;
font-size: 16px;
line-height: 1.5;
}
.kodza-contact-split__copy,
.kodza-contact-split__link {
display: inline-flex;
align-items: center;
gap: 8px;
max-width: 100%;
margin: 0;
padding: 0;
border: 0;
border-radius: calc(4px * var(--kz-radius-scale, 1));
background: none;
color: inherit;
font: inherit;
text-align: left;
text-decoration: none;
cursor: pointer;
}
/* The address, in large type: a click copies it and it rolls to "Copied." */
.kodza-contact-split__copy {
gap: 0.36em;
font-family: var(--kz-font-display, inherit);
font-size: calc(clamp(24px, 3.4cqi, 40px) * var(--kz-font-display-scale, 1));
font-weight: 500;
line-height: 1.15;
letter-spacing: var(--kz-font-display-tracking, -0.035em);
}
.kodza-contact-split__said,
.kodza-contact-split__link {
text-decoration: underline;
text-decoration-color: transparent;
text-decoration-thickness: max(1px, 0.04em);
text-underline-offset: 0.18em;
transition: text-decoration-color calc(0.2s * var(--kz-motion-scale, 1));
}
.kodza-contact-split__glyph {
flex: none;
width: 0.5em;
height: 0.5em;
fill: none;
stroke: currentColor;
stroke-width: 1.3;
opacity: 0.45;
transition: opacity calc(0.2s * var(--kz-motion-scale, 1)), transform calc(0.2s * var(--kz-motion-scale, 1)) cubic-bezier(0.22, 1, 0.36, 1);
}
@media (hover: hover) {
.kodza-contact-split__copy:hover .kodza-contact-split__said,
.kodza-contact-split__link:hover {
text-decoration-color: currentColor;
}
.kodza-contact-split__copy:hover .kodza-contact-split__glyph {
opacity: 1;
transform: translateY(-1px);
}
}
.kodza-contact-split__address {
font-style: normal;
}
/* The week of a place (--venue), written by the script from data-hours */
.kodza-contact-split__week {
margin: 12px 0 0;
padding: 0;
list-style: none;
}
.kodza-contact-split__week + .kodza-contact-split__list {
margin-top: 40px;
}
.kodza-contact-split__day {
display: flex;
justify-content: space-between;
gap: 16px;
margin: 0 -12px;
padding: 8px 12px;
border-radius: calc(10px * var(--kz-radius-scale, 1));
font-size: 15px;
line-height: 1.4;
font-variant-numeric: tabular-nums;
}
.kodza-contact-split__day > span:first-child {
color: var(--kz-contact-split-muted);
}
.kodza-contact-split__day.is-today {
background: var(--kz-contact-split-soft);
font-weight: 500;
}
.kodza-contact-split__day.is-today > span:first-child {
color: inherit;
}
.kodza-contact-split__day.is-closed > span:last-child {
color: var(--kz-contact-split-muted);
}
/* Call and Email as two big buttons, on a phone */
.kodza-contact-split__quick {
display: none;
}
.kodza-contact-split__big {
display: flex;
align-items: center;
justify-content: center;
height: 52px;
border-radius: var(--kz-radius-pill, 999px);
box-shadow: inset 0 0 0 1px var(--kz-contact-split-line);
color: inherit;
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 15px);
font-weight: var(--kz-font-label-weight, 500);
letter-spacing: var(--kz-font-label-tracking, -0.01em);
text-transform: var(--kz-font-label-case, none);
line-height: 1;
text-decoration: none;
transition: transform calc(0.15s * var(--kz-motion-scale, 1)) cubic-bezier(0.22, 1, 0.36, 1);
}
.kodza-contact-split__big--call {
box-shadow: none;
background: var(--kz-contact-split-ink);
color: var(--kz-contact-split-on-ink);
}
.kodza-contact-split__big:active {
transform: scale(0.97);
}
/* The form, straight on the section */
.kodza-contact-split__form {
box-sizing: border-box;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
background: none;
color: var(--kz-contact-split-fg);
}
.kodza-contact-split__stage .kodza-contact-split__key {
margin-bottom: 16px;
}
/* The form and the answer share one cell: the height goes from one to the other */
.kodza-contact-split__stage {
display: grid;
align-items: start;
}
.kodza-contact-split__stage > * {
grid-area: 1 / 1;
min-width: 0;
}
.kodza-contact-split__fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.kodza-contact-split__box {
position: relative;
display: block;
height: 56px;
margin: 0;
border-radius: calc(12px * var(--kz-radius-scale, 1));
background: var(--kz-contact-split-field);
box-shadow: inset 0 0 0 1px var(--kz-contact-split-line);
cursor: text;
transition: box-shadow calc(0.2s * var(--kz-motion-scale, 1));
}
.kodza-contact-split__box:focus-within {
box-shadow: inset 0 0 0 1.5px var(--kz-contact-split-ink), 0 0 0 4px var(--kz-contact-split-halo);
}
.kodza-contact-split__box:has([aria-invalid="true"]) {
box-shadow: inset 0 0 0 1.5px var(--kz-contact-split-ink);
}
.kodza-contact-split__box:has([aria-invalid="true"]):focus-within {
box-shadow: inset 0 0 0 1.5px var(--kz-contact-split-ink), 0 0 0 4px var(--kz-contact-split-halo);
}
.kodza-contact-split__input {
position: absolute;
inset: 0;
box-sizing: border-box;
width: 100%;
height: 100%;
margin: 0;
padding: 22px 16px 6px;
border: 0;
border-radius: inherit;
background: none;
color: inherit;
font: inherit;
font-size: 16px;
line-height: 22px;
appearance: none;
outline: none;
box-shadow: none;
}
/* The message: its box grows with the text, then scrolls */
.kodza-contact-split__box--area {
grid-column: 1 / -1;
height: auto;
}
.kodza-contact-split__area {
position: relative;
display: block;
min-height: 132px;
max-height: 320px;
padding: 26px 16px 30px;
overflow-y: hidden;
resize: none;
}
.kodza-contact-split__area.is-long {
overflow-y: auto;
}
.kodza-contact-split__label {
position: absolute;
top: 28px;
left: 16px;
max-width: calc(100% - 32px);
overflow: hidden;
font-size: 15px;
line-height: 1.2;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--kz-contact-split-muted);
pointer-events: none;
transform: translateY(-50%);
transform-origin: left center;
transition: transform calc(0.35s * var(--kz-motion-scale, 1)) cubic-bezier(0.22, 1, 0.36, 1);
}
.kodza-contact-split__input:focus + .kodza-contact-split__label,
.kodza-contact-split__input:not(:placeholder-shown) + .kodza-contact-split__label {
transform: translateY(calc(-50% - 14px)) scale(0.8);
}
.kodza-contact-split__box--area .kodza-contact-split__input:focus + .kodza-contact-split__label,
.kodza-contact-split__box--area .kodza-contact-split__input:not(:placeholder-shown) + .kodza-contact-split__label {
transform: translateY(calc(-50% - 12px)) scale(0.8);
}
.kodza-contact-split__count {
position: absolute;
right: 14px;
bottom: 10px;
font-family: var(--kz-font-label, var(--kz-font-mono, ui-monospace, "SF Mono", Menlo, monospace));
font-size: var(--kz-font-label-size, 12px);
font-weight: var(--kz-font-label-weight, 400);
letter-spacing: var(--kz-font-label-tracking, 0);
text-transform: var(--kz-font-label-case, none);
line-height: 1;
font-variant-numeric: tabular-nums;
color: var(--kz-contact-split-muted);
pointer-events: none;
opacity: 0;
transform: translateY(4px);
transition: opacity calc(0.25s * var(--kz-motion-scale, 1)), transform calc(0.25s * var(--kz-motion-scale, 1)) cubic-bezier(0.22, 1, 0.36, 1);
}
.kodza-contact-split__box.is-near .kodza-contact-split__count {
opacity: 1;
transform: none;
}
/* Robots fill it, people never see it */
.kodza-contact-split__trap {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.kodza-contact-split__trap input {
box-sizing: border-box;
}
.kodza-contact-split__foot {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 20px;
}
/* Help and error share one line, and swap */
.kodza-contact-split__note {
display: grid;
flex: 1 1 200px;
min-width: 0;
margin: 0;
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 13px);
font-weight: var(--kz-font-label-weight, 400);
letter-spacing: var(--kz-font-label-tracking, 0);
text-transform: var(--kz-font-label-case, none);
line-height: var(--kz-font-label-leading, 1.4);
color: var(--kz-contact-split-muted);
}
.kodza-contact-split__help,
.kodza-contact-split__error {
grid-area: 1 / 1;
transition: opacity calc(0.25s * var(--kz-motion-scale, 1)), transform calc(0.35s * var(--kz-motion-scale, 1)) cubic-bezier(0.22, 1, 0.36, 1), visibility 0s linear calc(0.35s * var(--kz-motion-scale, 1));
}
.kodza-contact-split__error {
display: flex;
align-items: flex-start;
gap: 6px;
color: var(--kz-contact-split-fg);
opacity: 0;
visibility: hidden;
transform: translateY(6px);
}
.kodza-contact-split__error::before {
content: "";
flex: none;
width: 14px;
height: 14px;
margin-top: 0.1em;
background: currentColor;
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1Zm0 3.25a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0V5A.75.75 0 0 1 8 4.25Zm0 7.75a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1Zm0 3.25a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0V5A.75.75 0 0 1 8 4.25Zm0 7.75a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z'/%3E%3C/svg%3E") center / contain no-repeat;
}
.kodza-contact-split__form.is-error .kodza-contact-split__help {
opacity: 0;
visibility: hidden;
transform: translateY(-6px);
}
.kodza-contact-split__form.is-error .kodza-contact-split__error {
opacity: 1;
visibility: visible;
transform: none;
transition-delay: 0s;
}
/* The send button: the spinner takes the word's place, the width stays */
.kodza-contact-split__send {
display: inline-grid;
place-items: center;
flex: none;
height: 52px;
margin: 0;
padding: 0 24px;
border: 0;
border-radius: var(--kz-radius-pill, 999px);
background: var(--kz-contact-split-ink);
color: var(--kz-contact-split-on-ink);
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 15px);
font-weight: var(--kz-font-label-weight, 500);
letter-spacing: var(--kz-font-label-tracking, -0.01em);
text-transform: var(--kz-font-label-case, none);
line-height: 1;
cursor: pointer;
transition: transform calc(0.15s * var(--kz-motion-scale, 1)) cubic-bezier(0.22, 1, 0.36, 1), opacity calc(0.2s * var(--kz-motion-scale, 1));
}
.kodza-contact-split__word,
.kodza-contact-split__spinner {
grid-area: 1 / 1;
transition: opacity calc(0.2s * var(--kz-motion-scale, 1)), transform calc(0.3s * var(--kz-motion-scale, 1)) cubic-bezier(0.22, 1, 0.36, 1);
}
.kodza-contact-split__spinner {
box-sizing: border-box;
width: 16px;
height: 16px;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
opacity: 0;
transform: scale(0.6);
animation: kodza-contact-split-spin calc(0.8s * var(--kz-motion-scale, 1)) linear infinite;
}
.kodza-contact-split__send[aria-busy="true"] {
cursor: progress;
}
.kodza-contact-split__send[aria-busy="true"] .kodza-contact-split__word {
opacity: 0;
transform: translateY(-6px);
}
.kodza-contact-split__send[aria-busy="true"] .kodza-contact-split__spinner {
opacity: 1;
transform: none;
}
@keyframes kodza-contact-split-spin {
to {
rotate: 360deg;
}
}
.kodza-contact-split__send:active,
.kodza-contact-split__again:active {
transform: scale(0.97);
}
@media (hover: hover) {
.kodza-contact-split__send:hover {
opacity: 0.86;
}
}
.kodza-contact-split__privacy {
margin: 18px 0 0;
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 13px);
font-weight: var(--kz-font-label-weight, 400);
letter-spacing: var(--kz-font-label-tracking, 0);
text-transform: var(--kz-font-label-case, none);
line-height: var(--kz-font-label-leading, 1.4);
color: var(--kz-contact-split-muted);
}
.kodza-contact-split__privacy a {
color: var(--kz-contact-split-fg);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 3px;
}
/* The answer */
.kodza-contact-split__done {
padding: 4px 0;
}
.kodza-contact-split__thanks {
margin: 0;
font-family: var(--kz-font-display, inherit);
font-size: calc(clamp(30px, 3.6cqi, 44px) * var(--kz-font-display-scale, 1));
font-weight: var(--kz-font-display-weight, 600);
line-height: 1.1;
letter-spacing: var(--kz-font-display-tracking, -0.04em);
text-transform: var(--kz-font-display-case, none);
text-wrap: balance;
outline: none;
}
.kodza-contact-split__next {
max-width: 44ch;
margin: 12px 0 0;
font-size: 15px;
line-height: 1.55;
text-wrap: pretty;
color: var(--kz-contact-split-muted);
}
.kodza-contact-split__next [data-value] {
color: var(--kz-contact-split-fg);
overflow-wrap: anywhere;
}
.kodza-contact-split__again {
height: 44px;
margin: 24px 0 0;
padding: 0 20px;
border: 0;
border-radius: var(--kz-radius-pill, 999px);
background: none;
box-shadow: inset 0 0 0 1px var(--kz-contact-split-line);
color: inherit;
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 15px);
font-weight: var(--kz-font-label-weight, 500);
letter-spacing: var(--kz-font-label-tracking, -0.01em);
text-transform: var(--kz-font-label-case, none);
line-height: 1;
cursor: pointer;
transition: transform calc(0.15s * var(--kz-motion-scale, 1)) cubic-bezier(0.22, 1, 0.36, 1), box-shadow calc(0.2s * var(--kz-motion-scale, 1));
}
@media (hover: hover) {
.kodza-contact-split__again:hover {
box-shadow: inset 0 0 0 1px var(--kz-contact-split-fg);
}
}
.kodza-contact-split__copy:focus-visible,
.kodza-contact-split__link:focus-visible,
.kodza-contact-split__big:focus-visible,
.kodza-contact-split__send:focus-visible,
.kodza-contact-split__again:focus-visible,
.kodza-contact-split__privacy a:focus-visible {
outline: 2px solid var(--kz-contact-split-ink);
outline-offset: 2px;
}
.kodza-contact-split__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.kodza-contact-split__status[hidden],
.kodza-contact-split__panel[hidden],
.kodza-contact-split__done[hidden] {
display: none;
}
/* A narrow column (a sidebar, a theme's text column): one column */
@container (max-width: 760px) {
.kodza-contact-split__head,
.kodza-contact-split__inner {
grid-template-columns: minmax(0, 1fr);
}
.kodza-contact-split__head {
gap: 20px;
padding: 40px 0 36px;
}
.kodza-contact-split__inner {
gap: 48px;
padding-top: 32px;
}
}
/* A narrow column: the time goes under the status, past the dot */
@container (max-width: 520px) {
.kodza-contact-split__bar {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.kodza-contact-split__status {
display: grid;
grid-template-columns: 8px auto;
justify-items: start;
align-items: center;
column-gap: 10px;
row-gap: 2px;
}
.kodza-contact-split__status:has(> .kodza-contact-split__time)::after {
display: none;
}
.kodza-contact-split__time {
grid-column: 2;
}
}
/* Below this, the form's column is too narrow for two fields side by side */
@container (max-width: 1000px) {
.kodza-contact-split__fields {
grid-template-columns: minmax(0, 1fr);
}
}
/* On a phone: Call and Email as two big buttons, the phone line goes */
@media (max-width: 600px) {
.kodza-contact-split__quick {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-bottom: 32px;
}
.kodza-contact-split__list {
grid-template-columns: minmax(0, 1fr);
gap: 32px;
}
.kodza-contact-split__item--phone {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.kodza-contact-split,
.kodza-contact-split *,
.kodza-contact-split *::before,
.kodza-contact-split *::after {
transition-duration: 0.01ms !important;
}
.kodza-contact-split__spinner,
.kodza-contact-split.is-open .kodza-contact-split__dot::after {
animation: none;
}
}
/* Dark: set the variables on the section, or on a parent
.kodza-contact-split {
--kz-contact-split-field: #161616;
--kz-contact-split-fg: #f2f2f0;
--kz-contact-split-muted: rgba(242, 242, 240, 0.62);
--kz-contact-split-line: rgba(255, 255, 255, 0.12);
--kz-contact-split-soft: rgba(255, 255, 255, 0.06);
--kz-contact-split-halo: rgba(255, 255, 255, 0.08);
--kz-contact-split-ink: #f2f2f0;
--kz-contact-split-on-ink: #0a0a0a;
}
*/
/* kodza-guard e34fa4c229bf — 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-contact-split__eyebrow):not(#_),
:where(.kodza-contact-split__status):not(#_),
:where(.kodza-contact-split__title):not(#_),
:where(.kodza-contact-split__lead):not(#_),
:where(.kodza-contact-split__list):not(#_),
:where(.kodza-contact-split__key):not(#_),
:where(.kodza-contact-split__address):not(#_),
:where(.kodza-contact-split__form):not(#_),
:where(.kodza-contact-split__trap):not(#_),
:where(.kodza-contact-split__note):not(#_),
:where(.kodza-contact-split__privacy):not(#_),
:where(.kodza-contact-split__thanks):not(#_),
:where(.kodza-contact-split__next):not(#_),
:where(.kodza-contact-split__week):not(#_),
:where(.kodza-contact-split__item dd:where(:is(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))):not(.kodza-contact-split__item :not(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))) *))):not(#_),
:where(.kodza-contact-split__trap input:where(:not([class*="kodza-"]))):not(#_) {
transition-duration: revert;
}
:where(.kodza-contact-split__big):not(#_) {
outline: revert;
background-color: revert;
background-image: revert;
outline-offset: revert;
}
:where(.kodza-contact-split__copy):not(#_),
:where(.kodza-contact-split__privacy a:where(:not([class*="kodza-"]))):not(#_) {
outline: revert;
outline-offset: revert;
transition-duration: revert;
}
:where(.kodza-contact-split__link):not(#_),
:where(.kodza-contact-split__send):not(#_),
:where(.kodza-contact-split__again):not(#_) {
outline: revert;
outline-offset: revert;
}
:where(.kodza-contact-split__input):not(#_) {
max-height: revert;
transition-duration: revert;
min-height: revert;
}
:where(.kodza-contact-split__day):not(#_) {
background-color: revert;
background-image: revert;
font-weight: revert;
transition-duration: revert;
}
:where(.kodza-contact-split__input):not(#_):where(:not([hidden])) {
display: revert;
}
:where(.kodza-contact-split__input):not(#_):where([hidden]) {
display: none;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-contact-split__bar:not(#_),
.kodza-contact-split__dot:not(#_),
.kodza-contact-split__state:not(#_),
.kodza-contact-split__line:not(#_),
.kodza-contact-split__time:not(#_),
.kodza-contact-split__at:not(#_),
.kodza-contact-split__head:not(#_),
.kodza-contact-split__inner:not(#_),
.kodza-contact-split__ways:not(#_),
.kodza-contact-split__quick:not(#_),
.kodza-contact-split__item:not(#_),
.kodza-contact-split__sr:not(#_),
.kodza-contact-split__roll:not(#_),
.kodza-contact-split__said:not(#_),
.kodza-contact-split__stage:not(#_),
.kodza-contact-split__panel:not(#_),
.kodza-contact-split__fields:not(#_),
.kodza-contact-split__label:not(#_),
.kodza-contact-split__count:not(#_),
.kodza-contact-split__foot:not(#_),
.kodza-contact-split__help:not(#_),
.kodza-contact-split__error:not(#_),
.kodza-contact-split__word:not(#_),
.kodza-contact-split__done:not(#_),
.kodza-contact-split__next span:where(:not([class*="kodza-"])):not(#_),
.kodza-contact-split__day span:where(:not([class*="kodza-"])):not(#_) {
box-sizing: revert;
}
.kodza-contact-split__eyebrow:not(#_),
.kodza-contact-split__status:not(#_),
.kodza-contact-split__title:not(#_),
.kodza-contact-split__key:not(#_),
.kodza-contact-split__note:not(#_),
.kodza-contact-split__privacy: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-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-contact-split__lead:not(#_),
.kodza-contact-split__next: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-contact-split__big:not(#_) {
border: revert;
margin: revert;
padding: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-style: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-contact-split__list:not(#_) {
border: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
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-contact-split__copy:not(#_) {
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
box-shadow: revert;
appearance: revert;
max-height: revert;
text-transform: revert;
text-underline-offset: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
transition-property: revert;
transition-timing-function: revert;
transition-delay: revert;
white-space: revert;
min-height: revert;
}
.kodza-contact-split__glyph: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-contact-split__link:not(#_) {
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
box-shadow: revert;
appearance: revert;
max-height: revert;
letter-spacing: revert;
text-transform: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-contact-split__address: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;
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-contact-split__form:not(#_) {
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
float: revert;
position: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
transition-property: revert;
transition-timing-function: revert;
transition-delay: revert;
}
.kodza-contact-split__box:not(#_) {
border: revert;
padding: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: 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-contact-split__input:not(#_) {
text-decoration: revert;
list-style: revert;
float: revert;
outline-offset: revert;
max-width: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
transition-property: revert;
transition-timing-function: revert;
transition-delay: revert;
white-space: revert;
}
.kodza-contact-split__trap: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-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
transition-property: revert;
transition-timing-function: revert;
transition-delay: revert;
}
.kodza-contact-split__send:not(#_) {
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
box-shadow: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-style: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
white-space: revert;
min-height: revert;
}
.kodza-contact-split__thanks:not(#_) {
border: revert;
padding: revert;
border-radius: 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-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-contact-split__again:not(#_) {
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-style: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
white-space: revert;
min-height: revert;
}
.kodza-contact-split__week:not(#_) {
border: revert;
border-radius: revert;
outline: revert;
text-decoration: 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;
transition-property: revert;
transition-timing-function: revert;
transition-delay: revert;
}
.kodza-contact-split__day:not(#_) {
border: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: 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-contact-split__item dd:where(:is(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))):not(.kodza-contact-split__item :not(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))) *)):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-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-contact-split__trap input:where(:not([class*="kodza-"])):not(#_) {
border: revert;
margin: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: 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;
transition-property: revert;
transition-timing-function: revert;
transition-delay: revert;
white-space: revert;
min-height: revert;
}
.kodza-contact-split__privacy a:where(:not([class*="kodza-"])):not(#_) {
border: revert;
margin: revert;
padding: revert;
border-radius: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
transition-property: revert;
transition-timing-function: revert;
transition-delay: revert;
}
.kodza-contact-split__eyebrow:not(#_):where(:not([hidden])),
.kodza-contact-split__title:not(#_):where(:not([hidden])),
.kodza-contact-split__lead:not(#_):where(:not([hidden])),
.kodza-contact-split__key:not(#_):where(:not([hidden])),
.kodza-contact-split__glyph:not(#_):where(:not([hidden])),
.kodza-contact-split__address:not(#_):where(:not([hidden])),
.kodza-contact-split__form:not(#_):where(:not([hidden])),
.kodza-contact-split__trap:not(#_):where(:not([hidden])),
.kodza-contact-split__privacy:not(#_):where(:not([hidden])),
.kodza-contact-split__thanks:not(#_):where(:not([hidden])),
.kodza-contact-split__next:not(#_):where(:not([hidden])),
.kodza-contact-split__again:not(#_):where(:not([hidden])),
.kodza-contact-split__week:not(#_):where(:not([hidden])),
.kodza-contact-split__item dd:where(:is(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))):not(.kodza-contact-split__item :not(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))) *)):not(#_):where(:not([hidden])),
.kodza-contact-split__trap input:where(:not([class*="kodza-"])):not(#_):where(:not([hidden])),
.kodza-contact-split__privacy a:where(:not([class*="kodza-"])):not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-contact-split__eyebrow:not(#_):where([hidden]),
.kodza-contact-split__title:not(#_):where([hidden]),
.kodza-contact-split__lead:not(#_):where([hidden]),
.kodza-contact-split__key:not(#_):where([hidden]),
.kodza-contact-split__glyph:not(#_):where([hidden]),
.kodza-contact-split__address:not(#_):where([hidden]),
.kodza-contact-split__form:not(#_):where([hidden]),
.kodza-contact-split__trap:not(#_):where([hidden]),
.kodza-contact-split__privacy:not(#_):where([hidden]),
.kodza-contact-split__thanks:not(#_):where([hidden]),
.kodza-contact-split__next:not(#_):where([hidden]),
.kodza-contact-split__again:not(#_):where([hidden]),
.kodza-contact-split__week:not(#_):where([hidden]),
.kodza-contact-split__item dd:where(:is(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))):not(.kodza-contact-split__item :not(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))) *)):not(#_):where([hidden]),
.kodza-contact-split__trap input:where(:not([class*="kodza-"])):not(#_):where([hidden]),
.kodza-contact-split__privacy a:where(:not([class*="kodza-"])):not(#_):where([hidden]) {
display: none;
}
.kodza-contact-split__dot:not(#_)::after,
.kodza-contact-split__status:not(#_)::after,
.kodza-contact-split__error: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-contact-split:not(#_) {
width: min(100%, var(--kz-contact-split-width));
}
.kodza-contact-split__eyebrow:not(#_),
.kodza-contact-split__key:not(#_),
.kodza-contact-split__status:not(#_) {
margin: 0;
font-family: var(--kz-font-label, var(--kz-font-mono, ui-monospace, "SF Mono", Menlo, monospace));
font-size: var(--kz-font-label-size, 12px);
font-weight: var(--kz-font-label-weight, 400);
letter-spacing: var(--kz-font-label-tracking, 0);
text-transform: var(--kz-font-label-case, none);
line-height: var(--kz-font-label-leading, 1.4);
color: var(--kz-contact-split-muted);
}
.kodza-contact-split__status:not(#_) {
display: inline-flex;
color: var(--kz-contact-split-fg);
}
.kodza-contact-split .is-rolling:not(#_) {
overflow: clip;
}
.kodza-contact-split__title:not(#_) {
margin: 0;
font-family: var(--kz-font-display, inherit);
font-size: calc(clamp(40px, 6cqi, 72px) * var(--kz-font-display-scale, 1));
font-weight: var(--kz-font-display-weight, 600);
line-height: 0.98;
letter-spacing: var(--kz-font-display-tracking, -0.05em);
text-transform: var(--kz-font-display-case, none);
color: inherit;
}
.kodza-contact-split__lead:not(#_) {
max-width: 40ch;
margin: 0;
font-size: 17px;
line-height: 1.55;
color: var(--kz-contact-split-muted);
}
.kodza-contact-split__list:not(#_) {
display: grid;
margin: 0;
padding: 0;
}
.kodza-contact-split__item dd:not(#_) {
margin: 0;
font-size: 16px;
line-height: 1.5;
}
.kodza-contact-split__copy:not(#_) {
display: inline-flex;
align-items: center;
gap: 8px;
max-width: 100%;
margin: 0;
padding: 0;
border: 0;
border-radius: calc(4px * var(--kz-radius-scale, 1));
background: none;
color: inherit;
font: inherit;
text-align: left;
text-decoration: none;
cursor: pointer;
}
.kodza-contact-split__link:not(#_) {
display: inline-flex;
max-width: 100%;
margin: 0;
padding: 0;
border: 0;
border-radius: calc(4px * var(--kz-radius-scale, 1));
background: none;
color: inherit;
font: inherit;
text-align: left;
text-decoration: none;
}
.kodza-contact-split__copy:not(#_) {
gap: 0.36em;
font-family: var(--kz-font-display, inherit);
font-size: calc(clamp(24px, 3.4cqi, 40px) * var(--kz-font-display-scale, 1));
font-weight: 500;
line-height: 1.15;
letter-spacing: var(--kz-font-display-tracking, -0.035em);
}
.kodza-contact-split__link:not(#_) {
text-decoration: underline;
text-decoration-color: transparent;
text-decoration-thickness: max(1px, 0.04em);
text-underline-offset: 0.18em;
transition: text-decoration-color calc(0.2s * var(--kz-motion-scale, 1));
}
.kodza-contact-split__glyph:not(#_) {
transition: opacity calc(0.2s * var(--kz-motion-scale, 1)), transform calc(0.2s * var(--kz-motion-scale, 1)) cubic-bezier(0.22, 1, 0.36, 1);
}
@media (hover: hover) {
.kodza-contact-split__link:hover:not(#_) {
text-decoration-color: currentColor;
}
}
.kodza-contact-split__address:not(#_) {
font-style: normal;
}
.kodza-contact-split__week:not(#_) {
margin: 12px 0 0;
padding: 0;
list-style: none;
}
.kodza-contact-split__week + .kodza-contact-split__list:not(#_) {
margin-top: 40px;
}
.kodza-contact-split__day:not(#_) {
display: flex;
margin: 0 -12px;
padding: 8px 12px;
border-radius: calc(10px * var(--kz-radius-scale, 1));
font-size: 15px;
line-height: 1.4;
}
.kodza-contact-split__day.is-today:not(#_) {
background: var(--kz-contact-split-soft);
font-weight: 500;
}
.kodza-contact-split__big:not(#_) {
display: flex;
border-radius: var(--kz-radius-pill, 999px);
box-shadow: inset 0 0 0 1px var(--kz-contact-split-line);
color: inherit;
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 15px);
font-weight: var(--kz-font-label-weight, 500);
letter-spacing: var(--kz-font-label-tracking, -0.01em);
text-transform: var(--kz-font-label-case, none);
line-height: 1;
text-decoration: none;
transition: transform calc(0.15s * var(--kz-motion-scale, 1)) cubic-bezier(0.22, 1, 0.36, 1);
}
.kodza-contact-split__big--call:not(#_) {
box-shadow: none;
background: var(--kz-contact-split-ink);
color: var(--kz-contact-split-on-ink);
}
.kodza-contact-split__form:not(#_) {
box-sizing: border-box;
margin: 0;
padding: 0;
border: 0;
background: none;
color: var(--kz-contact-split-fg);
}
.kodza-contact-split__stage .kodza-contact-split__key:not(#_) {
margin-bottom: 16px;
}
.kodza-contact-split__box:not(#_) {
position: relative;
display: block;
margin: 0;
border-radius: calc(12px * var(--kz-radius-scale, 1));
background: var(--kz-contact-split-field);
box-shadow: inset 0 0 0 1px var(--kz-contact-split-line);
transition: box-shadow calc(0.2s * var(--kz-motion-scale, 1));
}
.kodza-contact-split__box:focus-within:not(#_) {
box-shadow: inset 0 0 0 1.5px var(--kz-contact-split-ink), 0 0 0 4px var(--kz-contact-split-halo);
}
.kodza-contact-split__box:has([aria-invalid="true"]):not(#_) {
box-shadow: inset 0 0 0 1.5px var(--kz-contact-split-ink);
}
.kodza-contact-split__box:has([aria-invalid="true"]):focus-within:not(#_) {
box-shadow: inset 0 0 0 1.5px var(--kz-contact-split-ink), 0 0 0 4px var(--kz-contact-split-halo);
}
.kodza-contact-split__input:not(#_) {
position: absolute;
inset: 0;
box-sizing: border-box;
width: 100%;
height: 100%;
margin: 0;
padding: 22px 16px 6px;
border: 0;
border-radius: inherit;
background: none;
color: inherit;
font: inherit;
font-size: 16px;
line-height: 22px;
appearance: none;
outline: none;
box-shadow: none;
}
.kodza-contact-split__area:not(#_) {
position: relative;
display: block;
min-height: 132px;
max-height: 320px;
padding: 26px 16px 30px;
overflow-y: hidden;
resize: none;
}
.kodza-contact-split__area.is-long:not(#_) {
overflow-y: auto;
}
.kodza-contact-split__trap:not(#_) {
position: absolute;
}
.kodza-contact-split__trap input:not(#_) {
box-sizing: border-box;
}
.kodza-contact-split__note:not(#_) {
display: grid;
margin: 0;
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 13px);
font-weight: var(--kz-font-label-weight, 400);
letter-spacing: var(--kz-font-label-tracking, 0);
text-transform: var(--kz-font-label-case, none);
line-height: var(--kz-font-label-leading, 1.4);
color: var(--kz-contact-split-muted);
}
.kodza-contact-split__send:not(#_) {
display: inline-grid;
place-items: center;
flex: none;
height: 52px;
margin: 0;
padding: 0 24px;
border: 0;
border-radius: var(--kz-radius-pill, 999px);
background: var(--kz-contact-split-ink);
color: var(--kz-contact-split-on-ink);
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 15px);
font-weight: var(--kz-font-label-weight, 500);
letter-spacing: var(--kz-font-label-tracking, -0.01em);
text-transform: var(--kz-font-label-case, none);
line-height: 1;
cursor: pointer;
transition: transform calc(0.15s * var(--kz-motion-scale, 1)) cubic-bezier(0.22, 1, 0.36, 1), opacity calc(0.2s * var(--kz-motion-scale, 1));
}
.kodza-contact-split__send[aria-busy="true"]:not(#_) {
cursor: progress;
}
.kodza-contact-split__send:active:not(#_),
.kodza-contact-split__again:active:not(#_) {
transform: scale(0.97);
}
@media (hover: hover) {
.kodza-contact-split__send:hover:not(#_) {
opacity: 0.86;
}
}
.kodza-contact-split__privacy:not(#_) {
margin: 18px 0 0;
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 13px);
font-weight: var(--kz-font-label-weight, 400);
letter-spacing: var(--kz-font-label-tracking, 0);
text-transform: var(--kz-font-label-case, none);
line-height: var(--kz-font-label-leading, 1.4);
color: var(--kz-contact-split-muted);
}
.kodza-contact-split__privacy a:not(#_) {
color: var(--kz-contact-split-fg);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 3px;
}
.kodza-contact-split__thanks:not(#_) {
margin: 0;
font-family: var(--kz-font-display, inherit);
font-size: calc(clamp(30px, 3.6cqi, 44px) * var(--kz-font-display-scale, 1));
font-weight: var(--kz-font-display-weight, 600);
line-height: 1.1;
letter-spacing: var(--kz-font-display-tracking, -0.04em);
text-transform: var(--kz-font-display-case, none);
outline: none;
}
.kodza-contact-split__next:not(#_) {
max-width: 44ch;
margin: 12px 0 0;
font-size: 15px;
line-height: 1.55;
color: var(--kz-contact-split-muted);
}
.kodza-contact-split__again:not(#_) {
height: 44px;
margin: 24px 0 0;
padding: 0 20px;
border: 0;
border-radius: var(--kz-radius-pill, 999px);
background: none;
box-shadow: inset 0 0 0 1px var(--kz-contact-split-line);
color: inherit;
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 15px);
font-weight: var(--kz-font-label-weight, 500);
letter-spacing: var(--kz-font-label-tracking, -0.01em);
text-transform: var(--kz-font-label-case, none);
line-height: 1;
cursor: pointer;
transition: transform calc(0.15s * var(--kz-motion-scale, 1)) cubic-bezier(0.22, 1, 0.36, 1), box-shadow calc(0.2s * var(--kz-motion-scale, 1));
}
@media (hover: hover) {
.kodza-contact-split__again:hover:not(#_) {
box-shadow: inset 0 0 0 1px var(--kz-contact-split-fg);
}
}
.kodza-contact-split__copy:focus-visible:not(#_),
.kodza-contact-split__send:focus-visible:not(#_),
.kodza-contact-split__again:focus-visible:not(#_) {
outline: 2px solid var(--kz-contact-split-ink);
outline-offset: 2px;
}
.kodza-contact-split__link:focus-visible:not(#_),
.kodza-contact-split__big:focus-visible:not(#_),
.kodza-contact-split__privacy a:focus-visible:not(#_) {
outline: 2px solid var(--kz-contact-split-ink);
outline-offset: 2px;
}
.kodza-contact-split__status[hidden]:not(#_) {
display: none;
}
@container (max-width: 520px) {
.kodza-contact-split__status:not(#_) {
display: grid;
}
}
@media (prefers-reduced-motion: reduce) {
.kodza-contact-split *:not(#_),
.kodza-contact-split *:not(#_)::before,
.kodza-contact-split *:not(#_)::after {
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 — Contact Split
// The contact section in one paste: the ways to reach you on the left, a short form on the right.
// The status line works out from the hours whether someone is there right now ("Available now ·
// replies within 2 h", "Back tomorrow 9:00"), in your timezone, wherever the visitor is, next to
// the time it is where you are ("10:30 in Bordeaux"), whose minutes roll as they pass. The email
// copies in one click (the address rolls into "Copied." and back). On send, the form rolls up into
// the thanks; if the send fails, what was typed stays, with the address to write to instead.
// The form is a real one: it goes to its `action` (name, email, message), the usual way, or with
// fetch under data-send="fetch". A hidden field catches robots: filled, nothing is sent.
// Settings, on the section: data-hours ("mon-fri 09:00-18:00; sat 10:00-13:00"), data-timezone
// (yours, such as "Europe/Paris"), data-reply ("2 h", in the status line), data-locale (the names
// of days, "en-GB" by default), data-now ("2026-11-05T09:30", to start the clock there in a demo). The
// --venue modifier is a place with opening hours: the status says open or closed, and the week is
// written from data-hours into the list .kodza-contact-split__week. On the form: data-send,
// data-failed (the message when a fetch send fails). The words of the script, and the timings,
// are the constants below.
// The page hears kodza:submit (cancelable, event.detail.data the FormData) before the send.
(() => {
const SELECTOR = '.kodza-contact-split';
const WORDS = {
available: 'Available now · replies within {reply}',
availableNow: 'Available now',
back: 'Back {day} {time}',
backToday: 'Back at {time}',
open: 'Open now · until {time}',
opens: 'Closed · opens {day} {time}',
opensToday: 'Closed · opens at {time}',
tomorrow: 'tomorrow',
closed: 'Closed',
copied: 'Copied.',
cantCopy: "This page can't copy.",
left: '{left} left', // the message's count, from 80 % of its maxlength
name: 'Your name, so we know who we write to.',
email: 'That email does not look right.',
message: 'A line or two about the project.',
failed: 'That did not go through. Write to {email} instead.',
failedPlain: 'That did not go through. Try again.',
};
const LOCALE = 'en-GB'; // the names of days, without data-locale
const STAGE = 900; // ms: the form rolls up and the thanks comes in
const LAG_IN = 60; // ms: the thanks starts this much after the form
const LINE = 450; // ms: a line rolls (the status, the copied address)
const FIT = 220; // ms: the message box grows with the text
const COPIED_FOR = 1800; // ms the "Copied." line stays (a reading time, not motion)
const ROLL = 'cubic-bezier(0.65, 0, 0.35, 1)';
const ARRIVE = 'cubic-bezier(0.22, 1, 0.36, 1)';
const DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
const DAY = 864e5;
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// The site's pace, --kz-motion-scale (set on :root, read on the instance): every duration of the
// script times it, and 0 moves nothing, like reduced motion
const paceOf = (el) => {
const pace = parseFloat(getComputedStyle(el).getPropertyValue('--kz-motion-scale'));
return pace >= 0 ? pace : 1;
};
const fill = (text, values) => text.replace(/\{(\w+)\}/g, (all, key) => (key in values ? values[key] : all));
const clock = (text) => {
const [h, m] = text.split(':').map(Number);
return h * 60 + m;
};
const hm = (min) => `${Math.floor(min / 60) % 24}:${String(min % 60).padStart(2, '0')}`;
// "mon closed; tue-fri 10:00-14:00, 16:00-19:00" → the opening ranges of each weekday, in
// minutes (a range past midnight ends on the next day)
const hoursOf = (text) => {
const week = DAYS.map(() => []);
(text || '').toLowerCase().split(';').forEach((part) => {
const match = part.trim().match(/^([a-z]{3})(?:\s*-\s*([a-z]{3}))?\b(.*)$/);
if (!match) return;
const a = DAYS.indexOf(match[1]);
const b = match[2] ? DAYS.indexOf(match[2]) : a;
if (a < 0 || b < 0) return;
const ranges = [...match[3].matchAll(/(\d{1,2}:\d{2})\s*-\s*(\d{1,2}:\d{2})/g)].map(([, from, to]) => {
const start = clock(from);
const end = clock(to);
return [start, end > start ? end : end + 1440];
});
for (let i = a; ; i = (i + 1) % 7) {
week[i] = ranges;
if (i === b) break;
}
});
return week;
};
// The date and the minute of the day at a moment, in a time zone (the visitor's without one)
const partsAt = (date, zone) => {
const parts = {};
new Intl.DateTimeFormat('en-CA', {
timeZone: zone || undefined, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
}).formatToParts(date).forEach(({ type, value }) => { parts[type] = value; });
return { day: `${parts.year}-${parts.month}-${parts.day}`, min: (Number(parts.hour) % 24) * 60 + Number(parts.minute) };
};
const writeText = async (text) => {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// An older browser, or a frame without clipboard rights: the old way
const area = document.createElement('textarea');
area.value = text;
area.setAttribute('readonly', '');
area.style.cssText = 'position:fixed;top:0;left:0;opacity:0;pointer-events:none';
document.body.append(area);
area.select();
let done = false;
try {
done = document.execCommand('copy');
} catch {
done = false;
}
area.remove();
return done;
}
};
// 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) {
const pace = paceOf(root);
const still = reduceMotion || !pace; // the still path: no motion asked, by the visitor or the site
const data = root.dataset;
const q = (name) => root.querySelector(`.kodza-contact-split__${name}`);
const make = (tag, name, text) => {
const el = document.createElement(tag);
el.className = `kodza-contact-split__${name}`;
if (text != null) el.textContent = text;
return el;
};
const timers = new Set();
const later = (fn, ms) => {
const id = setTimeout(() => {
timers.delete(id);
fn();
}, ms);
timers.add(id);
return id;
};
signal.addEventListener('abort', () => timers.forEach(clearTimeout));
// A line that rolls: the old words go up, the new ones come from below, the width follows
const roll = (box, text, name) => {
const old = box.lastElementChild;
if (old?.textContent === text) return;
box.getAnimations({ subtree: true }).forEach((animation) => animation.finish());
[...box.children].slice(0, -1).forEach((el) => el.remove());
const line = make('span', name, text);
if (still || !old || !box.offsetParent) {
box.replaceChildren(line);
return;
}
const from = box.offsetWidth;
box.append(line);
old.setAttribute('aria-hidden', 'true');
const to = line.offsetWidth;
box.classList.add('is-rolling');
const timing = { duration: LINE * pace, easing: ARRIVE };
box.animate([{ width: `${from}px` }, { width: `${to}px` }], timing);
old.animate([{ transform: 'translateY(0)' }, { transform: 'translateY(-110%)' }], { ...timing, fill: 'forwards' });
line.animate([{ transform: 'translateY(110%)' }, { transform: 'translateY(0)' }], timing).onfinish = () => {
old.remove();
// A newer roll may have started meanwhile: it keeps the clip
if (box.lastElementChild === line) box.classList.remove('is-rolling');
};
};
// The status: open or not right now, in the place's timezone
const status = q('status');
const state = q('state');
const week = q('week');
const venue = root.classList.contains('kodza-contact-split--venue');
const hours = data.hours ? hoursOf(data.hours) : null;
const zone = data.timezone || '';
const locale = data.locale || LOCALE;
const dayName = (i, style) => {
try {
return new Intl.DateTimeFormat(locale, { weekday: style, timeZone: 'UTC' }).format(Date.UTC(2024, 0, 1 + i)); // 1 Jan 2024 is a Monday
} catch {
return DAYS[i];
}
};
// data-now starts the clock at a set moment (a demo), and it runs from there
const born = Date.now();
const fixed = (data.now || '').match(/^(\d{4}-\d{2}-\d{2})T(\d{1,2}:\d{2})/);
const nowAt = () => {
if (fixed) return { day: fixed[1], min: (clock(fixed[2]) + Math.floor((Date.now() - born) / 6e4)) % 1440 };
try {
return partsAt(new Date(), zone);
} catch {
return partsAt(new Date());
}
};
const readStatus = (now) => {
const today = (new Date(Date.parse(`${now.day}T00:00:00Z`)).getUTCDay() + 6) % 7;
// Open now: a range of today, or one of yesterday that runs past midnight
const yesterday = (today + 6) % 7;
const open = hours[today].find(([start, end]) => start <= now.min && now.min < end)
|| hours[yesterday].map(([start, end]) => [start - 1440, end - 1440]).find(([, end]) => now.min < end);
if (open) return { today, open: true, until: open[1] };
for (let ahead = 0; ahead < 8; ahead += 1) {
const i = (today + ahead) % 7;
const next = hours[i].find(([start]) => ahead > 0 || start > now.min);
if (next) return { today, open: false, ahead, day: ahead === 1 ? WORDS.tomorrow : dayName(i, 'short'), time: hm(next[0]) };
}
return { today, open: false, ahead: -1 };
};
const writeWeek = (today) => {
if (!week) return;
week.replaceChildren(...DAYS.map((key, i) => {
const row = make('li', 'day');
const times = hours[i].map(([start, end]) => `${hm(start)} – ${hm(end % 1440)}`).join(' · ');
row.append(document.createElement('span'), document.createElement('span'));
row.firstChild.textContent = dayName(i, 'long');
row.lastChild.textContent = times || WORDS.closed;
row.classList.toggle('is-today', i === today);
row.classList.toggle('is-closed', !times);
return row;
}));
};
// The time where you are, written from data-text on .kodza-contact-split__time ("{time} in Bordeaux")
const time = q('time');
let shownDay = -1;
const update = () => {
const at = nowAt();
const now = readStatus(at);
root.classList.toggle('is-open', now.open);
let text;
if (now.open) text = venue ? fill(WORDS.open, { time: hm(now.until % 1440) }) : fill(data.reply ? WORDS.available : WORDS.availableNow, { reply: data.reply || '' });
else if (now.ahead < 0) text = WORDS.closed;
else if (now.ahead === 0) text = fill(venue ? WORDS.opensToday : WORDS.backToday, now);
else text = fill(venue ? WORDS.opens : WORDS.back, now);
roll(state, text, 'line');
if (time) roll(time, fill(time.dataset.text || '{time}', { time: hm(at.min) }), 'at');
if (now.today !== shownDay) {
shownDay = now.today;
writeWeek(now.today);
}
};
if (hours && status && state) {
status.hidden = false;
update();
// A look at the clock as each minute turns (zones are whole minutes apart)
const next = () => later(() => {
update();
next();
}, 6e4 - ((Date.now() - (fixed ? born : 0)) % 6e4) + 20);
next();
}
// The email copies in one click: the address rolls into "Copied." and back
const copy = q('copy');
const said = copy?.querySelector('.kodza-contact-split__roll');
const heard = copy?.nextElementSibling;
const address = copy?.dataset.copy || '';
let back = 0;
copy?.addEventListener('click', async () => {
const done = await writeText(address);
if (signal.aborted || !said) return;
clearTimeout(back);
roll(said, done ? WORDS.copied : WORDS.cantCopy, 'said');
if (heard) heard.textContent = done ? WORDS.copied : WORDS.cantCopy;
back = later(() => {
roll(said, address, 'said');
if (heard) heard.textContent = '';
}, COPIED_FOR);
}, { signal });
// The form
const form = q('form');
if (!form) return;
const stage = q('stage');
const panel = q('panel');
const done = q('done');
const thanks = q('thanks');
const button = q('send');
const error = q('error');
const nameInput = form.elements.name;
const emailInput = form.elements.email;
const area = form.elements.message;
const trap = form.elements.website;
const box = area?.closest('.kodza-contact-split__box');
const count = q('count');
const thanksText = thanks?.textContent || '';
// The message box grows with the text, up to its max-height, then scrolls
const fit = (animate) => {
if (!area) return;
const from = area.offsetHeight;
area.style.height = 'auto';
const full = area.scrollHeight;
area.style.height = `${full}px`;
const to = area.offsetHeight;
area.classList.toggle('is-long', full > to);
if (animate && !still && from && from !== to) area.animate([{ height: `${from}px` }, { height: `${to}px` }], { duration: FIT * pace, easing: ARRIVE });
};
const counted = () => {
const max = area?.maxLength;
if (!box || !count || !(max > 0)) return;
const used = area.value.length;
box.classList.toggle('is-near', used >= max * 0.8);
count.textContent = fill(WORDS.left, { left: max - used });
};
area?.addEventListener('input', () => {
fit(true);
counted();
}, { signal });
// Mod+Enter sends from the message, where Enter makes a new line
area?.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' || !(event.metaKey || event.ctrlKey)) return;
event.preventDefault();
form.requestSubmit(button);
}, { signal });
// Checked before the send: the error under the field at fault, the focus on it
let errorField = null;
const clean = () => {
errorField?.removeAttribute('aria-invalid');
errorField = null;
form.classList.remove('is-error');
};
const fail = (field, message, focus = field) => {
clean();
error.textContent = message;
form.classList.add('is-error');
errorField = field;
if (field.matches('input, textarea')) field.setAttribute('aria-invalid', 'true');
focus?.focus();
};
const invalid = () => {
if (nameInput && !nameInput.value.trim()) return fail(nameInput, WORDS.name) || true;
if (emailInput && (!emailInput.value.trim() || !emailInput.validity.valid)) return fail(emailInput, WORDS.email) || true;
if (area && !area.value.trim()) return fail(area, WORDS.message) || true;
return false;
};
form.noValidate = true;
form.addEventListener('input', () => {
if (!errorField) return;
const fixed = errorField === emailInput ? emailInput.value.trim() && emailInput.validity.valid : errorField.value?.trim();
if (fixed) clean();
}, { signal });
// Two blocks in one place: the one leaving rolls up (down on the way back), the other follows,
// and the box between them changes height with them
const swap = (cell, out, into, backwards) => {
cell.getAnimations({ subtree: true }).forEach((animation) => animation.cancel());
out.hidden = false;
into.hidden = false;
if (still) {
out.hidden = true;
return;
}
const hOut = out.offsetHeight;
const hIn = into.offsetHeight;
cell.classList.add('is-rolling');
const timing = { duration: STAGE * pace, easing: ROLL };
const travel = backwards ? [hIn, -hIn] : [-hOut, hOut];
const grow = cell.animate(
[{ height: `${Math.max(hOut, 1)}px` }, { height: `${Math.max(hIn, 1)}px` }],
{ ...timing, fill: 'forwards' },
);
// The tail of the block leaving fades, so no edge of it lingers over the one coming in
out.animate([
{ transform: 'translateY(0)', opacity: 1 },
{ opacity: 1, offset: 0.6 },
{ transform: `translateY(${travel[0]}px)`, opacity: 0 },
], { ...timing, fill: 'forwards' });
into.animate([{ transform: `translateY(${travel[1]}px)` }, { transform: 'translateY(0)' }], {
...timing, delay: LAG_IN * pace, fill: 'backwards',
}).onfinish = () => {
out.hidden = true;
cell.classList.remove('is-rolling');
out.getAnimations().forEach((animation) => animation.cancel());
grow.cancel();
};
};
let sent = false;
const finish = (values) => {
sent = true;
const name = String(values.get('name') || '').trim().split(/\s+/)[0];
const email = String(values.get('email') || '').trim();
if (thanks) thanks.textContent = name && thanks.dataset.text ? fill(thanks.dataset.text, { name }) : thanksText;
done.querySelectorAll('[data-value]').forEach((el) => {
if (el.dataset.value === 'email' && email) el.textContent = email;
if (el.dataset.value === 'name' && name) el.textContent = name;
});
swap(stage, panel, done);
if (thanks) {
thanks.tabIndex = -1;
thanks.focus({ preventScroll: true });
}
};
// The send: kodza:submit first (the page can take it over), then the form's action, the usual
// way, or with fetch under data-send="fetch"
let sending = false;
const send = async (values) => {
sending = true;
button.setAttribute('aria-busy', 'true');
const method = (form.getAttribute('method') || 'get').toUpperCase();
const url = method === 'GET' ? `${form.action}${form.action.includes('?') ? '&' : '?'}${new URLSearchParams(values)}` : form.action;
try {
const response = await fetch(url, { method, body: method === 'GET' ? undefined : values, headers: { Accept: 'application/json' }, signal });
if (!response.ok) throw new Error(String(response.status));
sending = false;
finish(values);
} catch {
if (signal.aborted) return;
sending = false;
// What was typed stays; the address is the way out
fail(button, form.dataset.failed || (address ? fill(WORDS.failed, { email: address }) : WORDS.failedPlain), null);
}
button.removeAttribute('aria-busy');
};
form.addEventListener('submit', (event) => {
if (sent || sending || invalid()) {
event.preventDefault();
return;
}
clean();
const values = new FormData(form);
// A robot filled the hidden field: it gets its thanks, nothing leaves
if (trap?.value) {
event.preventDefault();
finish(values);
return;
}
values.delete('website');
if (!form.dispatchEvent(new CustomEvent('kodza:submit', { detail: { data: values }, bubbles: true, cancelable: true }))) {
event.preventDefault();
finish(values);
return;
}
if (form.dataset.send !== 'fetch') return; // sent the usual way
event.preventDefault();
send(values);
}, { signal });
// Write another (the form's reset): the empty form comes back down
form.addEventListener('reset', () => requestAnimationFrame(() => {
if (signal.aborted || !sent) return;
const inside = done.contains(document.activeElement);
sent = false;
clean();
if (area) {
area.style.height = '';
area.classList.remove('is-long');
}
box?.classList.remove('is-near');
swap(stage, done, panel, true);
if (inside) nameInput?.focus({ preventScroll: true });
}), { signal });
}
// Kodza lifecycle, the same in every component: `kodza:init` starts the instances inside
// event.detail.root (the whole page by default), `kodza:destroy` stops them and puts their
// markup back. For themes and apps that swap content without a page load. Each running root
// keeps its markup as written under KODZA, shared by every Kodza script on the page: a Kodza
// component inside another one is saved as written, not half-started, and stopped with it. Two
// components on one root (a reveal on a stat card) both start, and stop together.
const KODZA = Symbol.for('kodza');
const RUNNING = Symbol.for(`kodza ${SELECTOR}`); // a root this component runs on, for every copy of this script
const instances = new WeakMap();
const find = (scope = document) => [scope, ...scope.querySelectorAll(SELECTOR)].filter((el) => el.matches?.(SELECTOR));
const attrsOf = (el) => [...el.attributes].map((a) => [a.name, a.value]);
const put = (el, saved) => {
[...el.attributes].forEach((a) => el.removeAttribute(a.name));
saved.attrs.forEach(([name, value]) => el.setAttribute(name, value));
el.innerHTML = saved.html;
};
// The markup inside a root as written: Kodza components already running in it give back their own
const written = (root) => {
const all = [...root.querySelectorAll('*')];
if (!all.some((el) => el[KODZA])) return root.innerHTML;
const copy = root.cloneNode(true);
const twins = copy.querySelectorAll('*');
let last = null;
all.forEach((el, i) => {
if (!el[KODZA] || last?.contains(el)) return;
last = el;
put(twins[i], el[KODZA]);
});
return copy.innerHTML;
};
const init = (scope) => find(scope).forEach((root) => {
// Already running, maybe from another copy of this script. Marked is-ready with no Kodza component
// running on it, a root is a copy of a started one (a cloned slide): it is left as it is
if (root[RUNNING] || (root.classList.contains('is-ready') && !root[KODZA])) return;
// Rendered by React (data-kodza-manual): React starts it, with an init aimed at that markup once
// it has hydrated it. Any earlier start would change markup React has not taken over yet
const owner = root.closest('[data-kodza-manual]');
if (owner && !(scope && owner.contains(scope))) return;
const controller = new AbortController();
// Another Kodza component already running on this root: its markup as written is kept, and the
// two stop together
const before = root[KODZA];
const saved = { html: before ? before.html : written(root), attrs: before ? before.attrs : attrsOf(root), stop: () => destroy(root) };
if (before) before.above = saved;
instances.set(root, { controller, saved, before });
root[KODZA] = saved;
root[RUNNING] = true;
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.saved.above?.stop(); // a Kodza component started after it on the same root stops first
// The Kodza components running inside it stop first: once its markup is put back, their own
// script could not find them any more
root.querySelectorAll('*').forEach((el) => el[KODZA]?.stop());
instance.controller.abort();
delete root[RUNNING];
// Started on top of another one: that one stops next, and puts the markup back
if (instance.before) {
delete instance.before.above;
root[KODZA] = instance.before;
return instance.before.stop();
}
delete root[KODZA];
put(root, instance.saved);
});
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 — Contact Split, for React. From https://kodza.dev/components/contact-split/
// 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: contact-split.css and contact-split.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.
// data-kodza-manual keeps the script from starting it on its own: the effect below does, once
// React has hydrated the markup (on a page that streams in, the script can load first).
// Built from your data (tabs, rows, options)? Put what the markup is made of in the key, as plain
// values (ids, text, numbers): a change starts the script again on fresh markup. What the visitor
// changes (the open tab, the sort, what they type) is the script's: keep it out of the key, follow
// it with onClick or onInput on the root, and change it from your code as a visitor would (click
// the tab; set the field's value, then dispatch a bubbling input event).
import { useRef, useEffect, type ComponentPropsWithoutRef, type ComponentRef, type CSSProperties } from 'react';
import './contact-split.css';
type Variant = "venue";
export type ContactSplitProps = Omit<ComponentPropsWithoutRef<'section'>, "children" | "variant" | "vars" | "timezone" | "hours" | "reply" | "onSubmit"> & {
/** Modifier class(es): .kodza-contact-split--venue */
variant?: Variant | Variant[];
/** data-timezone on the root (default: Europe/Paris) */
timezone?: "Europe/Paris";
/** data-hours on the root (default: mon-fri 09:00-18:00) */
hours?: "mon-fri 09:00-18:00";
/** data-reply on the root (default: 2 h) */
reply?: "2 h";
/** Settings, as CSS variables on the root */
vars?: {
/** widest the section gets — default: 1200px */
"--kz-contact-split-width"?: string | number;
/** between the two columns — default: 64px */
"--kz-contact-split-gap"?: string | number;
/** inside the fields — default: var(--kz-color-paper, #ffffff) */
"--kz-contact-split-field"?: string | number;
/** default: var(--kz-color-ink, #0a0a0a) */
"--kz-contact-split-fg"?: string | number;
/** default: color-mix(in srgb, var(--kz-contact-split-fg) 62%, transparent) */
"--kz-contact-split-muted"?: string | number;
/** default: color-mix(in srgb, var(--kz-contact-split-fg) 10%, transparent) */
"--kz-contact-split-line"?: string | number;
/** today's row in the week — default: color-mix(in srgb, var(--kz-contact-split-fg) 5%, transparent) */
"--kz-contact-split-soft"?: string | number;
/** around a focused field — default: color-mix(in srgb, var(--kz-contact-split-fg) 6%, transparent) */
"--kz-contact-split-halo"?: string | number;
/** the send button, focus rings — default: var(--kz-color-accent, var(--kz-contact-split-fg)) */
"--kz-contact-split-ink"?: string | number;
/** default: var(--kz-color-on-accent, var(--kz-color-paper, #ffffff)) */
"--kz-contact-split-on-ink"?: string | number;
};
/** "kodza:submit", fired by the script (bubbles from the root) */
onSubmit?: (event: CustomEvent) => void;
className?: string;
};
export function ContactSplit({ variant, timezone = "Europe/Paris", hours = "mon-fri 09:00-18:00", reply = "2 h", vars, onSubmit, className, style, ...rest }: ContactSplitProps) {
const ref = useRef<ComponentRef<'section'>>(null);
const classes = ["kodza-contact-split", ...([] as string[]).concat(variant ?? []).map((v) => `kodza-contact-split--${v}`), className].filter(Boolean).join(' ');
// Everything the markup is made of: when it changes, React gets a fresh element (see the top)
const key = JSON.stringify([classes, timezone, hours, reply, 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('./contact-split.js').then(() => {
if (live) document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root } }));
});
return () => {
live = false;
document.dispatchEvent(new CustomEvent('kodza:destroy', { detail: { root } }));
};
}, [key]);
useEffect(() => {
const root = ref.current;
if (!root || !onSubmit) return;
const listener = (event: Event) => onSubmit(event as CustomEvent);
root.addEventListener("kodza:submit", listener);
return () => root.removeEventListener("kodza:submit", listener);
}, [onSubmit, key]);
return (
<section key={key} {...rest} className={classes} data-kodza-manual data-timezone={timezone} data-hours={hours} data-reply={reply} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<div className="kodza-contact-split__bar">
<p className="kodza-contact-split__eyebrow">Contact</p>
<p className="kodza-contact-split__status" hidden>
<span className="kodza-contact-split__dot" aria-hidden="true" />
<span className="kodza-contact-split__state" />
<span className="kodza-contact-split__time" data-text={"{time} in Bordeaux"} />
</p>
</div>
<div className="kodza-contact-split__head">
<h2 className="kodza-contact-split__title">Tell us what you’re building.</h2>
<p className="kodza-contact-split__lead">No ticket number, no sales sequence. A person reads it and writes back.</p>
</div>
<div className="kodza-contact-split__inner">
<div className="kodza-contact-split__ways">
<div className="kodza-contact-split__quick">
<a className="kodza-contact-split__big kodza-contact-split__big--call" href="tel:+33556000000">Call</a>
{' '}
<a className="kodza-contact-split__big" href="mailto:hello@halyard.example">Email</a>
</div>
<dl className="kodza-contact-split__list">
<div className="kodza-contact-split__item kodza-contact-split__item--email">
<dt className="kodza-contact-split__key">Write to</dt>
<dd>
<button className="kodza-contact-split__copy" type="button" data-copy="hello@halyard.example">
<span className="kodza-contact-split__sr">Copy the address </span>
{' '}
<span className="kodza-contact-split__roll">
<span className="kodza-contact-split__said">hello@halyard.example</span>
</span>
{' '}
<svg className="kodza-contact-split__glyph" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">
<rect x="5.5" y="5.5" width="8" height="8" rx="1.5" />
<path d="M10.5 3.5v-.5A1.5 1.5 0 0 0 9 1.5H4A1.5 1.5 0 0 0 2.5 3v5A1.5 1.5 0 0 0 4 9.5h.5" />
</svg>
</button>
{' '}
<span className="kodza-contact-split__sr" role="status" />
</dd>
</div>
<div className="kodza-contact-split__item kodza-contact-split__item--phone">
<dt className="kodza-contact-split__key">Phone</dt>
<dd>
<a className="kodza-contact-split__link" href="tel:+33556000000">+33 5 56 00 00 00</a>
</dd>
</div>
<div className="kodza-contact-split__item">
<dt className="kodza-contact-split__key">Studio</dt>
<dd>
<address className="kodza-contact-split__address">12 quai des Chartrons<br />33000 Bordeaux</address>
</dd>
</div>
</dl>
</div>
<form className="kodza-contact-split__form" action="#" method="post">
<div className="kodza-contact-split__stage">
<div className="kodza-contact-split__panel">
<p className="kodza-contact-split__key">Or right here</p>
<div className="kodza-contact-split__fields">
<label className="kodza-contact-split__box">
<input className="kodza-contact-split__input" type="text" name="name" autoComplete="name" placeholder=" " required />
{' '}
<span className="kodza-contact-split__label">Your name</span>
</label>
{' '}
<label className="kodza-contact-split__box">
<input className="kodza-contact-split__input" type="email" name="email" autoComplete="email" placeholder=" " required />
{' '}
<span className="kodza-contact-split__label">Email</span>
</label>
{' '}
<label className="kodza-contact-split__box kodza-contact-split__box--area">
<textarea className="kodza-contact-split__input kodza-contact-split__area" name="message" rows={3} maxLength={1000} placeholder=" " required />
{' '}
<span className="kodza-contact-split__label">What are you building?</span>
{' '}
<span className="kodza-contact-split__count" aria-hidden="true" />
</label>
{' '}
<label className="kodza-contact-split__trap" aria-hidden="true">Leave this empty <input type="text" name="website" tabIndex={-1} autoComplete="off" /></label>
</div>
<div className="kodza-contact-split__foot">
<p className="kodza-contact-split__note" aria-live="polite">
<span className="kodza-contact-split__help">Two lines is plenty. We ask the rest.</span>
{' '}
<span className="kodza-contact-split__error" />
</p>
<button className="kodza-contact-split__send" type="submit">
<span className="kodza-contact-split__word">Send message</span>
<span className="kodza-contact-split__spinner" aria-hidden="true" />
</button>
</div>
<p className="kodza-contact-split__privacy">Your details are used to answer you. Nothing else. <a href="/privacy">Privacy</a></p>
</div>
<div className="kodza-contact-split__done" hidden>
<p className="kodza-contact-split__key">Sent</p>
<p className="kodza-contact-split__thanks" data-text={"Thanks, {name}."}>Thanks.</p>
<p className="kodza-contact-split__next">We write back to <span data-value="email">your address</span>. From a person, not a sequence.</p>
<button className="kodza-contact-split__again" type="reset">Write another</button>
</div>
</div>
</form>
</div>
</section>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.