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 colour input with a proper panel: saturation square, hue slider, hex field, presets, eyedropper and copy. Pick a preset and the handles glide to it.
With your AI
Say it to your AI. It gets the exact code below, not its best guess, and knows where it goes.
Add the Kodza Color Picker to my theme settings form.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<label class="kodza-color">
<span class="kodza-color__label">Brand colour</span>
<input class="kodza-color__native" type="color" name="brand" value="#ff5c35">
</label>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Color Picker
A real colour input, dressed up: the field shows a swatch and the hex code, and a
panel opens with a saturation / brightness square, a hue slider, a hex field, presets, the
browser's eyedropper (where it exists) and a copy button. Pick a preset and the colour
travels there: both handles glide and the square turns with them. The real input stays in step.
The panel itself stays monochrome. Same box as Select.
Everything is tuned with the --kz-color-* variables. */
.kodza-color {
--kz-color-bg: #ffffff;
--kz-color-fg: #0a0a0a;
--kz-color-muted: rgba(10, 10, 10, 0.6);
--kz-color-line: rgba(10, 10, 10, 0.14);
--kz-color-line-hover: rgba(10, 10, 10, 0.3);
--kz-color-soft: rgba(10, 10, 10, 0.06);
--kz-color-shadow: 0 18px 40px -16px rgba(10, 10, 10, 0.3);
--kz-color-height: 56px;
--kz-color-radius: 12px;
--kz-color-pad: 16px;
--kz-color-ease: cubic-bezier(0.22, 1, 0.36, 1);
--kz-color-value: #ff5c35;
--kz-color-hue: #ff3300;
position: relative;
display: block;
width: 100%;
max-width: 280px;
/* The closed panel keeps its size, ready to unroll, but never lengthens the page */
contain: layout;
/* A number, not auto, so it can wait for the panel to roll up before dropping back */
z-index: 0;
transition: z-index 0s linear 0.4s;
color: var(--kz-color-fg);
}
/* Open, the field rises above what follows it (its panel included) */
.kodza-color.is-open {
z-index: 20;
transition-delay: 0s;
}
.kodza-color__label {
position: absolute;
top: 10px;
left: 52px;
z-index: 1;
color: var(--kz-color-muted);
font-size: 12px;
line-height: 1;
pointer-events: none;
}
.kodza-color:not(.is-ready) .kodza-color__label {
left: var(--kz-color-pad);
}
/* Without JS: the native colour input, in the box */
.kodza-color__native {
display: block;
width: 100%;
height: var(--kz-color-height);
padding: 26px var(--kz-color-pad) 8px;
border: 0;
border-radius: var(--kz-color-radius);
background: var(--kz-color-bg);
box-shadow: inset 0 0 0 1px var(--kz-color-line);
box-sizing: border-box;
cursor: pointer;
}
.kodza-color.is-ready .kodza-color__native {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
opacity: 0;
pointer-events: none;
clip-path: inset(50%);
}
.kodza-color__trigger {
display: flex;
align-items: flex-end;
gap: 12px;
width: 100%;
height: var(--kz-color-height);
padding: 0 var(--kz-color-pad) 9px 52px;
border: 0;
border-radius: var(--kz-color-radius);
background: var(--kz-color-bg);
box-shadow: inset 0 0 0 1px var(--kz-color-line);
color: inherit;
font: inherit;
font-size: 15px;
line-height: 22px;
text-align: left;
cursor: pointer;
transition: box-shadow 0.25s ease;
}
.kodza-color__trigger:hover {
box-shadow: inset 0 0 0 1px var(--kz-color-line-hover);
}
.kodza-color__trigger:focus-visible,
.kodza-color.is-open .kodza-color__trigger {
outline: none;
box-shadow: inset 0 0 0 1.5px var(--kz-color-fg), 0 0 0 4px var(--kz-color-soft);
}
/* The swatch blends into the new colour */
.kodza-color__swatch {
position: absolute;
top: 12px;
left: 12px;
width: 32px;
height: 32px;
border-radius: 8px;
background: var(--kz-color-value);
box-shadow: inset 0 0 0 1px var(--kz-color-line);
transition: background-color 0.35s ease;
}
.kodza-color__hex {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 14px;
letter-spacing: 0.02em;
text-transform: uppercase;
}
/* The panel */
.kodza-color__pop {
position: absolute;
top: calc(var(--kz-color-height) + 8px);
left: 0;
z-index: 20;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 12px;
width: 260px;
padding: 12px;
border-radius: 16px;
background: var(--kz-color-bg);
box-shadow: 0 0 0 1px var(--kz-color-line), var(--kz-color-shadow);
box-sizing: border-box;
visibility: hidden;
opacity: 0;
clip-path: inset(0 0 100% 0 round 16px);
transform: translateY(-4px);
transition:
clip-path 0.4s var(--kz-color-ease),
opacity 0.2s ease,
transform 0.4s var(--kz-color-ease),
visibility 0s linear 0.4s;
}
.kodza-color.is-open .kodza-color__pop {
visibility: visible;
opacity: 1;
clip-path: inset(-40px -40px -60px -40px round 16px);
transform: none;
transition-delay: 0s;
}
/* Not enough room below: it unrolls upwards, from the top of the field */
.kodza-color.is-above .kodza-color__pop {
top: auto;
bottom: calc(var(--kz-color-height) + 8px);
clip-path: inset(100% 0 0 0 round 16px);
transform: translateY(4px);
}
.kodza-color.is-above.is-open .kodza-color__pop {
clip-path: inset(-60px -40px -40px -40px round 16px);
transform: none;
}
/* Saturation (left to right) and brightness (bottom to top) over the hue */
.kodza-color__area {
position: relative;
height: 150px;
border-radius: 10px;
background:
linear-gradient(to top, #000, transparent),
linear-gradient(to right, #fff, var(--kz-color-hue));
cursor: crosshair;
touch-action: none;
}
.kodza-color__area:focus-visible,
.kodza-color__hue:focus-visible {
outline: 2px solid var(--kz-color-fg);
outline-offset: 2px;
}
.kodza-color__handle {
position: absolute;
top: 0;
left: 0;
width: 18px;
height: 18px;
margin: -9px 0 0 -9px;
border-radius: 50%;
background: var(--kz-color-value);
box-shadow: 0 0 0 2.5px #ffffff, 0 1px 6px rgba(10, 10, 10, 0.4);
pointer-events: none;
/* translate, not transform: scale then grows the handle in place instead of
stretching its offset (it would drift away from the pointer while dragging) */
translate: var(--kz-x, 0) var(--kz-y, 0);
transition: scale 0.2s var(--kz-color-ease);
}
.kodza-color.is-dragging .kodza-color__handle {
scale: 1.25;
}
/* The hue: a real range input, restyled */
.kodza-color__hue {
width: 100%;
height: 14px;
margin: 0;
border-radius: 999px;
background: linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00);
cursor: pointer;
appearance: none;
}
.kodza-color__hue::-webkit-slider-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--kz-color-hue);
box-shadow: 0 0 0 2.5px #ffffff, 0 1px 6px rgba(10, 10, 10, 0.4);
appearance: none;
}
.kodza-color__hue::-moz-range-thumb {
width: 18px;
height: 18px;
border: 0;
border-radius: 50%;
background: var(--kz-color-hue);
box-shadow: 0 0 0 2.5px #ffffff, 0 1px 6px rgba(10, 10, 10, 0.4);
}
.kodza-color__row {
display: flex;
align-items: center;
gap: 6px;
}
.kodza-color__input {
box-sizing: border-box;
flex: 1;
min-width: 0;
height: 34px;
padding: 0 10px;
border: 0;
border-radius: 8px;
background: var(--kz-color-soft);
color: inherit;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 13px;
text-transform: uppercase;
outline: none;
}
.kodza-color__input:focus-visible {
box-shadow: inset 0 0 0 1.5px var(--kz-color-fg);
}
.kodza-color__tool {
position: relative;
display: grid;
place-items: center;
width: 34px;
height: 34px;
padding: 0;
border: 0;
border-radius: 8px;
background: var(--kz-color-soft);
color: inherit;
cursor: pointer;
transition: background-color 0.2s ease;
}
.kodza-color__tool:hover {
background: var(--kz-color-line);
}
.kodza-color__tool:focus-visible,
.kodza-color__preset:focus-visible {
outline: 2px solid var(--kz-color-fg);
outline-offset: 2px;
}
.kodza-color__tool svg {
grid-area: 1 / 1;
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
transition: opacity 0.2s ease, transform 0.3s var(--kz-color-ease);
}
/* Copied: the copy icon gives way to a tick */
.kodza-color__tool .kodza-color__done {
opacity: 0;
transform: scale(0.6);
}
.kodza-color__tool.is-done .kodza-color__done {
opacity: 1;
transform: none;
}
.kodza-color__tool.is-done .kodza-color__idle {
opacity: 0;
transform: scale(0.6);
}
/* minmax(0, 1fr): an empty button has a minimum width of its own, and eight of them would
push the row (and everything above it) past the panel's edge */
.kodza-color__presets {
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
gap: 6px;
}
.kodza-color__preset {
aspect-ratio: 1;
padding: 0;
border: 0;
border-radius: 6px;
box-shadow: inset 0 0 0 1px var(--kz-color-line);
cursor: pointer;
transition: transform 0.2s var(--kz-color-ease);
}
.kodza-color__preset:hover {
transform: scale(1.12);
}
.kodza-color__preset[aria-pressed="true"] {
box-shadow: inset 0 0 0 1px var(--kz-color-line), 0 0 0 2px var(--kz-color-bg), 0 0 0 3.5px var(--kz-color-fg);
}
@media (prefers-reduced-motion: reduce) {
.kodza-color__trigger,
.kodza-color__swatch,
.kodza-color__handle,
.kodza-color__pop,
.kodza-color__tool svg,
.kodza-color__preset {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
}
}
/* Theming example — on a dark background:
.dark-section .kodza-color {
--kz-color-bg: #161616;
--kz-color-fg: #f2f2f0;
--kz-color-muted: rgba(242, 242, 240, 0.62);
--kz-color-line: rgba(255, 255, 255, 0.14);
--kz-color-line-hover: rgba(255, 255, 255, 0.3);
--kz-color-soft: rgba(255, 255, 255, 0.08);
--kz-color-shadow: 0 18px 40px -16px rgba(0, 0, 0, 0.8);
}
*/
/* kodza-guard daf21f55ce9a — 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-color__native):not(#_) {
position: revert;
}
:where(.kodza-color__trigger):not(#_) {
outline: revert;
}
:where(.kodza-color__area):not(#_),
:where(.kodza-color__hue):not(#_),
:where(.kodza-color__tool):not(#_),
:where(.kodza-color__preset):not(#_) {
outline: revert;
outline-offset: revert;
}
:where(.kodza-color__input):not(#_) {
box-shadow: revert;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-color: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-color__label:not(#_),
.kodza-color__swatch:not(#_),
.kodza-color__hex:not(#_),
.kodza-color__handle:not(#_),
.kodza-color__row:not(#_),
.kodza-color__presets:not(#_) {
box-sizing: revert;
}
.kodza-color__native:not(#_) {
margin: revert;
outline: revert;
text-decoration: revert;
transition: 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;
white-space: revert;
min-height: revert;
}
.kodza-color__trigger:not(#_) {
margin: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
white-space: revert;
min-height: revert;
}
.kodza-color__area:not(#_) {
box-sizing: revert;
box-shadow: revert;
}
.kodza-color__hue:not(#_) {
border: revert;
padding: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
box-shadow: 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;
white-space: revert;
min-height: revert;
}
.kodza-color__input:not(#_) {
margin: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
float: revert;
position: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
white-space: revert;
min-height: revert;
}
.kodza-color__tool:not(#_) {
margin: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: 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-color__idle:not(#_),
.kodza-color__done:not(#_),
.kodza-color__tool 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-color__preset:not(#_) {
margin: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: 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;
white-space: revert;
min-height: revert;
}
.kodza-color__hue:not(#_):where(:not([hidden])),
.kodza-color__input:not(#_):where(:not([hidden])),
.kodza-color__idle:not(#_):where(:not([hidden])),
.kodza-color__done:not(#_):where(:not([hidden])),
.kodza-color__preset:not(#_):where(:not([hidden])),
.kodza-color__tool svg:where(:not([class*="kodza-"])):not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-color__hue:not(#_):where([hidden]),
.kodza-color__input:not(#_):where([hidden]),
.kodza-color__idle:not(#_):where([hidden]),
.kodza-color__done:not(#_):where([hidden]),
.kodza-color__preset:not(#_):where([hidden]),
.kodza-color__tool svg:where(:not([class*="kodza-"])):not(#_):where([hidden]) {
display: none;
}
/* 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-color:not(#_) {
position: relative;
display: block;
transition: z-index 0s linear 0.4s;
color: var(--kz-color-fg);
}
.kodza-color:not(#_) {
width: min(100%, 280px);
}
.kodza-color.is-open:not(#_) {
transition-delay: 0s;
}
.kodza-color__native:not(#_) {
display: block;
width: 100%;
height: var(--kz-color-height);
padding: 26px var(--kz-color-pad) 8px;
border: 0;
border-radius: var(--kz-color-radius);
background: var(--kz-color-bg);
box-shadow: inset 0 0 0 1px var(--kz-color-line);
box-sizing: border-box;
cursor: pointer;
}
.kodza-color.is-ready .kodza-color__native:not(#_) {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
opacity: 0;
pointer-events: none;
clip-path: inset(50%);
}
.kodza-color__trigger:not(#_) {
display: flex;
align-items: flex-end;
gap: 12px;
width: 100%;
height: var(--kz-color-height);
padding: 0 var(--kz-color-pad) 9px 52px;
border: 0;
border-radius: var(--kz-color-radius);
background: var(--kz-color-bg);
box-shadow: inset 0 0 0 1px var(--kz-color-line);
color: inherit;
font: inherit;
font-size: 15px;
line-height: 22px;
text-align: left;
cursor: pointer;
transition: box-shadow 0.25s ease;
}
.kodza-color__trigger:hover:not(#_) {
box-shadow: inset 0 0 0 1px var(--kz-color-line-hover);
}
.kodza-color__trigger:focus-visible:not(#_),
.kodza-color.is-open .kodza-color__trigger:not(#_) {
outline: none;
box-shadow: inset 0 0 0 1.5px var(--kz-color-fg), 0 0 0 4px var(--kz-color-soft);
}
.kodza-color__area:focus-visible:not(#_) {
outline: 2px solid var(--kz-color-fg);
outline-offset: 2px;
}
.kodza-color__hue:focus-visible:not(#_) {
outline: 2px solid var(--kz-color-fg);
outline-offset: 2px;
}
.kodza-color__hue:not(#_) {
width: 100%;
height: 14px;
margin: 0;
border-radius: 999px;
background: linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00);
cursor: pointer;
appearance: none;
}
.kodza-color__hue:not(#_)::-webkit-slider-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--kz-color-hue);
box-shadow: 0 0 0 2.5px #ffffff, 0 1px 6px rgba(10, 10, 10, 0.4);
appearance: none;
}
.kodza-color__input:not(#_) {
box-sizing: border-box;
flex: 1;
min-width: 0;
height: 34px;
padding: 0 10px;
border: 0;
border-radius: 8px;
background: var(--kz-color-soft);
color: inherit;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 13px;
text-transform: uppercase;
outline: none;
}
.kodza-color__input:focus-visible:not(#_) {
box-shadow: inset 0 0 0 1.5px var(--kz-color-fg);
}
.kodza-color__tool:not(#_) {
position: relative;
display: grid;
place-items: center;
width: 34px;
height: 34px;
padding: 0;
border: 0;
border-radius: 8px;
background: var(--kz-color-soft);
color: inherit;
cursor: pointer;
transition: background-color 0.2s ease;
}
.kodza-color__tool:hover:not(#_) {
background: var(--kz-color-line);
}
.kodza-color__tool:focus-visible:not(#_),
.kodza-color__preset:focus-visible:not(#_) {
outline: 2px solid var(--kz-color-fg);
outline-offset: 2px;
}
.kodza-color__tool svg:not(#_) {
transition: opacity 0.2s ease, transform 0.3s var(--kz-color-ease);
}
.kodza-color__preset:not(#_) {
aspect-ratio: 1;
padding: 0;
border: 0;
border-radius: 6px;
box-shadow: inset 0 0 0 1px var(--kz-color-line);
cursor: pointer;
transition: transform 0.2s var(--kz-color-ease);
}
.kodza-color__preset:hover:not(#_) {
transform: scale(1.12);
}
.kodza-color__preset[aria-pressed="true"]:not(#_) {
box-shadow: inset 0 0 0 1px var(--kz-color-line), 0 0 0 2px var(--kz-color-bg), 0 0 0 3.5px var(--kz-color-fg);
}
@media (prefers-reduced-motion: reduce) {
.kodza-color__trigger:not(#_),
.kodza-color__preset:not(#_) {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
}
}
@media (prefers-reduced-motion: reduce) {
.kodza-color__tool 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 — Color Picker
// Builds a field and a panel over the real <input type="color"> and keeps it in step (firing
// "input" while you drag and "change" when you let go). The square sets saturation and
// brightness (drag, or arrows: Shift for bigger steps), the slider sets the hue, the hex field
// takes any #rgb or #rrggbb. Presets come from data-presets (comma-separated hex codes).
(() => {
const SELECTOR = '.kodza-color';
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const PRESETS = ['#0a0a0a', '#f2f2f0', '#ff5c35', '#f5b700', '#12b76a', '#14b8a6', '#2f6bff', '#a855f7'];
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const hexToRgb = (hex) => {
let h = hex.replace('#', '').trim();
if (h.length === 3) h = [...h].map((c) => c + c).join('');
if (!/^[0-9a-f]{6}$/i.test(h)) return null;
return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
};
const rgbToHex = (rgb) => `#${rgb.map((v) => Math.round(v).toString(16).padStart(2, '0')).join('')}`;
const rgbToHsv = ([r, g, b]) => {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b);
const d = max - Math.min(r, g, b);
let h = 0;
if (d) h = max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
return [(h * 60 + 360) % 360, max ? d / max : 0, max];
};
const hsvToRgb = ([h, s, v]) => {
const f = (n) => {
const k = (n + h / 60) % 6;
return (v - v * s * clamp(Math.min(k, 4 - k), 0, 1)) * 255;
};
return [f(5), f(3), f(1)];
};
const copy = '<svg class="kodza-color__idle" viewBox="0 0 16 16" aria-hidden="true"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5"/><path d="M10.5 5.5V3.8c0-.7-.6-1.3-1.3-1.3H3.8c-.7 0-1.3.6-1.3 1.3v5.4c0 .7.6 1.3 1.3 1.3h1.7"/></svg><svg class="kodza-color__done" viewBox="0 0 16 16" aria-hidden="true"><path d="M3.5 8.5l3 3 6-7"/></svg>';
const dropper = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M10.5 2.5l3 3-1.5 1.5-3-3zM9 4l3 3-6 6H3v-3z"/></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-color__native');
const label = root.querySelector('.kodza-color__label');
if (!native) return;
const id = `kodza-color-${++uid}`;
const presets = (root.dataset.presets || '').split(',').map((s) => s.trim()).filter((s) => hexToRgb(s));
const trigger = document.createElement('button');
trigger.type = 'button';
trigger.className = 'kodza-color__trigger';
trigger.setAttribute('aria-haspopup', 'dialog');
trigger.setAttribute('aria-expanded', 'false');
if (label) {
label.id = `${id}-label`;
trigger.setAttribute('aria-labelledby', `${id}-label ${id}-hex`);
}
trigger.innerHTML = `<span class="kodza-color__swatch" aria-hidden="true"></span><span class="kodza-color__hex" id="${id}-hex"></span>`;
const pop = document.createElement('div');
pop.className = 'kodza-color__pop';
pop.setAttribute('role', 'dialog');
pop.setAttribute('aria-label', label ? `${label.textContent}: pick a colour` : 'Pick a colour');
pop.innerHTML = `
<div class="kodza-color__area" tabindex="0" role="slider" aria-valuemin="0" aria-valuemax="100" aria-label="Saturation and brightness"><span class="kodza-color__handle"></span></div>
<input class="kodza-color__hue" type="range" min="0" max="359" step="1" aria-label="Hue">
<div class="kodza-color__row">
<input class="kodza-color__input" type="text" maxlength="7" spellcheck="false" autocomplete="off" aria-label="Hex code">
${'EyeDropper' in window ? `<button class="kodza-color__tool" type="button" data-tool="drop" aria-label="Pick a colour from the screen">${dropper}</button>` : ''}
<button class="kodza-color__tool" type="button" data-tool="copy" aria-label="Copy the hex code">${copy}</button>
</div>
<div class="kodza-color__presets"></div>`;
native.after(trigger, pop);
native.tabIndex = -1;
native.setAttribute('aria-hidden', 'true');
const area = pop.querySelector('.kodza-color__area');
const handle = pop.querySelector('.kodza-color__handle');
const hue = pop.querySelector('.kodza-color__hue');
const input = pop.querySelector('.kodza-color__input');
const hexText = trigger.querySelector('.kodza-color__hex');
const presetBox = pop.querySelector('.kodza-color__presets');
const presetButtons = (presets.length ? presets : PRESETS).map((hex) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'kodza-color__preset';
button.style.background = hex;
button.dataset.hex = hex.toLowerCase();
button.setAttribute('aria-label', hex);
presetBox.append(button);
return button;
});
let hsv = rgbToHsv(hexToRgb(native.value) || [0, 0, 0]);
// Everything follows the current colour
const paint = ({ fromInput = false } = {}) => {
const hex = rgbToHex(hsvToRgb(hsv));
root.style.setProperty('--kz-color-value', hex);
root.style.setProperty('--kz-color-hue', rgbToHex(hsvToRgb([hsv[0], 1, 1])));
handle.style.setProperty('--kz-x', `${hsv[1] * area.clientWidth}px`);
handle.style.setProperty('--kz-y', `${(1 - hsv[2]) * area.clientHeight}px`);
hue.value = Math.round(hsv[0]);
hexText.textContent = hex;
if (!fromInput) input.value = hex;
area.setAttribute('aria-valuenow', Math.round(hsv[2] * 100));
area.setAttribute('aria-valuetext', `Saturation ${Math.round(hsv[1] * 100)}%, brightness ${Math.round(hsv[2] * 100)}%`);
presetButtons.forEach((button) => button.setAttribute('aria-pressed', String(button.dataset.hex === hex)));
return hex;
};
const commit = (kind) => {
native.value = rgbToHex(hsvToRgb(hsv));
native.dispatchEvent(new Event(kind, { bubbles: true }));
};
// A preset or a typed code: the colour itself travels there (hue by the shortest way), so
// both handles and the square glide together instead of jumping
let glide = 0;
const glideTo = (hex) => {
const rgb = hexToRgb(hex);
if (!rgb) return;
const next = rgbToHsv(rgb);
// Greys keep the current hue, so the hue handle does not fly away for nothing
if (next[1] === 0) next[0] = hsv[0];
cancelAnimationFrame(glide);
const from = [...hsv];
const turn = ((next[0] - from[0] + 540) % 360) - 180;
const done = () => {
hsv = next;
paint();
commit('input');
commit('change');
};
if (reduceMotion) return done();
const start = performance.now();
const step = (now) => {
if (signal.aborted) return;
const t = Math.min(1, (now - start) / 450);
const e = 1 - Math.pow(1 - t, 3);
hsv = [(from[0] + turn * e + 360) % 360, from[1] + (next[1] - from[1]) * e, from[2] + (next[2] - from[2]) * e];
paint();
if (t < 1) glide = requestAnimationFrame(step);
else done();
};
glide = requestAnimationFrame(step);
};
// Dragging in the square
const fromPointer = (event) => {
const box = area.getBoundingClientRect();
hsv = [hsv[0], clamp((event.clientX - box.left) / box.width, 0, 1), 1 - clamp((event.clientY - box.top) / box.height, 0, 1)];
paint();
commit('input');
};
area.addEventListener('pointerdown', (event) => {
area.setPointerCapture(event.pointerId);
root.classList.add('is-dragging');
cancelAnimationFrame(glide);
fromPointer(event);
}, { signal });
area.addEventListener('pointermove', (event) => area.hasPointerCapture(event.pointerId) && fromPointer(event), { signal });
const stop = (event) => {
if (!area.hasPointerCapture(event.pointerId)) return;
area.releasePointerCapture(event.pointerId);
root.classList.remove('is-dragging');
commit('change');
};
area.addEventListener('pointerup', stop, { signal });
area.addEventListener('pointercancel', stop, { signal });
area.addEventListener('keydown', (event) => {
const step = event.shiftKey ? 0.1 : 0.01;
const moves = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, step], ArrowDown: [0, -step] };
if (!moves[event.key]) return;
event.preventDefault();
hsv = [hsv[0], clamp(hsv[1] + moves[event.key][0], 0, 1), clamp(hsv[2] + moves[event.key][1], 0, 1)];
paint();
commit('input');
commit('change');
}, { signal });
hue.addEventListener('input', () => {
hsv = [Number(hue.value), hsv[1], hsv[2]];
paint();
commit('input');
}, { signal });
hue.addEventListener('change', () => commit('change'), { signal });
input.addEventListener('input', () => {
const value = input.value.startsWith('#') ? input.value : `#${input.value}`;
if (hexToRgb(value) && (value.length === 4 || value.length === 7)) {
hsv = rgbToHsv(hexToRgb(value));
paint({ fromInput: true });
commit('input');
}
}, { signal });
input.addEventListener('change', () => {
paint();
commit('change');
}, { signal });
presetButtons.forEach((button) => button.addEventListener('click', () => glideTo(button.dataset.hex), { signal }));
pop.querySelector('[data-tool="copy"]')?.addEventListener('click', async (event) => {
const button = event.currentTarget;
try {
await navigator.clipboard.writeText(rgbToHex(hsvToRgb(hsv)));
button.classList.add('is-done');
button.setAttribute('aria-label', 'Copied');
setTimeout(() => {
button.classList.remove('is-done');
button.setAttribute('aria-label', 'Copy the hex code');
}, 1400);
} catch {
input.select();
}
}, { signal });
pop.querySelector('[data-tool="drop"]')?.addEventListener('click', async () => {
try {
const { sRGBHex } = await new window.EyeDropper().open({ signal });
glideTo(sRGBHex);
} catch {
/* cancelled */
}
}, { signal });
// Opening and closing
const isOpen = () => root.classList.contains('is-open');
// Opened from the keyboard, the focus goes into the panel; with a click, it stays put
let viaKeyboard = false;
trigger.addEventListener('keydown', (event) => (viaKeyboard = event.key === 'Enter' || event.key === ' '), { signal });
trigger.addEventListener('pointerdown', () => (viaKeyboard = false), { signal });
const open = () => {
// Not enough room below and enough above: the panel opens upwards. A closed panel that
// changes side gets there at once, so it always unrolls from the field
const box = trigger.getBoundingClientRect();
const room = pop.offsetHeight + 16;
const above = box.bottom + room > innerHeight && box.top > room;
if (above !== root.classList.contains('is-above')) {
pop.style.transition = 'none';
root.classList.toggle('is-above', above);
void pop.offsetWidth;
pop.style.transition = '';
}
root.classList.add('is-open');
trigger.setAttribute('aria-expanded', 'true');
paint();
if (viaKeyboard) requestAnimationFrame(() => area.focus({ preventScroll: true }));
};
const close = (refocus = true) => {
if (!isOpen()) return;
root.classList.remove('is-open');
trigger.setAttribute('aria-expanded', 'false');
if (refocus) trigger.focus({ preventScroll: true });
};
trigger.addEventListener('click', () => (isOpen() ? close() : open()), { signal });
// Escape closes it from the field too (opened with a click, the focus stays there)
root.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || !isOpen()) return;
event.preventDefault();
close();
}, { signal });
root.addEventListener('click', (event) => {
if (trigger.contains(event.target) || pop.contains(event.target)) return;
event.preventDefault();
trigger.focus();
}, { signal });
pop.addEventListener('click', (event) => event.target.closest('button') && event.preventDefault(), { signal });
document.addEventListener('pointerdown', (event) => !root.contains(event.target) && close(false), { signal });
root.addEventListener('focusout', (event) => {
if (isOpen() && event.relatedTarget && !root.contains(event.relatedTarget)) close(false);
}, { signal });
native.addEventListener('change', () => {
const rgb = hexToRgb(native.value);
if (rgb && native.value.toLowerCase() !== rgbToHex(hsvToRgb(hsv))) glideTo(native.value);
}, { signal });
paint();
}
// 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 — Color Picker, for React. From https://kodza.dev/components/color-picker/
// 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: color-picker.css and color-picker.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 './color-picker.css';
export type ColorPickerProps = Omit<ComponentPropsWithoutRef<'label'>, "children" | "vars"> & {
/** Settings, as CSS variables on the root */
vars?: {
/** default: #ffffff */
"--kz-color-bg"?: string | number;
/** default: #0a0a0a */
"--kz-color-fg"?: string | number;
/** default: rgba(10, 10, 10, 0.6) */
"--kz-color-muted"?: string | number;
/** default: rgba(10, 10, 10, 0.14) */
"--kz-color-line"?: string | number;
/** default: rgba(10, 10, 10, 0.3) */
"--kz-color-line-hover"?: string | number;
/** default: rgba(10, 10, 10, 0.06) */
"--kz-color-soft"?: string | number;
/** default: 0 18px 40px -16px rgba(10, 10, 10, 0.3) */
"--kz-color-shadow"?: string | number;
/** default: 56px */
"--kz-color-height"?: string | number;
/** default: 12px */
"--kz-color-radius"?: string | number;
/** default: 16px */
"--kz-color-pad"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-color-ease"?: string | number;
};
/** The text, plain: the script reads it and rebuilds the markup around it */
children?: string;
className?: string;
};
export function ColorPicker({ vars, children = "Brand colour", className, style, ...rest }: ColorPickerProps) {
const ref = useRef<ComponentRef<'label'>>(null);
const classes = ["kodza-color", 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, children, 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('./color-picker.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-color__label">{children}</span>
{' '}
<input className="kodza-color__native" type="color" name="brand" defaultValue="#ff5c35" />
</label>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.