OTP Input
A one-time code field: one real input drawn as cells. Digits rise into place, paste and SMS autofill just work, and the cells turn to ink once complete.
View codeA real multiple select as a field of tags: type to filter, pick to add a tag. Removed tags shrink away while the others glide into their place.
With your AI
Say it to your AI. It gets the exact code below, not its best guess, and knows where it goes.
Use Kodza Multi Select to pick tags in my post form.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<label class="kodza-multi">
<span class="kodza-multi__label">Your stack</span>
<select class="kodza-multi__native" name="stack" multiple>
<option value="html" selected>HTML</option>
<option value="css" selected>CSS</option>
<option value="js">JavaScript</option>
<option value="wordpress">WordPress</option>
<option value="webflow">Webflow</option>
<option value="astro">Astro</option>
<option value="php">PHP, proudly</option>
<option value="jquery" disabled>jQuery (we moved on)</option>
</select>
</label>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Multi Select
A real multiple select, as a field of tags: type to filter, pick with a click or Enter, and
the tag pops into the field. Remove it with its cross or Backspace: it shrinks away while the
others glide into its place, and the field eases to its new height. The real select stays
in step for the form. Same box as Select.
Everything is tuned with the --kz-multi-* variables. */
.kodza-multi {
--kz-multi-bg: #ffffff;
--kz-multi-fg: #0a0a0a;
--kz-multi-muted: rgba(10, 10, 10, 0.6);
--kz-multi-line: rgba(10, 10, 10, 0.14);
--kz-multi-line-hover: rgba(10, 10, 10, 0.3);
--kz-multi-soft: rgba(10, 10, 10, 0.06);
--kz-multi-ink: #0a0a0a;
--kz-multi-on-ink: #ffffff;
--kz-multi-shadow: 0 18px 40px -16px rgba(10, 10, 10, 0.3);
--kz-multi-radius: 12px;
--kz-multi-pad: 16px;
--kz-multi-ease: cubic-bezier(0.22, 1, 0.36, 1);
position: relative;
display: block;
width: 100%;
max-width: 420px;
/* The closed list keeps its size, ready to unroll, but never lengthens the page */
contain: layout;
/* A number, not auto, so it can wait for the list to roll up before dropping back */
z-index: 0;
transition: z-index 0s linear 0.35s;
color: var(--kz-multi-fg);
}
/* Open, the field rises above what follows it (its list included) */
.kodza-multi.is-open {
z-index: 20;
transition-delay: 0s;
}
.kodza-multi__label {
position: absolute;
top: 10px;
left: var(--kz-multi-pad);
z-index: 1;
color: var(--kz-multi-muted);
font-size: 12px;
line-height: 1;
pointer-events: none;
}
/* Without JS: the native multiple select, styled */
.kodza-multi__native {
width: 100%;
min-height: 120px;
padding: 30px var(--kz-multi-pad) 8px;
border: 0;
border-radius: var(--kz-multi-radius);
background: var(--kz-multi-bg);
box-shadow: inset 0 0 0 1px var(--kz-multi-line);
color: inherit;
font: inherit;
font-size: 15px;
}
.kodza-multi.is-ready .kodza-multi__native {
position: absolute;
width: 1px;
height: 1px;
min-height: 0;
padding: 0;
overflow: hidden;
opacity: 0;
pointer-events: none;
clip-path: inset(50%);
}
/* The field: tags and the filter input, wrapping */
.kodza-multi__field {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
min-height: 56px;
padding: 28px 44px 8px 12px;
border-radius: var(--kz-multi-radius);
background: var(--kz-multi-bg);
box-shadow: inset 0 0 0 1px var(--kz-multi-line);
box-sizing: border-box;
cursor: text;
transition: box-shadow 0.25s ease;
}
.kodza-multi__field:hover {
box-shadow: inset 0 0 0 1px var(--kz-multi-line-hover);
}
.kodza-multi__field:focus-within {
box-shadow: inset 0 0 0 1.5px var(--kz-multi-fg), 0 0 0 4px var(--kz-multi-soft);
}
.kodza-multi__tag {
display: inline-flex;
align-items: center;
gap: 4px;
height: 28px;
padding: 0 4px 0 10px;
border-radius: 999px;
background: var(--kz-multi-ink);
color: var(--kz-multi-on-ink);
font-size: 13px;
font-weight: 500;
white-space: nowrap;
}
.kodza-multi__tag.is-new {
animation: kodza-multi-pop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes kodza-multi-pop {
from {
transform: scale(0.6);
}
}
.kodza-multi__remove {
display: grid;
place-items: center;
width: 20px;
height: 20px;
padding: 0;
border: 0;
border-radius: 50%;
background: none;
color: inherit;
cursor: pointer;
opacity: 0.7;
transition: opacity 0.2s ease, background-color 0.2s ease;
}
.kodza-multi__remove:hover {
background: rgba(255, 255, 255, 0.18);
opacity: 1;
}
.kodza-multi__remove:focus-visible {
outline: 2px solid var(--kz-multi-on-ink);
outline-offset: -2px;
}
.kodza-multi__remove svg {
width: 10px;
height: 10px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
}
.kodza-multi__input {
box-sizing: border-box;
flex: 1;
min-width: 88px;
height: 28px;
padding: 0 4px;
border: 0;
background: none;
color: inherit;
font: inherit;
font-size: 15px;
outline: none;
}
.kodza-multi__input::placeholder {
color: var(--kz-multi-muted);
}
.kodza-multi__chevron {
position: absolute;
top: 20px;
right: 16px;
width: 16px;
height: 16px;
fill: none;
stroke: var(--kz-multi-muted);
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
pointer-events: none;
transition: transform 0.35s var(--kz-multi-ease);
}
.kodza-multi.is-open .kodza-multi__chevron {
transform: rotate(180deg);
}
/* The list unrolls under the field */
.kodza-multi__list {
position: absolute;
top: calc(100% + 8px);
right: 0;
left: 0;
z-index: 20;
max-height: 260px;
margin: 0;
padding: 6px;
overflow-y: auto;
overscroll-behavior: contain;
border-radius: 14px;
background: var(--kz-multi-bg);
box-shadow: 0 0 0 1px var(--kz-multi-line), var(--kz-multi-shadow);
list-style: none;
visibility: hidden;
opacity: 0;
clip-path: inset(0 0 100% 0 round 14px);
transform: translateY(-4px);
transition:
clip-path 0.35s var(--kz-multi-ease),
opacity 0.2s ease,
transform 0.35s var(--kz-multi-ease),
visibility 0s linear 0.35s;
}
.kodza-multi.is-open .kodza-multi__list {
visibility: visible;
opacity: 1;
clip-path: inset(-40px -40px -60px -40px round 14px);
transform: none;
transition-delay: 0s;
}
/* Not enough room below: it unrolls upwards, from the top of the field */
.kodza-multi.is-above .kodza-multi__list {
top: auto;
bottom: calc(100% + 8px);
clip-path: inset(100% 0 0 0 round 14px);
transform: translateY(4px);
}
.kodza-multi.is-above.is-open .kodza-multi__list {
clip-path: inset(-60px -40px -40px -40px round 14px);
transform: none;
}
.kodza-multi__highlight {
position: absolute;
top: 0;
right: 6px;
left: 6px;
height: var(--kz-hl-h, 36px);
border-radius: 8px;
background: var(--kz-multi-soft);
opacity: var(--kz-hl-on, 0);
transform: translateY(var(--kz-hl-y, 0));
pointer-events: none;
}
.kodza-multi.is-open .kodza-multi__highlight {
transition: transform 0.22s var(--kz-multi-ease), opacity 0.15s ease;
}
.kodza-multi__option {
position: relative;
display: flex;
align-items: center;
gap: 10px;
height: 36px;
padding: 0 10px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.kodza-multi__option[hidden] {
display: none;
}
.kodza-multi__option[aria-disabled="true"] {
color: var(--kz-multi-muted);
cursor: not-allowed;
}
/* A small checkbox per option: ink grows, the tick draws */
.kodza-multi__box {
position: relative;
display: grid;
flex: none;
place-items: center;
width: 16px;
height: 16px;
border-radius: 5px;
box-shadow: inset 0 0 0 1.5px var(--kz-multi-line-hover);
}
.kodza-multi__box::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: var(--kz-multi-ink);
transform: scale(0);
transition: transform 0.2s ease-in;
}
.kodza-multi__box svg {
position: relative;
width: 11px;
height: 11px;
fill: none;
stroke: var(--kz-multi-on-ink);
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 1;
stroke-dashoffset: 1;
transition: stroke-dashoffset 0.12s ease-in;
}
.kodza-multi__option[aria-selected="true"] .kodza-multi__box::before {
transform: none;
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.kodza-multi__option[aria-selected="true"] .kodza-multi__box svg {
stroke-dashoffset: 0;
transition: stroke-dashoffset 0.3s var(--kz-multi-ease) 0.1s;
}
.kodza-multi__option mark {
background: none;
color: inherit;
text-decoration: underline;
text-decoration-thickness: 1.5px;
text-underline-offset: 3px;
}
.kodza-multi__empty {
padding: 18px 10px;
font-size: 13px;
color: var(--kz-multi-muted);
text-align: center;
}
.kodza-multi__live {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.kodza-multi__tag.is-new {
animation: none;
}
.kodza-multi__field,
.kodza-multi__chevron,
.kodza-multi__list,
.kodza-multi.is-open .kodza-multi__highlight,
.kodza-multi__box::before,
.kodza-multi__box svg {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
}
}
/* Theming example — on a dark background:
.dark-section .kodza-multi {
--kz-multi-bg: #161616;
--kz-multi-fg: #f2f2f0;
--kz-multi-muted: rgba(242, 242, 240, 0.62);
--kz-multi-line: rgba(255, 255, 255, 0.14);
--kz-multi-line-hover: rgba(255, 255, 255, 0.3);
--kz-multi-soft: rgba(255, 255, 255, 0.08);
--kz-multi-ink: #f2f2f0;
--kz-multi-on-ink: #0a0a0a;
--kz-multi-shadow: 0 18px 40px -16px rgba(0, 0, 0, 0.8);
}
*/
/* kodza-guard dc90acf35251 — 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-multi__native):not(#_) {
position: revert;
}
:where(.kodza-multi__remove):not(#_) {
outline: revert;
outline-offset: revert;
}
:where(.kodza-multi__highlight):not(#_) {
transition: revert;
}
:where(.kodza-multi__option):not(#_) {
color: revert;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-multi:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: 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-multi__label:not(#_),
.kodza-multi__tag:not(#_),
.kodza-multi__box:not(#_),
.kodza-multi__text:not(#_),
.kodza-multi__live:not(#_) {
box-sizing: revert;
}
.kodza-multi__native:not(#_) {
margin: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: 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;
}
.kodza-multi__remove:not(#_) {
margin: revert;
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-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;
white-space: revert;
min-height: revert;
}
.kodza-multi__input:not(#_) {
margin: revert;
border-radius: revert;
text-decoration: revert;
transition: revert;
list-style: 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-multi__chevron:not(#_),
.kodza-multi__box 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;
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-multi__list:not(#_) {
border: revert;
outline: revert;
text-decoration: revert;
box-sizing: revert;
float: revert;
outline-offset: revert;
appearance: revert;
max-width: 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-multi__highlight:not(#_) {
border: revert;
margin: revert;
padding: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: 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-multi__option:not(#_) {
border: revert;
margin: 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;
font-family: 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-multi__empty:not(#_) {
border: revert;
margin: 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;
font-family: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-multi__native option:where(:not([class*="kodza-"])):not(#_) {
box-sizing: revert;
color: revert;
}
.kodza-multi__remove 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-multi__native:not(#_):where(:not([hidden])),
.kodza-multi__input:not(#_):where(:not([hidden])),
.kodza-multi__chevron:not(#_):where(:not([hidden])),
.kodza-multi__list:not(#_):where(:not([hidden])),
.kodza-multi__highlight:not(#_):where(:not([hidden])),
.kodza-multi__empty:not(#_):where(:not([hidden])),
.kodza-multi__remove svg:where(:not([class*="kodza-"])):not(#_):where(:not([hidden])),
.kodza-multi__box svg:where(:not([class*="kodza-"])):not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-multi__native:not(#_):where([hidden]),
.kodza-multi__input:not(#_):where([hidden]),
.kodza-multi__chevron:not(#_):where([hidden]),
.kodza-multi__list:not(#_):where([hidden]),
.kodza-multi__highlight:not(#_):where([hidden]),
.kodza-multi__empty:not(#_):where([hidden]),
.kodza-multi__remove svg:where(:not([class*="kodza-"])):not(#_):where([hidden]),
.kodza-multi__box svg:where(:not([class*="kodza-"])):not(#_):where([hidden]) {
display: none;
}
.kodza-multi__box: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-multi:not(#_) {
position: relative;
display: block;
transition: z-index 0s linear 0.35s;
color: var(--kz-multi-fg);
}
.kodza-multi:not(#_) {
width: min(100%, 420px);
}
.kodza-multi.is-open:not(#_) {
transition-delay: 0s;
}
.kodza-multi__native:not(#_) {
width: 100%;
min-height: 120px;
padding: 30px var(--kz-multi-pad) 8px;
border: 0;
border-radius: var(--kz-multi-radius);
background: var(--kz-multi-bg);
box-shadow: inset 0 0 0 1px var(--kz-multi-line);
color: inherit;
font: inherit;
font-size: 15px;
}
.kodza-multi.is-ready .kodza-multi__native:not(#_) {
position: absolute;
width: 1px;
height: 1px;
min-height: 0;
padding: 0;
overflow: hidden;
opacity: 0;
pointer-events: none;
clip-path: inset(50%);
}
.kodza-multi__remove:not(#_) {
display: grid;
place-items: center;
width: 20px;
height: 20px;
padding: 0;
border: 0;
border-radius: 50%;
background: none;
color: inherit;
cursor: pointer;
opacity: 0.7;
transition: opacity 0.2s ease, background-color 0.2s ease;
}
.kodza-multi__remove:hover:not(#_) {
background: rgba(255, 255, 255, 0.18);
opacity: 1;
}
.kodza-multi__remove:focus-visible:not(#_) {
outline: 2px solid var(--kz-multi-on-ink);
outline-offset: -2px;
}
.kodza-multi__input:not(#_) {
box-sizing: border-box;
flex: 1;
min-width: 88px;
height: 28px;
padding: 0 4px;
border: 0;
background: none;
color: inherit;
font: inherit;
font-size: 15px;
outline: none;
}
.kodza-multi__input:not(#_)::placeholder {
color: var(--kz-multi-muted);
}
.kodza-multi__chevron:not(#_) {
position: absolute;
transition: transform 0.35s var(--kz-multi-ease);
}
.kodza-multi__list:not(#_) {
position: absolute;
max-height: 260px;
margin: 0;
padding: 6px;
border-radius: 14px;
background: var(--kz-multi-bg);
box-shadow: 0 0 0 1px var(--kz-multi-line), var(--kz-multi-shadow);
list-style: none;
transition: clip-path 0.35s var(--kz-multi-ease),
opacity 0.2s ease,
transform 0.35s var(--kz-multi-ease),
visibility 0s linear 0.35s;
}
.kodza-multi.is-open .kodza-multi__list:not(#_) {
transition-delay: 0s;
}
.kodza-multi__highlight:not(#_) {
position: absolute;
border-radius: 8px;
background: var(--kz-multi-soft);
}
.kodza-multi.is-open .kodza-multi__highlight:not(#_) {
transition: transform 0.22s var(--kz-multi-ease), opacity 0.15s ease;
}
.kodza-multi__option:not(#_) {
position: relative;
display: flex;
padding: 0 10px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
}
.kodza-multi__option[hidden]:not(#_) {
display: none;
}
.kodza-multi__option[aria-disabled="true"]:not(#_) {
color: var(--kz-multi-muted);
}
.kodza-multi__box svg:not(#_) {
position: relative;
transition: stroke-dashoffset 0.12s ease-in;
}
.kodza-multi__option[aria-selected="true"] .kodza-multi__box svg:not(#_) {
transition: stroke-dashoffset 0.3s var(--kz-multi-ease) 0.1s;
}
.kodza-multi__empty:not(#_) {
padding: 18px 10px;
font-size: 13px;
color: var(--kz-multi-muted);
text-align: center;
}
@media (prefers-reduced-motion: reduce) {
.kodza-multi__chevron:not(#_),
.kodza-multi__list:not(#_),
.kodza-multi.is-open .kodza-multi__highlight:not(#_),
.kodza-multi__box svg: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 — Multi Select
// Builds a field of tags and a filterable list over the real <select multiple>, and keeps both in
// step (firing "change" on the real one). In the filter input: arrows move, Enter toggles,
// Escape closes, Backspace on an empty input removes the last tag. Removals are animated: the
// tag shrinks away, the others glide into place, the field eases to its new height.
(() => {
const SELECTOR = '.kodza-multi';
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const ease = 'cubic-bezier(0.22, 1, 0.36, 1)';
const tick = '<svg viewBox="0 0 16 16"><path d="M3.5 8.5l3 3 6-7" pathLength="1"/></svg>';
const cross = '<svg viewBox="0 0 12 12" aria-hidden="true"><path d="M3 3l6 6M9 3 3 9"/></svg>';
let uid = 0;
// One instance. Everything it starts is tied to `signal`, so kodza:destroy stops it all
function mount(root, signal) {
const native = root.querySelector('.kodza-multi__native');
const label = root.querySelector('.kodza-multi__label');
if (!native) return;
const id = `kodza-multi-${++uid}`;
if (label) label.id = `${id}-label`;
const field = document.createElement('div');
field.className = 'kodza-multi__field';
const input = document.createElement('input');
input.className = 'kodza-multi__input';
input.type = 'text';
input.autocomplete = 'off';
input.spellcheck = false;
input.placeholder = root.dataset.placeholder || 'Add…';
input.setAttribute('role', 'combobox');
input.setAttribute('aria-expanded', 'false');
input.setAttribute('aria-controls', `${id}-list`);
input.setAttribute('aria-autocomplete', 'list');
if (label) input.setAttribute('aria-labelledby', `${id}-label`);
field.append(input);
field.insertAdjacentHTML('beforeend', '<svg class="kodza-multi__chevron" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 6.5 8 10.5l4-4"/></svg>');
const list = document.createElement('ul');
list.className = 'kodza-multi__list';
list.id = `${id}-list`;
list.setAttribute('role', 'listbox');
list.setAttribute('aria-multiselectable', 'true');
if (label) list.setAttribute('aria-labelledby', `${id}-label`);
const highlight = Object.assign(document.createElement('li'), { className: 'kodza-multi__highlight' });
highlight.setAttribute('role', 'presentation');
const empty = Object.assign(document.createElement('li'), { className: 'kodza-multi__empty', textContent: 'Nothing matches. Yet.', hidden: true });
empty.setAttribute('role', 'presentation');
list.append(highlight);
const entries = [...native.options].map((option, i) => {
const item = document.createElement('li');
item.className = 'kodza-multi__option';
item.id = `${id}-option-${i}`;
item.setAttribute('role', 'option');
item.innerHTML = `<span class="kodza-multi__box" aria-hidden="true">${tick}</span><span class="kodza-multi__text"></span>`;
item.querySelector('.kodza-multi__text').textContent = option.textContent;
if (option.disabled) item.setAttribute('aria-disabled', 'true');
list.append(item);
return { option, item, text: option.textContent, tag: null };
});
list.append(empty);
native.after(field, list);
native.tabIndex = -1;
native.setAttribute('aria-hidden', 'true');
const live = Object.assign(document.createElement('span'), { className: 'kodza-multi__live' });
live.setAttribute('aria-live', 'polite');
root.append(live);
const isOpen = () => root.classList.contains('is-open');
const shown = () => entries.filter(({ item }) => !item.hidden && item.getAttribute('aria-disabled') !== 'true');
let active = null;
const light = (entry, scroll = true) => {
active = entry;
if (!entry) {
input.removeAttribute('aria-activedescendant');
return list.style.setProperty('--kz-hl-on', 0);
}
input.setAttribute('aria-activedescendant', entry.item.id);
list.style.setProperty('--kz-hl-y', `${entry.item.offsetTop}px`);
list.style.setProperty('--kz-hl-h', `${entry.item.offsetHeight}px`);
list.style.setProperty('--kz-hl-on', 1);
// Scroll the list only (scrollIntoView would also nudge the page), with the list's own
// padding above the first row still shown (rows filtered out above it don't count)
if (scroll) {
let prev = entry.item.previousElementSibling;
while (prev?.hidden) prev = prev.previousElementSibling;
const top = prev === highlight ? 0 : entry.item.offsetTop;
const bottom = entry.item.offsetTop + entry.item.offsetHeight;
if (top < list.scrollTop) list.scrollTop = top;
else if (bottom > list.scrollTop + list.clientHeight) list.scrollTop = bottom - list.clientHeight;
}
};
// Everything that moves in the field, measured before and after a change, glides
const glide = (change) => {
const before = new Map([...field.children].map((el) => [el, el.getBoundingClientRect()]));
const height = field.offsetHeight;
change();
if (reduceMotion) return;
// Screen pixels back to the field's own (a parent may be scaled with a transform)
const k = field.getBoundingClientRect().width / field.offsetWidth || 1;
[...field.children].forEach((el) => {
const a = before.get(el);
if (!a || el.classList.contains('is-new')) return;
const b = el.getBoundingClientRect();
const dx = (a.left - b.left) / k;
const dy = (a.top - b.top) / k;
if (Math.abs(dx) + Math.abs(dy) > 0.5) el.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }], { duration: 420, easing: ease });
});
const to = field.offsetHeight;
if (to !== height) field.animate([{ height: `${height}px` }, { height: `${to}px` }], { duration: 420, easing: ease });
};
const makeTag = (entry, fresh) => {
const tag = document.createElement('span');
tag.className = `kodza-multi__tag${fresh && !reduceMotion ? ' is-new' : ''}`;
tag.append(entry.text);
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'kodza-multi__remove';
remove.tabIndex = -1;
remove.setAttribute('aria-label', `Remove ${entry.text}`);
remove.innerHTML = cross;
remove.addEventListener('click', (event) => {
event.stopPropagation();
toggle(entry, false);
input.focus();
}, { signal });
tag.append(remove);
tag.addEventListener('animationend', () => tag.classList.remove('is-new'), { signal });
return tag;
};
const toggle = (entry, on = !entry.option.selected) => {
if (!entry || entry.item.getAttribute('aria-disabled') === 'true' || entry.option.selected === on) return;
entry.option.selected = on;
entry.item.setAttribute('aria-selected', String(on));
if (on) {
entry.tag = makeTag(entry, true);
glide(() => input.before(entry.tag));
} else if (entry.tag) {
const tag = entry.tag;
entry.tag = null;
const out = () => glide(() => tag.remove());
if (reduceMotion) out();
else tag.animate([{ opacity: 1, transform: 'none' }, { opacity: 0, transform: 'scale(0.6)' }], { duration: 160, easing: 'ease-in', fill: 'forwards' }).finished.then(out);
}
live.textContent = `${entry.text} ${on ? 'added' : 'removed'}`;
native.dispatchEvent(new Event('change', { bubbles: true }));
};
// Filter: letters anywhere in the name, underlined
const filter = () => {
const query = input.value.trim().toLowerCase();
entries.forEach((entry) => {
const at = query ? entry.text.toLowerCase().indexOf(query) : -1;
entry.item.hidden = query !== '' && at < 0;
const text = entry.item.querySelector('.kodza-multi__text');
if (at < 0) return (text.textContent = entry.text);
const mark = Object.assign(document.createElement('mark'), { textContent: entry.text.slice(at, at + query.length) });
text.replaceChildren(entry.text.slice(0, at), mark, entry.text.slice(at + query.length));
});
empty.hidden = shown().length > 0;
light(shown()[0] || null);
};
const open = () => {
if (isOpen()) return;
// Not enough room below and more above: the list opens upwards. A closed list that changes
// side gets there at once, so it always unrolls from the field
const box = field.getBoundingClientRect();
const above = box.bottom + Math.min(272, list.scrollHeight) + 16 > innerHeight && box.top > innerHeight / 2;
if (above !== root.classList.contains('is-above')) {
list.style.transition = 'none';
root.classList.toggle('is-above', above);
void list.offsetWidth;
list.style.transition = '';
}
root.classList.add('is-open');
input.setAttribute('aria-expanded', 'true');
requestAnimationFrame(() => light(shown()[0] || null, false));
};
const close = () => {
if (!isOpen()) return;
root.classList.remove('is-open');
input.setAttribute('aria-expanded', 'false');
light(null);
};
input.addEventListener('focus', open, { signal });
input.addEventListener('input', () => {
open();
filter();
}, { signal });
input.addEventListener('keydown', (event) => {
const pool = shown();
const index = pool.indexOf(active);
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
open();
if (pool.length) light(pool[(index + (event.key === 'ArrowDown' ? 1 : -1) + pool.length) % pool.length]);
} else if (event.key === 'Enter') {
event.preventDefault();
if (isOpen() && active) toggle(active);
} else if (event.key === 'Escape') {
close();
} else if (event.key === 'Backspace' && input.value === '') {
// The last tag shown (they line up in the order they were picked, not the list's)
const owner = new Map(entries.filter((entry) => entry.tag).map((entry) => [entry.tag, entry]));
const last = [...field.querySelectorAll('.kodza-multi__tag')].reverse().map((tag) => owner.get(tag)).find(Boolean);
if (last) toggle(last, false);
} else if (event.key === 'Tab') {
close();
}
}, { signal });
entries.forEach((entry) => {
entry.item.addEventListener('pointermove', () => active !== entry && entry.item.getAttribute('aria-disabled') !== 'true' && light(entry, false), { signal });
entry.item.addEventListener('click', () => {
toggle(entry);
input.focus();
}, { signal });
});
list.addEventListener('mousedown', (event) => event.preventDefault(), { signal });
root.addEventListener('click', (event) => {
if (event.target === input) return;
event.preventDefault();
if (!list.contains(event.target)) input.focus();
}, { signal });
document.addEventListener('pointerdown', (event) => !root.contains(event.target) && close(), { signal });
// The tags of the options already chosen
entries.forEach((entry) => {
entry.item.setAttribute('aria-selected', String(entry.option.selected));
if (entry.option.selected) input.before((entry.tag = makeTag(entry, false)));
});
}
// Kodza lifecycle, the same in every component: `kodza:init` starts the instances inside
// event.detail.root (the whole page by default), `kodza:destroy` stops them and puts their
// markup back. For themes and apps that swap content without a page load.
const instances = new WeakMap();
const find = (scope = document) => [scope, ...scope.querySelectorAll(SELECTOR)].filter((el) => el.matches?.(SELECTOR));
const init = (scope) => find(scope).forEach((root) => {
if (root.classList.contains('is-ready')) return; // already running, maybe from another copy of this script
const controller = new AbortController();
instances.set(root, { controller, html: root.innerHTML, attrs: [...root.attributes].map((a) => [a.name, a.value]) });
mount(root, controller.signal);
root.classList.add('is-ready');
});
const destroy = (scope) => find(scope).forEach((root) => {
const instance = instances.get(root);
if (!instance) return;
instances.delete(root);
instance.controller.abort();
[...root.attributes].forEach((a) => root.removeAttribute(a.name));
instance.attrs.forEach(([name, value]) => root.setAttribute(name, value));
root.innerHTML = instance.html;
});
document.addEventListener('kodza:init', (event) => init(event.detail?.root));
document.addEventListener('kodza:destroy', (event) => destroy(event.detail?.root));
init();
// Loaded in <head>, the script runs before the markup exists: once more when it is parsed
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => init(), { once: true });
})();
'use client';
// Kodza — Multi Select, for React. From https://kodza.dev/components/multi-select/
// 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: multi-select.css and multi-select.js, the component's CSS and JS from Kodza, as they are.
// Yours to edit: the markup is plain JSX below.
// The script changes this markup once React has rendered it (it adds layers, splits text, moves
// nodes), so React must not patch it afterwards. When a prop changes, the key gives React a fresh
// element: kodza:destroy stops the script on the old one, kodza:init starts it on the new one.
import { useRef, useEffect, type ComponentPropsWithoutRef, type ComponentRef, type CSSProperties } from 'react';
import './multi-select.css';
export type MultiSelectProps = Omit<ComponentPropsWithoutRef<'label'>, "children" | "vars"> & {
/** Settings, as CSS variables on the root */
vars?: {
/** default: #ffffff */
"--kz-multi-bg"?: string | number;
/** default: #0a0a0a */
"--kz-multi-fg"?: string | number;
/** default: rgba(10, 10, 10, 0.6) */
"--kz-multi-muted"?: string | number;
/** default: rgba(10, 10, 10, 0.14) */
"--kz-multi-line"?: string | number;
/** default: rgba(10, 10, 10, 0.3) */
"--kz-multi-line-hover"?: string | number;
/** default: rgba(10, 10, 10, 0.06) */
"--kz-multi-soft"?: string | number;
/** default: #0a0a0a */
"--kz-multi-ink"?: string | number;
/** default: #ffffff */
"--kz-multi-on-ink"?: string | number;
/** default: 0 18px 40px -16px rgba(10, 10, 10, 0.3) */
"--kz-multi-shadow"?: string | number;
/** default: 12px */
"--kz-multi-radius"?: string | number;
/** default: 16px */
"--kz-multi-pad"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-multi-ease"?: string | number;
};
className?: string;
};
export function MultiSelect({ vars, className, style, ...rest }: MultiSelectProps) {
const ref = useRef<ComponentRef<'label'>>(null);
const classes = ["kodza-multi", 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('./multi-select.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 (
<label key={key} {...rest} className={classes} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<span className="kodza-multi__label">Your stack</span>
<select className="kodza-multi__native" name="stack" multiple defaultValue="html">
<option value="html">HTML</option>
<option value="css">CSS</option>
<option value="js">JavaScript</option>
<option value="wordpress">WordPress</option>
<option value="webflow">Webflow</option>
<option value="astro">Astro</option>
<option value="php">PHP, proudly</option>
<option value="jquery" disabled>jQuery (we moved on)</option>
</select>
</label>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.