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 codeA real number input between − and +. Changed digits roll with the count, a held button speeds up, the edge button dims at the limit. Typed values are put back in range on leaving.
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 Number Stepper to my page.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<div class="kodza-number-stepper">
<label class="kodza-number-stepper__box">
<span class="kodza-number-stepper__label">Guests</span>
<input class="kodza-number-stepper__input" type="number" name="guests" value="2" min="1" max="12" step="1" inputmode="numeric">
</label>
<p class="kodza-number-stepper__note">
<span class="kodza-number-stepper__hint">Up to 12. The sofa folds out.</span>
<span class="kodza-number-stepper__error" aria-live="polite"></span>
</p>
</div>
<div class="kodza-number-stepper kodza-number-stepper--compact">
<label class="kodza-number-stepper__box">
<span class="kodza-number-stepper__label">Quantity</span>
<input class="kodza-number-stepper__input" type="number" name="quantity" value="1" min="1" max="5" step="1" inputmode="numeric">
</label>
<p class="kodza-number-stepper__note">
<span class="kodza-number-stepper__hint">5 left.</span>
<span class="kodza-number-stepper__error" aria-live="polite"></span>
</p>
</div>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Number Stepper
A real number input between − and +. Each digit that changes rolls, up as the count goes up,
down as it goes down; a new place (9 to 10) opens its room as its digit rolls in. Holding a
button speeds up, the button at the edge goes dim at the limit, and typing stays possible:
out of range, the error says why, and on leaving the field the value rolls back into range.
Without JS, the native input stays, styled, with its own arrows. Same box as Text Field.
Everything is tuned with the --kz-number-* variables; the words and timings, at the top of
the script. */
.kodza-number-stepper {
--kz-number-bg: #ffffff;
--kz-number-fg: #0a0a0a;
--kz-number-muted: rgba(10, 10, 10, 0.6);
--kz-number-line: rgba(10, 10, 10, 0.14);
--kz-number-line-hover: rgba(10, 10, 10, 0.3);
--kz-number-soft: rgba(10, 10, 10, 0.06);
--kz-number-button: rgba(10, 10, 10, 0.05); /* the − and + */
--kz-number-button-hover: rgba(10, 10, 10, 0.09);
--kz-number-button-press: rgba(10, 10, 10, 0.14);
--kz-number-error: #0a0a0a; /* a message, never colour alone: set a red here if you want one */
--kz-number-height: 56px;
--kz-number-radius: calc(12px * var(--kz-radius-scale, 1));
--kz-number-button-size: 40px;
--kz-number-button-radius: calc(8px * var(--kz-radius-scale, 1));
--kz-number-value-size: 17px;
--kz-number-width: var(--kz-field-width, 240px);
--kz-number-duration: 0.28s;
--kz-number-ease: cubic-bezier(0.22, 1, 0.36, 1);
position: relative;
display: grid;
gap: 8px;
width: 100%;
max-width: var(--kz-number-width);
color: var(--kz-number-fg);
}
/* The compact one, for a cart line: a pill with round buttons, the label kept for screen readers */
.kodza-number-stepper--compact {
--kz-number-height: 44px;
--kz-number-radius: var(--kz-radius-pill, 999px);
--kz-number-button-size: 36px;
--kz-number-button-radius: var(--kz-radius-pill, 999px);
--kz-number-value-size: 15px;
--kz-number-width: 136px;
}
/* The box: label on top, the number below (and the native arrows, without the script) */
.kodza-number-stepper__box {
position: relative;
display: block;
box-sizing: border-box;
height: var(--kz-number-height);
margin: 0;
padding: 0;
border-radius: var(--kz-number-radius);
background: var(--kz-number-bg);
box-shadow: inset 0 0 0 1px var(--kz-number-line);
cursor: text;
transition: box-shadow calc(0.25s * var(--kz-motion-scale, 1)) ease;
}
.kodza-number-stepper__box:hover,
.kodza-number-stepper:has(.kodza-number-stepper__button:hover:not(:disabled)) .kodza-number-stepper__box {
box-shadow: inset 0 0 0 1px var(--kz-number-line-hover);
}
.kodza-number-stepper__box:focus-within {
box-shadow: inset 0 0 0 1.5px var(--kz-number-fg), 0 0 0 4px var(--kz-number-soft);
}
.kodza-number-stepper.is-invalid .kodza-number-stepper__box {
box-shadow: inset 0 0 0 1.5px var(--kz-number-error);
}
.kodza-number-stepper.is-invalid .kodza-number-stepper__box:focus-within {
box-shadow: inset 0 0 0 1.5px var(--kz-number-error), 0 0 0 4px var(--kz-number-soft);
}
.kodza-number-stepper.is-disabled .kodza-number-stepper__box {
opacity: 0.5;
cursor: not-allowed;
}
.kodza-number-stepper__label {
position: absolute;
top: 10px;
left: 16px;
z-index: 2;
color: var(--kz-number-muted);
font-size: 12px;
line-height: 1;
white-space: nowrap;
pointer-events: none;
}
.kodza-number-stepper__input {
display: block;
box-sizing: border-box;
width: 100%;
height: 100%;
margin: 0;
padding: 25px 16px 9px;
border: 0;
border-radius: inherit;
background: none;
color: inherit;
font: inherit;
font-size: 15px;
line-height: 22px;
font-variant-numeric: tabular-nums;
outline: none;
}
.kodza-number-stepper__input:disabled {
cursor: not-allowed;
}
/* With the script: − on the left, + on the right, label and number centred between them. The
input is still there, on top, its text hidden behind the digits that roll; typing shows it */
.kodza-number-stepper.is-ready .kodza-number-stepper__box {
--kz-number-inset: calc((var(--kz-number-height) - var(--kz-number-button-size)) / 2);
--kz-number-side: calc(var(--kz-number-button-size) + var(--kz-number-inset) * 2);
}
.kodza-number-stepper.is-ready .kodza-number-stepper__label {
right: var(--kz-number-side);
left: var(--kz-number-side);
overflow: hidden;
text-align: center;
text-overflow: ellipsis;
}
.kodza-number-stepper.is-ready .kodza-number-stepper__input,
.kodza-number-stepper__value {
font-size: var(--kz-number-value-size);
font-weight: 500;
letter-spacing: -0.01em;
text-align: center;
}
.kodza-number-stepper.is-ready .kodza-number-stepper__input {
position: relative;
z-index: 1;
padding: 25px var(--kz-number-side) 9px;
color: transparent;
caret-color: var(--kz-number-fg);
-moz-appearance: textfield;
appearance: textfield;
}
.kodza-number-stepper.is-ready .kodza-number-stepper__input::-webkit-inner-spin-button,
.kodza-number-stepper.is-ready .kodza-number-stepper__input::-webkit-outer-spin-button {
margin: 0;
-webkit-appearance: none;
appearance: none;
}
/* While typing, the input's own text, exactly where the digits were */
.kodza-number-stepper.is-typing .kodza-number-stepper__input {
color: inherit;
}
.kodza-number-stepper.is-typing .kodza-number-stepper__value {
visibility: hidden;
}
/* The digits: one slot per place, each its own window, so only the digits that change roll */
.kodza-number-stepper__value {
position: absolute;
right: var(--kz-number-side);
bottom: 9px;
left: var(--kz-number-side);
display: flex;
justify-content: center;
height: 22px;
line-height: 22px;
font-variant-numeric: tabular-nums;
white-space: pre;
pointer-events: none;
}
.kodza-number-stepper__digit {
display: inline-grid;
overflow: hidden;
}
.kodza-number-stepper__digit > span {
grid-area: 1 / 1;
}
/* Compact: no visible label, the number in the middle */
.kodza-number-stepper--compact .kodza-number-stepper__label {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.kodza-number-stepper--compact .kodza-number-stepper__input {
padding: 11px 16px;
}
.kodza-number-stepper--compact.is-ready .kodza-number-stepper__input {
padding: 11px var(--kz-number-side);
}
.kodza-number-stepper--compact .kodza-number-stepper__value {
bottom: 11px;
}
/* − and +: tactile squares in the box (round in the compact one), dim at the limit */
.kodza-number-stepper__button {
position: absolute;
top: var(--kz-number-inset);
z-index: 2;
display: grid;
place-items: center;
box-sizing: border-box;
width: var(--kz-number-button-size);
height: var(--kz-number-button-size);
margin: 0;
padding: 0;
border: 0;
border-radius: var(--kz-number-button-radius);
background: var(--kz-number-button);
color: var(--kz-number-fg);
font: inherit;
cursor: pointer;
touch-action: manipulation;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
transition:
background-color calc(0.15s * var(--kz-motion-scale, 1)) ease,
transform calc(0.15s * var(--kz-motion-scale, 1)) var(--kz-number-ease);
}
.kodza-number-stepper.is-ready .kodza-number-stepper__button {
--kz-number-inset: calc((var(--kz-number-height) - var(--kz-number-button-size)) / 2);
}
.kodza-number-stepper__minus {
left: var(--kz-number-inset);
}
.kodza-number-stepper__plus {
right: var(--kz-number-inset);
}
.kodza-number-stepper__button svg {
width: 14px;
height: 14px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-width: 1.5;
transition: opacity calc(0.2s * var(--kz-motion-scale, 1)) ease;
}
@media (hover: hover) {
.kodza-number-stepper__button:hover:not(:disabled) {
background: var(--kz-number-button-hover);
}
}
/* Pressed: by the pointer, or by the arrow key that does the same thing */
.kodza-number-stepper__button.is-pressed {
background: var(--kz-number-button-press);
transform: scale(0.92);
}
.kodza-number-stepper__button:disabled {
background: transparent;
cursor: not-allowed;
}
.kodza-number-stepper__button:disabled svg {
opacity: 0.28;
}
.kodza-number-stepper.is-disabled .kodza-number-stepper__button {
opacity: 0.5;
}
/* Hint and error share one line: the hint steps out upward as the error rises in */
.kodza-number-stepper__note {
display: grid;
margin: 0;
padding: 0 2px;
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-number-stepper__hint,
.kodza-number-stepper__error {
grid-area: 1 / 1;
transition:
opacity calc(0.22s * var(--kz-motion-scale, 1)) ease,
transform calc(0.35s * var(--kz-motion-scale, 1)) var(--kz-number-ease);
}
.kodza-number-stepper__hint {
color: var(--kz-number-muted);
}
.kodza-number-stepper__error {
display: flex;
align-items: center;
gap: 6px;
color: var(--kz-number-error);
font-weight: var(--kz-font-label-weight, 500);
opacity: 0;
transform: translateY(100%);
}
.kodza-number-stepper__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;
}
.kodza-number-stepper.is-invalid .kodza-number-stepper__hint {
opacity: 0;
transform: translateY(-100%);
}
.kodza-number-stepper.is-invalid .kodza-number-stepper__error {
opacity: 1;
transform: none;
}
/* What screen readers hear after a press on − or +: the new value */
.kodza-number-stepper__live {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.kodza-number-stepper__box,
.kodza-number-stepper__button,
.kodza-number-stepper__button svg,
.kodza-number-stepper__hint,
.kodza-number-stepper__error {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
}
}
/* Theming example — on a dark background:
.dark-section .kodza-number-stepper {
--kz-number-bg: #161616;
--kz-number-fg: #f2f2f0;
--kz-number-muted: rgba(242, 242, 240, 0.62);
--kz-number-line: rgba(255, 255, 255, 0.14);
--kz-number-line-hover: rgba(255, 255, 255, 0.3);
--kz-number-soft: rgba(255, 255, 255, 0.08);
--kz-number-button: rgba(255, 255, 255, 0.07);
--kz-number-button-hover: rgba(255, 255, 255, 0.12);
--kz-number-button-press: rgba(255, 255, 255, 0.18);
--kz-number-error: #f2f2f0;
}
*/
/* kodza-guard 981055c608ea — 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-number-stepper__input):not(#_) {
position: revert;
appearance: revert;
letter-spacing: revert;
text-align: revert;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-number-stepper:not(#_),
.kodza-number-stepper__label:not(#_),
.kodza-number-stepper__value:not(#_),
.kodza-number-stepper__digit:not(#_),
.kodza-number-stepper__hint:not(#_),
.kodza-number-stepper__error:not(#_),
.kodza-number-stepper__live:not(#_),
.kodza-number-stepper__digit span:where(:not([class*="kodza-"])):not(#_) {
box-sizing: revert;
}
.kodza-number-stepper__box:not(#_) {
border: revert;
outline: revert;
text-decoration: revert;
list-style: 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-number-stepper__input:not(#_) {
text-decoration: revert;
transition: revert;
list-style: revert;
float: revert;
box-shadow: revert;
outline-offset: revert;
max-width: revert;
max-height: revert;
text-transform: revert;
text-underline-offset: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
white-space: revert;
min-height: revert;
}
.kodza-number-stepper__button:not(#_) {
outline: revert;
text-decoration: revert;
list-style: revert;
float: 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-number-stepper__note:not(#_) {
border: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-style: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-number-stepper__button svg:where(:not([class*="kodza-"])):not(#_) {
border: revert;
margin: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-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-number-stepper__button svg:where(:not([class*="kodza-"])):not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-number-stepper__button svg:where(:not([class*="kodza-"])):not(#_):where([hidden]) {
display: none;
}
.kodza-number-stepper__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-number-stepper:not(#_) {
width: min(100%, var(--kz-number-width));
}
.kodza-number-stepper__box:not(#_) {
position: relative;
display: block;
box-sizing: border-box;
margin: 0;
padding: 0;
border-radius: var(--kz-number-radius);
background: var(--kz-number-bg);
box-shadow: inset 0 0 0 1px var(--kz-number-line);
transition: box-shadow calc(0.25s * var(--kz-motion-scale, 1)) ease;
}
.kodza-number-stepper__box:hover:not(#_),
.kodza-number-stepper:has(.kodza-number-stepper__button:hover:not(:disabled)) .kodza-number-stepper__box:not(#_) {
box-shadow: inset 0 0 0 1px var(--kz-number-line-hover);
}
.kodza-number-stepper__box:focus-within:not(#_) {
box-shadow: inset 0 0 0 1.5px var(--kz-number-fg), 0 0 0 4px var(--kz-number-soft);
}
.kodza-number-stepper.is-invalid .kodza-number-stepper__box:not(#_) {
box-shadow: inset 0 0 0 1.5px var(--kz-number-error);
}
.kodza-number-stepper.is-invalid .kodza-number-stepper__box:focus-within:not(#_) {
box-shadow: inset 0 0 0 1.5px var(--kz-number-error), 0 0 0 4px var(--kz-number-soft);
}
.kodza-number-stepper__input:not(#_) {
display: block;
box-sizing: border-box;
width: 100%;
height: 100%;
margin: 0;
padding: 25px 16px 9px;
border: 0;
border-radius: inherit;
background: none;
color: inherit;
font: inherit;
font-size: 15px;
line-height: 22px;
font-variant-numeric: tabular-nums;
outline: none;
}
.kodza-number-stepper__input:disabled:not(#_) {
cursor: not-allowed;
}
.kodza-number-stepper.is-ready .kodza-number-stepper__input:not(#_) {
font-size: var(--kz-number-value-size);
font-weight: 500;
letter-spacing: -0.01em;
text-align: center;
}
.kodza-number-stepper.is-ready .kodza-number-stepper__input:not(#_) {
position: relative;
z-index: 1;
padding: 25px var(--kz-number-side) 9px;
color: transparent;
caret-color: var(--kz-number-fg);
-moz-appearance: textfield;
appearance: textfield;
}
.kodza-number-stepper.is-ready .kodza-number-stepper__input:not(#_)::-webkit-inner-spin-button,
.kodza-number-stepper.is-ready .kodza-number-stepper__input:not(#_)::-webkit-outer-spin-button {
margin: 0;
-webkit-appearance: none;
appearance: none;
}
.kodza-number-stepper.is-typing .kodza-number-stepper__input:not(#_) {
color: inherit;
}
.kodza-number-stepper--compact .kodza-number-stepper__input:not(#_) {
padding: 11px 16px;
}
.kodza-number-stepper--compact.is-ready .kodza-number-stepper__input:not(#_) {
padding: 11px var(--kz-number-side);
}
.kodza-number-stepper__button:not(#_) {
position: absolute;
top: var(--kz-number-inset);
z-index: 2;
display: grid;
place-items: center;
box-sizing: border-box;
width: var(--kz-number-button-size);
height: var(--kz-number-button-size);
margin: 0;
padding: 0;
border: 0;
border-radius: var(--kz-number-button-radius);
background: var(--kz-number-button);
color: var(--kz-number-fg);
font: inherit;
cursor: pointer;
touch-action: manipulation;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
transition: background-color calc(0.15s * var(--kz-motion-scale, 1)) ease,
transform calc(0.15s * var(--kz-motion-scale, 1)) var(--kz-number-ease);
}
.kodza-number-stepper__minus:not(#_) {
left: var(--kz-number-inset);
}
.kodza-number-stepper__plus:not(#_) {
right: var(--kz-number-inset);
}
.kodza-number-stepper__button svg:not(#_) {
transition: opacity calc(0.2s * var(--kz-motion-scale, 1)) ease;
}
@media (hover: hover) {
.kodza-number-stepper__button:hover:not(:disabled):not(#_) {
background: var(--kz-number-button-hover);
}
}
.kodza-number-stepper__button.is-pressed:not(#_) {
background: var(--kz-number-button-press);
transform: scale(0.92);
}
.kodza-number-stepper__button:disabled:not(#_) {
background: transparent;
cursor: not-allowed;
}
.kodza-number-stepper.is-disabled .kodza-number-stepper__button:not(#_) {
opacity: 0.5;
}
.kodza-number-stepper__note:not(#_) {
display: grid;
margin: 0;
padding: 0 2px;
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-number-stepper__box:not(#_),
.kodza-number-stepper__button svg:not(#_) {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
}
}
@media (prefers-reduced-motion: reduce) {
.kodza-number-stepper__button: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 — Number Stepper
// A real number input between − and +, which the form still sends. Each digit that changes rolls
// in the direction of the count: up, the old digit leaves upward and the new one comes from
// below; down, the other way. A new place (9 to 10) opens its room as its digit rolls in.
// Holding − or + steps again and again, faster and faster; the button at the edge goes dim at
// the limit. The arrows step too (Page keys by ten, Home and End to the ends). Typing stays
// possible: out of range, the error says why, and on leaving the field the value rolls back
// into range, snapped to the step. min, max and step are read from the input.
// "change" fires on the real input at each step; after setting .value from a script, fire
// "change" on it. The words and timings are below; each word can be set per field with
// data-text-<name> (data-text-max="…"), {min}, {max}, {step}, {label} and {value} are filled in.
(() => {
const SELECTOR = '.kodza-number-stepper';
const WORDS = {
max: '{max} max.', // under the field, while typing past the max
min: '{min} min.',
step: 'Steps of {step}.',
number: 'Numbers only.',
required: 'Pick a number.',
decrease: 'Decrease', // the − button, for screen readers
increase: 'Increase',
live: '{label}: {value}', // read out after a press on − or +
};
const ROLL = 280; // ms, a digit rolling to the next one
const HOLD = 420; // ms to hold − or + before it repeats (a wait: not scaled by the site's pace)
const REPEAT = 160; // ms between the first repeats
const SPEED_UP = 0.86; // each repeat comes this much sooner
const FASTEST = 45; // ms between repeats, at full speed
const PAGE = 10; // steps for Page Up and Page Down
const BUMP = 3; // px, the number nudging at a limit the arrows cannot pass
const ease = 'cubic-bezier(0.22, 1, 0.36, 1)';
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 icon = (d) => `<svg viewBox="0 0 14 14" aria-hidden="true"><path d="${d}"/></svg>`;
const fill = (words, values) => words.replace(/\{(\w+)\}/g, (all, key) => (key in values ? values[key] : all));
const places = (n) => (String(n).split('.')[1] || '').length;
let uid = 0;
// One instance. Everything it starts is tied to `signal`, 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 native = root.querySelector('.kodza-number-stepper__input');
const box = root.querySelector('.kodza-number-stepper__box');
const label = root.querySelector('.kodza-number-stepper__label');
const hint = root.querySelector('.kodza-number-stepper__hint');
const error = root.querySelector('.kodza-number-stepper__error');
if (!native || !box) return;
const word = (key) => root.dataset[`text${key[0].toUpperCase()}${key.slice(1)}`] || WORDS[key];
const id = `kodza-number-stepper-${++uid}`;
// The digits over the input, the two buttons, and what screen readers hear
const value = document.createElement('span');
value.className = 'kodza-number-stepper__value';
value.setAttribute('aria-hidden', 'true');
native.after(value);
const button = (side, key, d) => {
const el = document.createElement('button');
el.type = 'button';
el.className = `kodza-number-stepper__button kodza-number-stepper__${side}`;
el.tabIndex = -1; // the arrows do it from the field; screen readers still reach it
el.setAttribute('aria-label', word(key));
el.innerHTML = icon(d);
return el;
};
const minus = button('minus', 'decrease', 'M3 7h8');
const plus = button('plus', 'increase', 'M3 7h8M7 3v8');
box.after(minus, plus);
const live = document.createElement('span');
live.className = 'kodza-number-stepper__live';
live.setAttribute('aria-live', 'polite');
root.append(live);
if (hint || error) {
if (hint) hint.id = `${id}-hint`;
if (error) error.id = `${id}-error`;
native.setAttribute('aria-describedby', [hint?.id, error?.id].filter(Boolean).join(' '));
}
// min, max and step as the input has them now (a script may change them)
const rules = () => {
const min = parseFloat(native.min);
const max = parseFloat(native.max);
const any = native.step === 'any';
const step = parseFloat(native.step) > 0 ? parseFloat(native.step) : 1;
const base = Number.isFinite(min) ? min : 0; // the steps count from the min, as the browser does
const decimals = any ? null : Math.max(places(step), Number.isFinite(min) ? places(min) : 0);
const round = (n) => (decimals === null ? n : Number(n.toFixed(decimals)));
// The highest value on a step, at or under the max
const top = Number.isFinite(max) ? (any ? max : round(base + Math.floor((max - base) / step + 1e-9) * step)) : Infinity;
const bottom = Number.isFinite(min) ? min : -Infinity;
return { min, max, step, any, base, decimals, round, top, bottom };
};
const format = (n, r = rules()) => (r.decimals === null ? String(n) : n.toFixed(r.decimals));
const current = () => (native.value === '' ? NaN : Number(native.value));
// A value back on a step and inside the range
const fit = (n, r = rules()) => {
let next = r.any ? n : r.round(r.base + Math.round((n - r.base) / r.step) * r.step);
next = Math.min(Math.max(next, r.bottom), r.top);
return r.round(next);
};
// Hint and error share one line
const say = (text) => {
if (!error) return;
error.textContent = text;
root.classList.add('is-invalid');
native.setAttribute('aria-invalid', 'true');
};
const clearError = () => {
if (!error) return;
root.classList.remove('is-invalid');
native.removeAttribute('aria-invalid');
};
const tell = (text) => {
live.textContent = '';
requestAnimationFrame(() => { live.textContent = text; });
};
// The digits: one slot per place, lined up from the right, so 9 to 10 rolls only the units
// and opens a new place on the left
let shown = '';
let slots = [];
const slot = () => {
const el = document.createElement('span');
el.className = 'kodza-number-stepper__digit';
return { el, now: null };
};
// A slot's digit leaves, its new one (if any) comes in, from below when the count goes up
const swap = (place, char, dir, duration) => {
const old = place.now;
place.now = null;
if (char) {
place.now = document.createElement('span');
place.now.textContent = char;
place.el.append(place.now);
}
if (still || !duration) {
old?.remove();
return;
}
const from = dir > 0 ? 100 : -100;
place.now?.animate([{ transform: `translateY(${from}%)` }, { transform: 'none' }], { duration, easing: ease });
if (!old) return;
// Still arriving from a previous step: it leaves from where it is
const at = getComputedStyle(old).transform;
old.getAnimations().forEach((animation) => animation.cancel());
old.animate([{ transform: at === 'none' ? 'none' : at }, { transform: `translateY(${-from}%)` }], { duration, easing: ease, fill: 'forwards' })
.finished.then(() => old.remove(), () => {});
};
const show = (text, dir = 1, duration = ROLL * pace) => {
const before = shown.padStart(Math.max(shown.length, text.length));
const after = text.padStart(before.length);
shown = text;
// Slots lined up with the longer of the two
while (slots.length < before.length) {
const place = slot();
value.prepend(place.el);
slots.unshift(place);
}
const keep = [];
slots.forEach((place, i) => {
const was = before[i] === ' ' ? '' : before[i];
const next = after[i] === ' ' ? '' : after[i];
if (was !== next) swap(place, next, dir, duration);
if (next) keep.push(place);
const opens = !was && next;
const closes = was && !next;
if (!opens && !closes) return;
if (still || !duration) {
if (closes) place.el.remove();
return;
}
// A place that comes or goes: its room opens or closes as its digit rolls
place.el.getAnimations().forEach((animation) => animation.cancel());
const width = `${place.el.scrollWidth}px`;
const room = place.el.animate(
[{ width: opens ? '0px' : width }, { width: opens ? width : '0px' }],
{ duration, easing: ease, fill: closes ? 'forwards' : 'none' },
);
if (closes) room.finished.then(() => place.el.remove(), () => {});
});
slots = keep;
};
// Nudged at a limit the arrows cannot pass
const bump = (dir) => {
if (still) return;
value.animate(
[{ transform: 'none' }, { transform: `translateY(${-dir * BUMP}px)` }, { transform: 'none' }],
{ duration: 260 * pace, easing: 'ease-out' },
);
};
// The buttons, dim at the limit
const refresh = () => {
const r = rules();
const n = current();
const off = native.disabled || native.readOnly;
minus.disabled = off || (Number.isFinite(n) && n <= r.bottom);
plus.disabled = off || (Number.isFinite(n) && n >= r.top);
root.classList.toggle('is-disabled', native.disabled);
if ((minus.disabled && holding === -1) || (plus.disabled && holding === 1)) stopHold();
};
let syncing = false;
const write = (text, dir, duration) => {
native.value = text;
syncing = true;
native.dispatchEvent(new Event('input', { bubbles: true }));
native.dispatchEvent(new Event('change', { bubbles: true }));
syncing = false;
clearError();
show(text, dir, duration);
refresh();
};
// Typing: the input shows its own text; leaving the field (or a step) puts it in range
let typing = false;
const check = () => {
const r = rules();
const n = current();
if (native.validity.badInput) return say(word('number'));
if (!Number.isFinite(n)) return clearError();
if (n > r.max) return say(fill(word('max'), { max: format(r.max, r) }));
if (n < r.min) return say(fill(word('min'), { min: format(r.min, r) }));
if (!r.any && Math.abs(fit(n, r) - n) > 1e-9) return say(fill(word('step'), { step: r.step }));
clearError();
};
const commit = () => {
if (!typing) return;
typing = false;
const typed = native.value;
if (native.validity.badInput) {
native.value = shown; // not a number: the last one comes back
} else if (typed !== '') {
const n = Number(typed);
const next = format(fit(n));
// The digits take over from the typed text, then roll to the value in range
show(typed, 1, 0);
if (next !== typed) write(next, Number(next) > n ? 1 : -1);
} else {
show('', 1, 0);
}
root.classList.remove('is-typing');
clearError();
refresh();
};
// One step (or several: Page keys), from the value there now
const stepBy = (count, duration) => {
commit();
if (native.disabled || native.readOnly) return false;
const r = rules();
const n = current();
let next;
if (!Number.isFinite(n)) next = fit(r.base > r.top ? r.top : Math.max(r.base, r.bottom), r);
else {
const k = (n - r.base) / r.step;
const from = count > 0 ? Math.floor(k + 1e-9) : Math.ceil(k - 1e-9);
next = r.round(Math.min(Math.max(r.base + (from + count) * r.step, r.bottom), r.top));
}
if (Number.isFinite(n) && next === n) return false;
write(format(next, r), Number.isFinite(n) && next < n ? -1 : 1, duration);
return true;
};
const jump = (to) => {
commit();
const n = current();
if (native.disabled || native.readOnly || !Number.isFinite(to) || to === n) return false;
write(format(to), Number.isFinite(n) && to < n ? -1 : 1);
return true;
};
// Held down: once, then again after HOLD, faster and faster
let holding = 0;
let timer = 0;
let pressed = null;
const stopHold = () => {
clearTimeout(timer);
if (holding && shown) tell(fill(word('live'), { label: label?.textContent.trim() || '', value: shown }));
holding = 0;
pressed?.classList.remove('is-pressed');
pressed = null;
};
const startHold = (dir, el) => {
stopHold();
if (!stepBy(dir)) return;
holding = dir;
pressed = el;
el.classList.add('is-pressed');
let wait = REPEAT;
const again = () => {
// Fast repeats roll faster too, so each digit still lands before the next one leaves
if (!stepBy(dir, Math.min(ROLL, wait * 1.4) * pace)) return stopHold();
wait = Math.max(FASTEST, wait * SPEED_UP);
timer = setTimeout(again, wait);
};
timer = setTimeout(again, HOLD);
};
[[minus, -1], [plus, 1]].forEach(([el, dir]) => {
el.addEventListener('pointerdown', (event) => {
if (event.button !== 0 || el.disabled) return;
event.preventDefault(); // the focus stays where it is, nothing gets selected
startHold(dir, el);
}, { signal });
el.addEventListener('pointerleave', stopHold, { signal });
// Pressed by a screen reader or a script (no pointer): one step
el.addEventListener('click', (event) => {
if (event.detail !== 0) return;
if (stepBy(dir)) tell(fill(word('live'), { label: label?.textContent.trim() || '', value: shown }));
}, { signal });
el.addEventListener('contextmenu', (event) => event.preventDefault(), { signal });
});
document.addEventListener('pointerup', stopHold, { signal });
document.addEventListener('pointercancel', stopHold, { signal });
window.addEventListener('blur', stopHold, { signal });
// The keys, in the field. The matching button is pressed with them
let keyed = null;
const press = (el) => {
keyed?.classList.remove('is-pressed');
keyed = el;
el?.classList.add('is-pressed');
};
native.addEventListener('keydown', (event) => {
if (event.altKey || event.ctrlKey || event.metaKey) return;
const r = rules();
let dir = 0;
let moved = false;
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
dir = event.key === 'ArrowUp' ? 1 : -1;
moved = stepBy(dir);
} else if (event.key === 'PageUp' || event.key === 'PageDown') {
dir = event.key === 'PageUp' ? 1 : -1;
moved = stepBy(dir * PAGE);
} else if ((event.key === 'Home' && Number.isFinite(r.min)) || (event.key === 'End' && Number.isFinite(r.max))) {
dir = event.key === 'End' ? 1 : -1;
moved = jump(dir > 0 ? r.top : r.min);
} else if (event.key === 'Enter') {
commit(); // a typed value in range first, then the form sends as usual
return;
} else return;
event.preventDefault();
if (native.disabled || native.readOnly) return;
press(dir > 0 ? plus : minus);
if (!moved) bump(dir);
}, { signal });
native.addEventListener('keyup', () => press(null), { signal });
native.addEventListener('blur', () => {
press(null);
commit();
}, { signal });
native.addEventListener('input', () => {
if (syncing) return;
typing = true;
root.classList.add('is-typing');
check();
refresh();
}, { signal });
// The form's own check: the error under the field instead of the browser's bubble
native.addEventListener('invalid', (event) => {
event.preventDefault();
say(native.validity.valueMissing ? word('required') : native.validationMessage);
const first = native.form && [...native.form.elements].find((el) => el.willValidate && !el.validity.valid);
if (!native.form || first === native) native.focus();
}, { signal });
// A value set from outside (a script, the form's reset): the digits roll to it
const read = () => {
if (typing) return;
const text = native.value;
if (text === shown) return refresh();
const was = Number(shown);
show(text, Number.isFinite(was) && Number(text) < was ? -1 : 1);
clearError();
refresh();
};
native.addEventListener('change', () => {
if (!syncing) read();
}, { signal });
native.form?.addEventListener('reset', () => setTimeout(() => {
typing = false;
root.classList.remove('is-typing');
read();
}), { signal });
const observer = new MutationObserver(refresh);
observer.observe(native, { attributes: true, attributeFilter: ['min', 'max', 'step', 'disabled', 'readonly', 'required'] });
signal.addEventListener('abort', () => {
observer.disconnect();
clearTimeout(timer);
}, { once: true });
show(native.value, 1, 0);
refresh();
}
// 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 — Number Stepper, for React. From https://kodza.dev/components/number-stepper/
// 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: number-stepper.css and number-stepper.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 './number-stepper.css';
type Variant = "compact";
export type NumberStepperProps = Omit<ComponentPropsWithoutRef<'div'>, "children" | "variant" | "vars"> & {
/** Modifier class(es): .kodza-number-stepper--compact */
variant?: Variant | Variant[];
/** Settings, as CSS variables on the root */
vars?: {
/** default: #ffffff */
"--kz-number-bg"?: string | number;
/** default: #0a0a0a */
"--kz-number-fg"?: string | number;
/** default: rgba(10, 10, 10, 0.6) */
"--kz-number-muted"?: string | number;
/** default: rgba(10, 10, 10, 0.14) */
"--kz-number-line"?: string | number;
/** default: rgba(10, 10, 10, 0.3) */
"--kz-number-line-hover"?: string | number;
/** default: rgba(10, 10, 10, 0.06) */
"--kz-number-soft"?: string | number;
/** the − and + — default: rgba(10, 10, 10, 0.05) */
"--kz-number-button"?: string | number;
/** default: rgba(10, 10, 10, 0.09) */
"--kz-number-button-hover"?: string | number;
/** default: rgba(10, 10, 10, 0.14) */
"--kz-number-button-press"?: string | number;
/** a message, never colour alone: set a red here if you want one — default: #0a0a0a */
"--kz-number-error"?: string | number;
/** default: 56px */
"--kz-number-height"?: string | number;
/** default: calc(12px * var(--kz-radius-scale, 1)) */
"--kz-number-radius"?: string | number;
/** default: 40px */
"--kz-number-button-size"?: string | number;
/** default: calc(8px * var(--kz-radius-scale, 1)) */
"--kz-number-button-radius"?: string | number;
/** default: 17px */
"--kz-number-value-size"?: string | number;
/** default: var(--kz-field-width, 240px) */
"--kz-number-width"?: string | number;
/** default: 0.28s */
"--kz-number-duration"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-number-ease"?: string | number;
};
className?: string;
};
export function NumberStepper({ variant, vars, className, style, ...rest }: NumberStepperProps) {
const ref = useRef<ComponentRef<'div'>>(null);
const classes = ["kodza-number-stepper", ...([] as string[]).concat(variant ?? []).map((v) => `kodza-number-stepper--${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('./number-stepper.js').then(() => {
if (live) document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root } }));
});
return () => {
live = false;
document.dispatchEvent(new CustomEvent('kodza:destroy', { detail: { root } }));
};
}, [key]);
return (
<div key={key} {...rest} className={classes} data-kodza-manual style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<label className="kodza-number-stepper__box">
<span className="kodza-number-stepper__label">Guests</span>
{' '}
<input className="kodza-number-stepper__input" type="number" name="guests" defaultValue="2" min="1" max="12" step="1" inputMode="numeric" />
</label>
<p className="kodza-number-stepper__note">
<span className="kodza-number-stepper__hint">Up to 12. The sofa folds out.</span>
{' '}
<span className="kodza-number-stepper__error" aria-live="polite" />
</p>
</div>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.