Choice Cards
Cards to compare and pick, on real radio buttons or checkboxes, with a title, a line and an optional price. The picked card's ink border draws itself around it from the corner while a check draws in its mark. Pure CSS.
View codeAn email field and its button in one pill: the address is checked in place, the button rolls to a turning ring, then the thanks rises where the pill was. Posts to the form action or to your code.
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 Subscribe Field to my page.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<!-- action: your list's form URL (Mailchimp, Brevo, a WordPress plugin), or cancel "kodza:subscribe" and send it yourself -->
<form class="kodza-subscribe" action="https://example.com/subscribe" method="post">
<div class="kodza-subscribe__stage">
<div class="kodza-subscribe__pill">
<label class="kodza-subscribe__box">
<input class="kodza-subscribe__input" type="email" name="email" placeholder=" " autocomplete="email" required data-error="Enter a real email address.">
<span class="kodza-subscribe__label">Email</span>
</label>
<button class="kodza-subscribe__button" type="submit">
<span class="kodza-subscribe__word">Subscribe</span>
<span class="kodza-subscribe__spinner" aria-hidden="true"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5" /><circle cx="12" cy="12" r="8.5" pathLength="100" /></svg></span>
</button>
</div>
<p class="kodza-subscribe__done" role="status" tabindex="-1">
<span class="kodza-subscribe__tick" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M7.5 12.5l3 3 6-6.5" pathLength="1" /></svg></span>
<span class="kodza-subscribe__thanks">You're in. Check your inbox.</span>
</p>
</div>
<p class="kodza-subscribe__note">
<span class="kodza-subscribe__hint">One email when something ships. Unsubscribe in one click.</span>
<span class="kodza-subscribe__error" aria-live="polite"></span>
</p>
</form>
<form class="kodza-subscribe kodza-subscribe--icon" action="https://example.com/subscribe" method="post">
<div class="kodza-subscribe__stage">
<div class="kodza-subscribe__pill">
<label class="kodza-subscribe__box">
<input class="kodza-subscribe__input" type="email" name="email" placeholder=" " autocomplete="email" required data-error="Enter a real email address.">
<span class="kodza-subscribe__label">Email for the launch</span>
</label>
<button class="kodza-subscribe__button" type="submit" aria-label="Subscribe">
<span class="kodza-subscribe__word" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M5 12h14M13 6l6 6-6 6" /></svg></span>
<span class="kodza-subscribe__spinner" aria-hidden="true"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5" /><circle cx="12" cy="12" r="8.5" pathLength="100" /></svg></span>
</button>
</div>
<p class="kodza-subscribe__done" role="status" tabindex="-1">
<span class="kodza-subscribe__tick" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M7.5 12.5l3 3 6-6.5" pathLength="1" /></svg></span>
<span class="kodza-subscribe__thanks">Saved. We write once, on launch day.</span>
</p>
</div>
<p class="kodza-subscribe__note">
<span class="kodza-subscribe__hint">For the launch. Then we leave you alone.</span>
<span class="kodza-subscribe__error" aria-live="polite"></span>
</p>
</form>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Subscribe Field
An email field and its button in one pill. Sent, the button's word rolls away for a turning
ring (the width never moves), then the pill rises out and the thanks comes up in its place,
its tick drawing in. A wrong address swaps the hint for the error, like every Kodza field.
The form posts to its own action (Mailchimp, Brevo, a WordPress plugin), or your code sends
it: see component.js. The --icon variant trades the word for an arrow.
Everything is tuned with the --kz-subscribe-* variables. Its width follows --kz-field-width,
shared by every Kodza form field. */
.kodza-subscribe {
--kz-subscribe-bg: #ffffff;
--kz-subscribe-fg: #0a0a0a;
--kz-subscribe-muted: rgba(10, 10, 10, 0.6);
--kz-subscribe-line: rgba(10, 10, 10, 0.14);
--kz-subscribe-line-hover: rgba(10, 10, 10, 0.3);
--kz-subscribe-focus: #0a0a0a;
--kz-subscribe-halo: rgba(10, 10, 10, 0.04);
--kz-subscribe-error: #0a0a0a; /* monochrome by default: an icon and a message, never colour alone */
--kz-subscribe-ink: #0a0a0a; /* the button and the tick */
--kz-subscribe-ink-hover: #2a2a2a;
--kz-subscribe-on-ink: #ffffff;
--kz-subscribe-width: var(--kz-field-width, 440px);
--kz-subscribe-height: 56px;
--kz-subscribe-button: 44px; /* the button's height, inside the pill */
--kz-subscribe-pad: 22px; /* from the pill's edge to the text */
--kz-subscribe-ease: cubic-bezier(0.22, 1, 0.36, 1);
--kz-subscribe-swap: cubic-bezier(0.65, 0, 0.35, 1); /* the pill leaving, the thanks arriving */
display: grid;
gap: 8px;
width: 100%;
max-width: var(--kz-subscribe-width);
margin: 0;
color: var(--kz-subscribe-fg);
}
/* The pill and the thanks share one cell: the height never jumps. The clip lets the focus
halo show, and hides what rolls past it: the thanks comes up from behind its bottom edge */
.kodza-subscribe__stage {
display: grid;
clip-path: inset(-8px -8px -4px);
}
.kodza-subscribe__pill,
.kodza-subscribe__done {
grid-area: 1 / 1;
transition:
transform calc(0.6s * var(--kz-motion-scale, 1)) var(--kz-subscribe-swap),
opacity calc(0.45s * var(--kz-motion-scale, 1)) ease,
visibility 0s linear 0s;
}
.kodza-subscribe__pill {
position: relative;
display: flex;
align-items: center;
height: var(--kz-subscribe-height);
border-radius: var(--kz-radius-pill, 999px);
background: var(--kz-subscribe-bg);
box-shadow: inset 0 0 0 1px var(--kz-subscribe-line);
transition:
transform calc(0.6s * var(--kz-motion-scale, 1)) var(--kz-subscribe-swap),
opacity calc(0.4s * var(--kz-motion-scale, 1)) cubic-bezier(0.4, 0, 1, 1),
visibility 0s linear 0s,
box-shadow calc(0.25s * var(--kz-motion-scale, 1)) ease;
}
@media (hover: hover) {
.kodza-subscribe__pill:hover {
box-shadow: inset 0 0 0 1px var(--kz-subscribe-line-hover);
}
}
.kodza-subscribe__pill:has(.kodza-subscribe__input:focus) {
box-shadow: inset 0 0 0 1.5px var(--kz-subscribe-focus), 0 0 0 4px var(--kz-subscribe-halo);
}
/* The box: the whole left part is the label, so a click anywhere focuses the input */
.kodza-subscribe__box {
position: relative;
display: flex;
flex: 1;
align-items: flex-end;
box-sizing: border-box;
min-width: 0;
height: 100%;
margin: 0;
padding: 0 12px 9px var(--kz-subscribe-pad);
cursor: text;
}
.kodza-subscribe__input {
flex: 1;
min-width: 0;
height: 22px;
margin: 0;
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
font-size: 15px;
line-height: 22px;
outline: none;
}
/* The label: inside the pill at rest, folded up above the text once focused or filled */
.kodza-subscribe__label {
position: absolute;
top: 50%;
left: var(--kz-subscribe-pad);
max-width: calc(100% - var(--kz-subscribe-pad) - 12px);
overflow: hidden;
color: var(--kz-subscribe-muted);
font-size: 15px;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
pointer-events: none;
transform: translateY(-50%);
transform-origin: left top;
transition: transform calc(0.35s * var(--kz-motion-scale, 1)) var(--kz-subscribe-ease);
}
.kodza-subscribe__box:focus-within .kodza-subscribe__label,
.kodza-subscribe__input:not(:placeholder-shown) ~ .kodza-subscribe__label {
transform: translateY(-18px) scale(0.8);
}
/* The button: its word and its ring share one cell, so its width never moves */
.kodza-subscribe__button {
display: inline-grid;
flex: none;
place-items: center;
box-sizing: border-box;
height: var(--kz-subscribe-button);
margin: 0 calc((var(--kz-subscribe-height) - var(--kz-subscribe-button)) / 2) 0 0;
padding: 0 20px;
overflow: hidden;
border: 0;
border-radius: var(--kz-radius-pill, 999px);
background: var(--kz-subscribe-ink);
color: var(--kz-subscribe-on-ink);
font: inherit;
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 14px);
font-weight: var(--kz-font-label-weight, 500);
line-height: 1;
letter-spacing: var(--kz-font-label-tracking, -0.01em);
text-transform: var(--kz-font-label-case, none);
white-space: nowrap;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition:
background-color calc(0.2s * var(--kz-motion-scale, 1)) ease,
transform calc(0.2s * var(--kz-motion-scale, 1)) var(--kz-subscribe-ease);
}
@media (hover: hover) {
.kodza-subscribe__button:hover {
background: var(--kz-subscribe-ink-hover);
}
}
.kodza-subscribe__button:active {
transform: scale(0.97);
}
.kodza-subscribe__button:focus-visible {
outline: 2px solid var(--kz-subscribe-focus);
outline-offset: 2px;
}
.kodza-subscribe__word,
.kodza-subscribe__spinner {
grid-area: 1 / 1;
transition: transform calc(0.5s * var(--kz-motion-scale, 1)) var(--kz-subscribe-ease);
}
.kodza-subscribe__word {
display: block;
}
.kodza-subscribe__spinner {
width: 18px;
height: 18px;
transform: translateY(calc(var(--kz-subscribe-button) * 0.75));
}
.kodza-subscribe svg {
display: block;
width: 100%;
height: 100%;
overflow: visible;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
}
.kodza-subscribe__spinner svg {
stroke-width: 2.2;
}
.kodza-subscribe__spinner circle:first-child {
opacity: 0.25;
}
.kodza-subscribe__spinner circle:nth-child(2) {
stroke-dasharray: 28 100;
transform: rotate(-90deg);
transform-origin: 50% 50%;
}
/* Sending: the word leaves through the top, the ring comes up and turns */
.kodza-subscribe:is([data-state="sending"], [data-state="done"]) .kodza-subscribe__word {
transform: translateY(calc(var(--kz-subscribe-button) * -0.75));
}
.kodza-subscribe:is([data-state="sending"], [data-state="done"]) .kodza-subscribe__spinner {
transform: none;
}
.kodza-subscribe:is([data-state="sending"], [data-state="done"]) .kodza-subscribe__spinner svg {
animation: kodza-subscribe-turn calc(0.9s * var(--kz-motion-scale, 1)) linear infinite;
}
.kodza-subscribe[data-state="sending"] .kodza-subscribe__button {
cursor: progress;
}
@keyframes kodza-subscribe-turn {
to {
transform: rotate(360deg);
}
}
/* The thanks: waits below, comes up as the pill rises out */
.kodza-subscribe__done {
display: flex;
align-items: center;
gap: 12px;
box-sizing: border-box;
min-width: 0;
height: var(--kz-subscribe-height);
margin: 0;
padding: 0 calc((var(--kz-subscribe-height) - 32px) / 2);
font-size: 15px;
font-weight: 500;
line-height: 1.3;
letter-spacing: -0.01em;
text-wrap: pretty;
outline: none;
opacity: 0;
visibility: hidden;
transform: translateY(calc(100% + 12px));
transition-delay: 0s, 0s, calc(0.6s * var(--kz-motion-scale, 1));
}
.kodza-subscribe__done:focus-visible {
outline: 2px solid var(--kz-subscribe-focus);
outline-offset: 2px;
border-radius: var(--kz-radius-pill, 999px);
}
.kodza-subscribe__tick {
display: grid;
flex: none;
place-items: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--kz-subscribe-ink);
color: var(--kz-subscribe-on-ink);
transform: scale(0.6);
transition: transform calc(0.5s * var(--kz-motion-scale, 1)) var(--kz-subscribe-ease);
}
.kodza-subscribe__tick svg {
width: 18px;
height: 18px;
stroke-width: 2.2;
}
.kodza-subscribe__tick path {
stroke-dasharray: 1;
stroke-dashoffset: 1;
transition: stroke-dashoffset calc(0.2s * var(--kz-motion-scale, 1)) ease;
}
.kodza-subscribe[data-state="done"] .kodza-subscribe__pill {
opacity: 0;
visibility: hidden;
transform: translateY(calc(-100% - 12px));
transition-delay: 0s, calc(0.14s * var(--kz-motion-scale, 1)), calc(0.6s * var(--kz-motion-scale, 1)), 0s;
}
.kodza-subscribe[data-state="done"] .kodza-subscribe__done {
opacity: 1;
visibility: visible;
transform: none;
transition-delay: calc(0.06s * var(--kz-motion-scale, 1)), calc(0.22s * var(--kz-motion-scale, 1)), 0s;
}
.kodza-subscribe[data-state="done"] .kodza-subscribe__tick {
transform: none;
transition-delay: calc(0.3s * var(--kz-motion-scale, 1));
}
.kodza-subscribe[data-state="done"] .kodza-subscribe__tick path {
stroke-dashoffset: 0;
transition: stroke-dashoffset calc(0.4s * var(--kz-motion-scale, 1)) var(--kz-subscribe-ease) calc(0.45s * var(--kz-motion-scale, 1));
}
/* The --icon variant: a round button with an arrow */
.kodza-subscribe--icon .kodza-subscribe__button {
width: var(--kz-subscribe-button);
padding: 0;
}
.kodza-subscribe--icon .kodza-subscribe__word {
width: 18px;
height: 18px;
padding: 0;
}
.kodza-subscribe--icon .kodza-subscribe__word svg {
stroke-width: 2;
}
@media (hover: hover) {
.kodza-subscribe--icon:not([data-state]) .kodza-subscribe__button:hover .kodza-subscribe__word {
transform: translateX(2px);
}
}
/* The note: the hint and the error share one line, and swap */
.kodza-subscribe__note {
display: grid;
margin: 0;
padding: 0 var(--kz-subscribe-pad);
overflow: hidden;
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 13px);
font-weight: var(--kz-font-label-weight, inherit);
letter-spacing: var(--kz-font-label-tracking, inherit);
line-height: var(--kz-font-label-leading, 1.4);
text-transform: var(--kz-font-label-case, inherit);
}
.kodza-subscribe__hint,
.kodza-subscribe__error {
grid-area: 1 / 1;
transition:
opacity calc(0.25s * var(--kz-motion-scale, 1)) ease,
transform calc(0.4s * var(--kz-motion-scale, 1)) var(--kz-subscribe-ease);
}
.kodza-subscribe__hint {
color: var(--kz-subscribe-muted);
}
.kodza-subscribe__error {
display: flex;
align-items: center;
gap: 6px;
color: var(--kz-subscribe-error);
font-weight: var(--kz-font-label-weight, 500);
opacity: 0;
transform: translateY(100%);
}
.kodza-subscribe__error::before {
content: "";
flex: none;
width: 14px;
height: 14px;
background: currentColor;
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6.5' fill='none' stroke='black' stroke-width='1.5'/%3E%3Cpath d='M8 4.5v4.2M8 10.9v.1' stroke='black' stroke-width='1.6' stroke-linecap='round'/%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%3Ccircle cx='8' cy='8' r='6.5' fill='none' stroke='black' stroke-width='1.5'/%3E%3Cpath d='M8 4.5v4.2M8 10.9v.1' stroke='black' stroke-width='1.6' stroke-linecap='round'/%3E%3C/svg%3E") center / contain no-repeat;
}
/* A wrong address: a firmer outline, and the error replaces the hint. A failed send shows its
message the same way, without blaming the address */
.kodza-subscribe:has(.kodza-subscribe__input[aria-invalid="true"]) .kodza-subscribe__pill {
box-shadow: inset 0 0 0 1.5px var(--kz-subscribe-error);
}
.kodza-subscribe:is(:has(.kodza-subscribe__input[aria-invalid="true"]), [data-failed]) .kodza-subscribe__hint {
opacity: 0;
transform: translateY(-100%);
}
.kodza-subscribe:is(:has(.kodza-subscribe__input[aria-invalid="true"]), [data-failed]) .kodza-subscribe__error {
opacity: 1;
transform: none;
}
@media (prefers-reduced-motion: reduce) {
.kodza-subscribe:is([data-state="sending"], [data-state="done"]) .kodza-subscribe__spinner svg {
animation: none;
}
.kodza-subscribe__pill,
.kodza-subscribe__done,
.kodza-subscribe__label,
.kodza-subscribe__word,
.kodza-subscribe__spinner,
.kodza-subscribe__tick,
.kodza-subscribe__tick path,
.kodza-subscribe__hint,
.kodza-subscribe__error {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
}
}
/* Theming example — on a dark background:
.dark-section .kodza-subscribe {
--kz-subscribe-bg: #161616;
--kz-subscribe-fg: #f2f2f0;
--kz-subscribe-muted: rgba(242, 242, 240, 0.62);
--kz-subscribe-line: rgba(255, 255, 255, 0.14);
--kz-subscribe-line-hover: rgba(255, 255, 255, 0.3);
--kz-subscribe-focus: #f2f2f0;
--kz-subscribe-halo: rgba(255, 255, 255, 0.06);
--kz-subscribe-error: #f2f2f0;
--kz-subscribe-ink: #f2f2f0;
--kz-subscribe-ink-hover: #d6d6d4;
--kz-subscribe-on-ink: #0a0a0a;
}
*/
/* kodza-guard e3a699ad4b23 — 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-subscribe__button):not(#_) {
outline: revert;
outline-offset: revert;
}
:where(.kodza-subscribe__done):not(#_) {
border-radius: revert;
outline-offset: revert;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-subscribe:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-height: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-subscribe__stage:not(#_),
.kodza-subscribe__pill:not(#_),
.kodza-subscribe__label:not(#_),
.kodza-subscribe__word:not(#_),
.kodza-subscribe__spinner:not(#_),
.kodza-subscribe__tick:not(#_),
.kodza-subscribe__thanks:not(#_),
.kodza-subscribe__hint:not(#_),
.kodza-subscribe__error:not(#_) {
box-sizing: revert;
}
.kodza-subscribe__box:not(#_) {
border: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
float: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-subscribe__input:not(#_) {
border-radius: revert;
text-decoration: revert;
transition: 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;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
white-space: revert;
min-height: revert;
}
.kodza-subscribe__button:not(#_) {
text-decoration: revert;
list-style: revert;
float: revert;
position: revert;
box-shadow: revert;
appearance: revert;
max-width: revert;
max-height: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
min-height: revert;
}
.kodza-subscribe__done:not(#_) {
border: revert;
text-decoration: revert;
list-style: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-style: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-subscribe__note:not(#_) {
border: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-style: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-subscribe__spinner svg:where(:not([class*="kodza-"])):not(#_),
.kodza-subscribe__tick svg:where(:not([class*="kodza-"])):not(#_),
.kodza-subscribe__word svg:where(:not([class*="kodza-"])):not(#_) {
border: revert;
margin: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-subscribe__input:not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-subscribe__input:not(#_):where([hidden]) {
display: none;
}
.kodza-subscribe__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-subscribe:not(#_) {
display: grid;
color: var(--kz-subscribe-fg);
}
.kodza-subscribe:not(#_) {
width: min(100%, var(--kz-subscribe-width));
}
.kodza-subscribe__done:not(#_) {
transition: transform calc(0.6s * var(--kz-motion-scale, 1)) var(--kz-subscribe-swap),
opacity calc(0.45s * var(--kz-motion-scale, 1)) ease,
visibility 0s linear 0s;
}
.kodza-subscribe__box:not(#_) {
position: relative;
display: flex;
box-sizing: border-box;
margin: 0;
padding: 0 12px 9px var(--kz-subscribe-pad);
}
.kodza-subscribe__input:not(#_) {
flex: 1;
min-width: 0;
height: 22px;
margin: 0;
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
font-size: 15px;
line-height: 22px;
outline: none;
}
.kodza-subscribe__button:not(#_) {
display: inline-grid;
flex: none;
place-items: center;
box-sizing: border-box;
height: var(--kz-subscribe-button);
margin: 0 calc((var(--kz-subscribe-height) - var(--kz-subscribe-button)) / 2) 0 0;
padding: 0 20px;
overflow: hidden;
border: 0;
border-radius: var(--kz-radius-pill, 999px);
background: var(--kz-subscribe-ink);
color: var(--kz-subscribe-on-ink);
font: inherit;
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 14px);
font-weight: var(--kz-font-label-weight, 500);
line-height: 1;
letter-spacing: var(--kz-font-label-tracking, -0.01em);
text-transform: var(--kz-font-label-case, none);
white-space: nowrap;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: background-color calc(0.2s * var(--kz-motion-scale, 1)) ease,
transform calc(0.2s * var(--kz-motion-scale, 1)) var(--kz-subscribe-ease);
}
@media (hover: hover) {
.kodza-subscribe__button:hover:not(#_) {
background: var(--kz-subscribe-ink-hover);
}
}
.kodza-subscribe__button:active:not(#_) {
transform: scale(0.97);
}
.kodza-subscribe__button:focus-visible:not(#_) {
outline: 2px solid var(--kz-subscribe-focus);
outline-offset: 2px;
}
.kodza-subscribe svg:not(#_) {
display: block;
}
.kodza-subscribe[data-state="sending"] .kodza-subscribe__button:not(#_) {
cursor: progress;
}
.kodza-subscribe__done:not(#_) {
display: flex;
box-sizing: border-box;
margin: 0;
padding: 0 calc((var(--kz-subscribe-height) - 32px) / 2);
font-size: 15px;
font-weight: 500;
line-height: 1.3;
letter-spacing: -0.01em;
outline: none;
transition-delay: 0s, 0s, calc(0.6s * var(--kz-motion-scale, 1));
}
.kodza-subscribe__done:focus-visible:not(#_) {
outline: 2px solid var(--kz-subscribe-focus);
outline-offset: 2px;
border-radius: var(--kz-radius-pill, 999px);
}
.kodza-subscribe[data-state="done"] .kodza-subscribe__done:not(#_) {
transition-delay: calc(0.06s * var(--kz-motion-scale, 1)), calc(0.22s * var(--kz-motion-scale, 1)), 0s;
}
.kodza-subscribe--icon .kodza-subscribe__button:not(#_) {
width: var(--kz-subscribe-button);
padding: 0;
}
.kodza-subscribe__note:not(#_) {
display: grid;
margin: 0;
padding: 0 var(--kz-subscribe-pad);
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 13px);
font-weight: var(--kz-font-label-weight, inherit);
letter-spacing: var(--kz-font-label-tracking, inherit);
line-height: var(--kz-font-label-leading, 1.4);
text-transform: var(--kz-font-label-case, inherit);
}
@media (prefers-reduced-motion: reduce) {
.kodza-subscribe__done:not(#_) {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
}
}
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — Subscribe Field
// Checks the address before anything leaves, turns the ring while it is sent, then brings the
// thanks up in place of the pill. Sending it is yours, two ways:
// - the form's action and method (Mailchimp, Brevo, a WordPress plugin's endpoint): the script
// posts the fields there. Another site's answer cannot be read, so it counts as sent;
// - or cancel the "kodza:subscribe" event (detail: { data, done, fail }) and send it yourself,
// then call detail.done(), or detail.fail("Your message") to show it in place of the hint.
// root.kodzaSubscribe = { done, fail, reset } does the same from anywhere. The failure message
// defaults to data-fail on the form. The motion is CSS.
(() => {
const SELECTOR = '.kodza-subscribe';
// The ring turns at least this long (ms), so a fast answer never flashes it
const MIN_SPIN = 700;
const FAIL_TEXT = 'Could not send. Try again.';
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;
};
let uid = 0;
// 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 form = root.closest('form');
const input = root.querySelector('.kodza-subscribe__input');
const pill = root.querySelector('.kodza-subscribe__pill');
const done = root.querySelector('.kodza-subscribe__done');
const thanks = root.querySelector('.kodza-subscribe__thanks');
const hint = root.querySelector('.kodza-subscribe__hint');
const error = root.querySelector('.kodza-subscribe__error');
if (!form || !input || !pill || !done || !error) return;
const minSpin = still ? 0 : MIN_SPIN * pace;
const prefix = `kodza-subscribe-${++uid}`;
const described = [];
if (hint) described.push((hint.id = `${prefix}-hint`));
described.push((error.id = `${prefix}-error`));
input.setAttribute('aria-describedby', described.join(' '));
// The thanks is written when it shows: a status that receives text is read out
const message = thanks ? thanks.textContent.trim() : '';
if (thanks) thanks.textContent = '';
// The script says what is wrong, in the field's own line, instead of the browser's bubble
const validated = form.noValidate;
form.noValidate = true;
// Focus is only moved for a visitor on the keyboard: after a click, a ring would show
let fromKeyboard = false;
root.addEventListener('keydown', () => { fromKeyboard = true; }, { signal });
root.addEventListener('pointerdown', () => { fromKeyboard = false; }, { signal });
const check = () => {
const bad = !input.validity.valid;
error.textContent = bad ? input.dataset.error || input.validationMessage : '';
input.setAttribute('aria-invalid', String(bad));
return !bad;
};
const set = (state) => {
if (state) root.dataset.state = state;
else delete root.dataset.state;
input.readOnly = Boolean(state);
if (state === 'sending') root.setAttribute('aria-busy', 'true');
else root.removeAttribute('aria-busy');
pill.inert = state === 'done';
if (thanks) thanks.textContent = state === 'done' ? message : '';
};
let token = 0; // a reset or a new send voids the answer of the one before
let timer = 0;
let started = 0;
// An answer lands once the ring has turned long enough
const later = (id, fn) => {
clearTimeout(timer);
timer = setTimeout(() => id === token && root.dataset.state === 'sending' && fn(), Math.max(0, minSpin - (performance.now() - started)));
};
const finish = () => {
set('done');
if (fromKeyboard) done.focus({ preventScroll: true });
};
const failWith = (text) => {
set();
root.dataset.failed = '';
error.textContent = text || root.dataset.fail || FAIL_TEXT;
};
const reset = () => {
token += 1;
clearTimeout(timer);
delete root.dataset.failed;
input.value = '';
input.removeAttribute('aria-invalid');
error.textContent = '';
set();
};
const send = () => {
const id = ++token;
started = performance.now();
const data = new FormData(form);
set('sending');
const ok = () => later(id, finish);
const ko = (text) => later(id, () => failWith(text));
const mine = root.dispatchEvent(new CustomEvent('kodza:subscribe', { bubbles: true, cancelable: true, detail: { data, done: ok, fail: ko } }));
if (!mine) return; // the page sends it, and calls done or fail
const action = form.getAttribute('action');
if (!action) {
console.warn('Kodza Subscribe Field: the form has no action, and nothing handled kodza:subscribe.');
return ko();
}
const url = new URL(action, location.href);
const method = (form.getAttribute('method') || 'get').toUpperCase();
const fields = new URLSearchParams();
data.forEach((value, name) => typeof value === 'string' && fields.append(name, value));
if (method === 'GET') fields.forEach((value, name) => url.searchParams.append(name, value));
const same = url.origin === location.origin;
fetch(url, {
method,
body: method === 'GET' ? undefined : fields,
mode: same ? 'same-origin' : 'no-cors',
credentials: same ? 'same-origin' : 'omit',
signal,
}).then((response) => (response.type === 'opaque' || response.ok ? ok() : ko()), () => !signal.aborted && ko());
};
form.addEventListener('submit', (event) => {
if (event.submitter && !root.contains(event.submitter)) return; // another button of a larger form
event.preventDefault();
if (root.dataset.state) return;
delete root.dataset.failed;
if (check()) send();
else input.focus();
}, { signal });
// Judged when the visitor leaves a filled field, then live while they fix it
input.addEventListener('blur', () => input.value && !root.dataset.state && check(), { signal });
input.addEventListener('input', () => {
if (root.dataset.failed !== undefined) {
delete root.dataset.failed;
error.textContent = '';
}
if (input.getAttribute('aria-invalid') === 'true') check();
}, { signal });
root.kodzaSubscribe = {
done: () => later(token, finish),
fail: (text) => later(token, () => failWith(text)),
reset,
};
signal.addEventListener('abort', () => {
token += 1;
clearTimeout(timer);
delete root.kodzaSubscribe;
form.noValidate = validated;
});
}
// 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 — Subscribe Field, for React. From https://kodza.dev/components/subscribe-field/
// 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: subscribe-field.css and subscribe-field.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 './subscribe-field.css';
type Variant = "icon";
export type SubscribeFieldProps = Omit<ComponentPropsWithoutRef<'form'>, "children" | "variant" | "vars" | "onSubscribe"> & {
/** Modifier class(es): .kodza-subscribe--icon */
variant?: Variant | Variant[];
/** Settings, as CSS variables on the root */
vars?: {
/** default: #ffffff */
"--kz-subscribe-bg"?: string | number;
/** default: #0a0a0a */
"--kz-subscribe-fg"?: string | number;
/** default: rgba(10, 10, 10, 0.6) */
"--kz-subscribe-muted"?: string | number;
/** default: rgba(10, 10, 10, 0.14) */
"--kz-subscribe-line"?: string | number;
/** default: rgba(10, 10, 10, 0.3) */
"--kz-subscribe-line-hover"?: string | number;
/** default: #0a0a0a */
"--kz-subscribe-focus"?: string | number;
/** default: rgba(10, 10, 10, 0.04) */
"--kz-subscribe-halo"?: string | number;
/** monochrome by default: an icon and a message, never colour alone — default: #0a0a0a */
"--kz-subscribe-error"?: string | number;
/** the button and the tick — default: #0a0a0a */
"--kz-subscribe-ink"?: string | number;
/** default: #2a2a2a */
"--kz-subscribe-ink-hover"?: string | number;
/** default: #ffffff */
"--kz-subscribe-on-ink"?: string | number;
/** default: var(--kz-field-width, 440px) */
"--kz-subscribe-width"?: string | number;
/** default: 56px */
"--kz-subscribe-height"?: string | number;
/** the button's height, inside the pill — default: 44px */
"--kz-subscribe-button"?: string | number;
/** from the pill's edge to the text — default: 22px */
"--kz-subscribe-pad"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-subscribe-ease"?: string | number;
/** the pill leaving, the thanks arriving — default: cubic-bezier(0.65, 0, 0.35, 1) */
"--kz-subscribe-swap"?: string | number;
};
/** "kodza:subscribe", fired by the script (bubbles from the root) */
onSubscribe?: (event: CustomEvent) => void;
className?: string;
};
export function SubscribeField({ variant, vars, onSubscribe, className, style, ...rest }: SubscribeFieldProps) {
const ref = useRef<ComponentRef<'form'>>(null);
const classes = ["kodza-subscribe", ...([] as string[]).concat(variant ?? []).map((v) => `kodza-subscribe--${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, 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('./subscribe-field.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 || !onSubscribe) return;
const listener = (event: Event) => onSubscribe(event as CustomEvent);
root.addEventListener("kodza:subscribe", listener);
return () => root.removeEventListener("kodza:subscribe", listener);
}, [onSubscribe, key]);
return (
<form action="https://example.com/subscribe" method="post" key={key} {...rest} className={classes} data-kodza-manual style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<div className="kodza-subscribe__stage">
<div className="kodza-subscribe__pill">
<label className="kodza-subscribe__box">
<input className="kodza-subscribe__input" type="email" name="email" placeholder=" " autoComplete="email" required data-error="Enter a real email address." />
{' '}
<span className="kodza-subscribe__label">Email</span>
</label>
{' '}
<button className="kodza-subscribe__button" type="submit">
<span className="kodza-subscribe__word">Subscribe</span>
{' '}
<span className="kodza-subscribe__spinner" aria-hidden="true">
<svg viewBox="0 0 24 24">
<circle cx="12" cy="12" r="8.5" />
<circle cx="12" cy="12" r="8.5" pathLength="100" />
</svg>
</span>
</button>
</div>
<p className="kodza-subscribe__done" role="status" tabIndex={-1}>
<span className="kodza-subscribe__tick" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path d="M7.5 12.5l3 3 6-6.5" pathLength="1" />
</svg>
</span>
{' '}
<span className="kodza-subscribe__thanks">You're in. Check your inbox.</span>
</p>
</div>
<p className="kodza-subscribe__note">
<span className="kodza-subscribe__hint">One email when something ships. Unsubscribe in one click.</span>
{' '}
<span className="kodza-subscribe__error" aria-live="polite" />
</p>
</form>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.