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 codeText that becomes its own field when clicked: the caret lands under the pointer, Enter saves with a check, Escape rolls the old text back in. A real input or textarea underneath, with a kodza:save event.
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 Inline Edit to my page.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<div class="kodza-inline-edit kodza-inline-edit--title">
<input class="kodza-inline-edit__native" type="text" name="project" value="Shoreline redesign" aria-label="Project name" required maxlength="60" autocomplete="off">
</div>
<div class="kodza-inline-edit">
<span class="kodza-inline-edit__label">Brief</span>
<textarea class="kodza-inline-edit__native" name="brief" rows="1" placeholder="Add a brief. One line beats a meeting.">Ship the landing page by Friday. Nothing else moves until it's live.</textarea>
</div>
<div class="kodza-inline-edit">
<span class="kodza-inline-edit__label">Address</span>
<span class="kodza-inline-edit__prefix">kodza.dev/</span>
<input class="kodza-inline-edit__native" type="text" name="handle" value="le-havre-studio" required pattern="[a-z0-9\-]+" spellcheck="false" autocomplete="off" data-error="Lowercase letters, digits and dashes only.">
<p class="kodza-inline-edit__note">
<span class="kodza-inline-edit__hint">Old links keep working.</span>
<span class="kodza-inline-edit__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 — Inline Edit
Text that turns into its own field when clicked. The box grows out from the text under the
pointer, its outline draws itself once you are in, Enter saves (a check draws itself next to
the text) and Escape rolls the old text back in. The box reaches past the text instead of
pushing it, so the text never moves on its column. The real input or textarea stays in the
markup for the form, and a multi-line one grows as you type.
Everything is tuned with the --kz-inline-edit-* variables. Its width, --kz-inline-edit-width,
falls back on --kz-field-width, shared by every Kodza form field. Errors are monochrome by
default: an icon and a message, never colour alone (set --kz-inline-edit-error to use a red).
Dark theme, for example:
.your-dark-section .kodza-inline-edit {
--kz-inline-edit-fg: #f2f2f0;
--kz-inline-edit-muted: rgba(242, 242, 240, 0.62);
--kz-inline-edit-hover: rgba(255, 255, 255, 0.07);
--kz-inline-edit-bg: #161616;
--kz-inline-edit-focus: #f2f2f0;
--kz-inline-edit-halo: rgba(255, 255, 255, 0.08);
--kz-inline-edit-error: #f2f2f0;
} */
.kodza-inline-edit {
--kz-inline-edit-fg: #0a0a0a;
--kz-inline-edit-muted: rgba(10, 10, 10, 0.6); /* label, prefix, placeholder, hint */
--kz-inline-edit-hover: rgba(10, 10, 10, 0.05); /* the box under the pointer */
--kz-inline-edit-bg: #ffffff; /* the box while editing */
--kz-inline-edit-focus: #0a0a0a; /* its outline */
--kz-inline-edit-halo: rgba(10, 10, 10, 0.06);
--kz-inline-edit-error: #0a0a0a;
--kz-inline-edit-width: var(--kz-field-width, 420px);
--kz-inline-edit-size: 15px;
--kz-inline-edit-leading: 1.55;
--kz-inline-edit-radius: calc(8px * var(--kz-radius-scale, 1));
--kz-inline-edit-reach-y: max(6px, 0.25em); /* how far the box reaches past the text, up and down */
--kz-inline-edit-reach-x: max(10px, 0.4em); /* and sideways */
--kz-inline-edit-duration: 0.45s; /* the outline drawing itself; the box and the note follow it. 0s = no motion */
--kz-inline-edit-ease: cubic-bezier(0.22, 1, 0.36, 1);
display: grid;
gap: 10px;
width: 100%;
max-width: var(--kz-inline-edit-width);
color: var(--kz-inline-edit-fg);
font-size: var(--kz-inline-edit-size);
line-height: var(--kz-inline-edit-leading);
}
/* The title variant: the page's display face, in large */
.kodza-inline-edit--title {
--kz-inline-edit-leading: 1.2;
font-family: var(--kz-font-display, inherit);
font-size: calc(28px * var(--kz-font-display-scale, 1));
font-weight: var(--kz-font-display-weight, 600);
letter-spacing: var(--kz-font-display-tracking, -0.03em);
text-transform: var(--kz-font-display-case, inherit);
}
.kodza-inline-edit__label {
display: block;
color: var(--kz-inline-edit-muted);
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 13px);
font-weight: var(--kz-font-label-weight, 500);
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);
}
/* The field itself, before the script runs: plain text with no border */
.kodza-inline-edit__native {
box-sizing: border-box;
width: 100%;
margin: 0;
padding: 0;
border: 0;
border-radius: 0;
background: none;
box-shadow: none;
color: inherit;
font: inherit;
letter-spacing: inherit;
line-height: inherit;
text-transform: inherit;
caret-color: var(--kz-inline-edit-focus);
outline: none;
resize: none;
overflow: hidden;
appearance: none;
}
.kodza-inline-edit__native::placeholder {
color: var(--kz-inline-edit-muted);
opacity: 1;
}
.kodza-inline-edit__prefix {
flex: none;
color: var(--kz-inline-edit-muted);
white-space: pre;
}
/* The row: the box, then the check that comes after a save */
.kodza-inline-edit__row {
display: flex;
align-items: flex-start;
min-width: 0;
}
.kodza-inline-edit__box {
position: relative;
z-index: 0;
display: inline-flex;
align-items: flex-start;
flex: 0 1 auto;
min-width: 0;
cursor: text;
}
.kodza-inline-edit.is-multiline .kodza-inline-edit__box {
flex: 1 1 auto;
}
/* The whole box takes the pointer, past the text too */
.kodza-inline-edit__box::before {
content: "";
position: absolute;
z-index: -1;
inset: calc(var(--kz-inline-edit-reach-y) * -1) calc(var(--kz-inline-edit-reach-x) * -1);
border-radius: var(--kz-inline-edit-radius);
box-shadow: inset 0 0 0 1.5px transparent;
transition: box-shadow calc(var(--kz-inline-edit-duration) * 0.45 * var(--kz-motion-scale, 1)) ease;
}
/* The box: two layers that reach past the text, behind it */
.kodza-inline-edit__fill,
.kodza-inline-edit__ring {
position: absolute;
z-index: -1;
inset: calc(var(--kz-inline-edit-reach-y) * -1) calc(var(--kz-inline-edit-reach-x) * -1);
border-radius: var(--kz-inline-edit-radius);
pointer-events: none;
}
.kodza-inline-edit__fill {
background: var(--kz-inline-edit-hover);
opacity: 0;
clip-path: inset(var(--kz-inline-edit-reach-y) var(--kz-inline-edit-reach-x) round var(--kz-inline-edit-radius));
transition:
opacity calc(var(--kz-inline-edit-duration) * 0.45 * var(--kz-motion-scale, 1)) ease,
clip-path calc(var(--kz-inline-edit-duration) * 0.8 * var(--kz-motion-scale, 1)) var(--kz-inline-edit-ease),
background-color calc(var(--kz-inline-edit-duration) * 0.45 * var(--kz-motion-scale, 1)) ease,
box-shadow calc(var(--kz-inline-edit-duration) * 0.45 * var(--kz-motion-scale, 1)) ease;
}
/* The outline: cut down to its left edge at rest, drawn around by the script */
.kodza-inline-edit__ring {
box-shadow: inset 0 0 0 1.5px var(--kz-inline-edit-focus), 0 0 0 4px var(--kz-inline-edit-halo);
clip-path: inset(-6px calc(100% + 6px) -6px -6px);
}
/* The text: a button that holds a mirror of the value, which gives the box its size */
.kodza-inline-edit__cell {
position: relative;
display: inline-grid;
grid-template-columns: minmax(0, 1fr);
flex: 0 1 auto;
min-width: 0;
}
.kodza-inline-edit.is-multiline .kodza-inline-edit__cell {
flex: 1 1 auto;
}
.kodza-inline-edit__view {
grid-area: 1 / 1;
display: block;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
border-radius: 0;
background: none;
box-shadow: none;
color: inherit;
font: inherit;
letter-spacing: inherit;
line-height: inherit;
text-align: left;
text-transform: inherit;
cursor: text;
overflow: hidden;
appearance: none;
}
.kodza-inline-edit__view:focus {
outline: none;
}
.kodza-inline-edit__text {
display: block;
overflow: hidden;
white-space: pre;
text-overflow: ellipsis;
}
.kodza-inline-edit.is-multiline .kodza-inline-edit__text {
white-space: pre-wrap;
overflow-wrap: break-word;
text-overflow: clip;
}
.kodza-inline-edit.is-empty .kodza-inline-edit__view {
color: var(--kz-inline-edit-muted);
}
/* Once the script runs, the field sits on the mirror, hidden until you edit */
.kodza-inline-edit.is-ready .kodza-inline-edit__native {
position: absolute;
top: 0;
left: 0;
width: calc(100% + 2px);
height: 100%;
visibility: hidden;
}
.kodza-inline-edit.is-multiline .kodza-inline-edit__native {
width: 100%;
white-space: pre-wrap;
overflow-wrap: break-word;
}
.kodza-inline-edit.is-editing .kodza-inline-edit__native {
visibility: visible;
}
.kodza-inline-edit.is-editing .kodza-inline-edit__view {
visibility: hidden;
}
/* Escape: the typed text rolls up and out of the box while the old one comes in under it */
.kodza-inline-edit.is-rolling .kodza-inline-edit__cell {
overflow: clip;
}
.kodza-inline-edit__ghost {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
}
/* Under the pointer, the box grows out from the text */
@media (hover: hover) {
.kodza-inline-edit:not(.is-disabled) .kodza-inline-edit__box:hover .kodza-inline-edit__fill {
opacity: 1;
clip-path: inset(0 round var(--kz-inline-edit-radius));
}
}
/* From the keyboard: the same box, with a line */
.kodza-inline-edit__box:has(.kodza-inline-edit__view:focus-visible) .kodza-inline-edit__fill {
opacity: 1;
clip-path: inset(0 round var(--kz-inline-edit-radius));
}
.kodza-inline-edit__box:has(.kodza-inline-edit__view:focus-visible)::before {
box-shadow: inset 0 0 0 1.5px var(--kz-inline-edit-focus);
}
/* Editing: the box becomes the field's own */
.kodza-inline-edit.is-editing .kodza-inline-edit__fill {
opacity: 1;
clip-path: inset(0 round var(--kz-inline-edit-radius));
background: var(--kz-inline-edit-bg);
box-shadow: none;
}
.kodza-inline-edit.is-editing .kodza-inline-edit__ring {
clip-path: inset(-6px);
}
.kodza-inline-edit:has(.kodza-inline-edit__native[aria-invalid="true"]) .kodza-inline-edit__ring {
box-shadow: inset 0 0 0 1.5px var(--kz-inline-edit-error), 0 0 0 4px var(--kz-inline-edit-halo);
}
/* The check, drawn by the script after a save, then undrawn from its start */
.kodza-inline-edit__check {
flex: none;
width: 16px;
height: 16px;
margin: calc(var(--kz-inline-edit-leading) * 0.5em - 8px) 0 0 calc(var(--kz-inline-edit-reach-x) + 6px);
fill: none;
stroke: var(--kz-inline-edit-fg);
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 1;
stroke-dashoffset: 1;
}
/* The note: the hint and the error share one line, and swap */
.kodza-inline-edit__note {
display: grid;
margin: 0;
padding: 0;
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-inline-edit__hint,
.kodza-inline-edit__error {
grid-area: 1 / 1;
transition:
opacity calc(var(--kz-inline-edit-duration) * 0.55 * var(--kz-motion-scale, 1)) ease,
transform calc(var(--kz-inline-edit-duration) * 0.9 * var(--kz-motion-scale, 1)) var(--kz-inline-edit-ease);
}
.kodza-inline-edit__hint {
color: var(--kz-inline-edit-muted);
}
.kodza-inline-edit__error {
display: flex;
align-items: center;
gap: 6px;
color: var(--kz-inline-edit-error);
font-weight: var(--kz-font-label-weight, 500);
opacity: 0;
transform: translateY(100%);
}
.kodza-inline-edit__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-inline-edit:has(.kodza-inline-edit__native[aria-invalid="true"]) .kodza-inline-edit__hint {
opacity: 0;
transform: translateY(-100%);
}
.kodza-inline-edit:has(.kodza-inline-edit__native[aria-invalid="true"]) .kodza-inline-edit__error {
opacity: 1;
transform: none;
}
/* Disabled: plain text, no box */
.kodza-inline-edit.is-disabled {
color: var(--kz-inline-edit-muted);
}
.kodza-inline-edit.is-disabled .kodza-inline-edit__box,
.kodza-inline-edit.is-disabled .kodza-inline-edit__view {
cursor: default;
}
@media (prefers-reduced-motion: reduce) {
.kodza-inline-edit *,
.kodza-inline-edit *::before,
.kodza-inline-edit *::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}
/* kodza-guard b5e4ca197c01 — 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-inline-edit__view):not(#_) {
outline: revert;
transition-duration: revert;
}
:where(.kodza-inline-edit__native):not(#_) {
position: revert;
transition-duration: revert;
white-space: revert;
}
:where(.kodza-inline-edit__check):not(#_),
:where(.kodza-inline-edit__note):not(#_) {
transition-duration: revert;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-inline-edit:not(#_),
.kodza-inline-edit__row:not(#_),
.kodza-inline-edit__box:not(#_),
.kodza-inline-edit__fill:not(#_),
.kodza-inline-edit__ring:not(#_),
.kodza-inline-edit__cell:not(#_),
.kodza-inline-edit__text:not(#_),
.kodza-inline-edit__label:not(#_),
.kodza-inline-edit__prefix:not(#_),
.kodza-inline-edit__hint:not(#_),
.kodza-inline-edit__error:not(#_) {
box-sizing: revert;
}
.kodza-inline-edit__view:not(#_) {
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
outline-offset: revert;
max-width: revert;
max-height: revert;
text-underline-offset: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
transition-property: revert;
transition-timing-function: revert;
transition-delay: revert;
white-space: revert;
min-height: revert;
}
.kodza-inline-edit__native:not(#_) {
text-decoration: revert;
list-style: revert;
float: revert;
outline-offset: revert;
max-width: revert;
max-height: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
transition-property: revert;
transition-timing-function: revert;
transition-delay: revert;
min-height: revert;
}
.kodza-inline-edit__check:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
transition-property: revert;
transition-timing-function: revert;
transition-delay: revert;
}
.kodza-inline-edit__note:not(#_) {
border: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-style: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
transition-property: revert;
transition-timing-function: revert;
transition-delay: revert;
}
.kodza-inline-edit__native:not(#_):where(:not([hidden])),
.kodza-inline-edit__check:not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-inline-edit__native:not(#_):where([hidden]),
.kodza-inline-edit__check:not(#_):where([hidden]) {
display: none;
}
.kodza-inline-edit__box:not(#_)::before,
.kodza-inline-edit__view:not(#_)::before,
.kodza-inline-edit__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-inline-edit:not(#_) {
width: min(100%, var(--kz-inline-edit-width));
}
.kodza-inline-edit__native:not(#_) {
box-sizing: border-box;
width: 100%;
margin: 0;
padding: 0;
border: 0;
border-radius: 0;
background: none;
box-shadow: none;
color: inherit;
font: inherit;
letter-spacing: inherit;
line-height: inherit;
text-transform: inherit;
caret-color: var(--kz-inline-edit-focus);
outline: none;
resize: none;
overflow: hidden;
appearance: none;
}
.kodza-inline-edit__native:not(#_)::placeholder {
color: var(--kz-inline-edit-muted);
opacity: 1;
}
.kodza-inline-edit__view:not(#_) {
grid-area: 1 / 1;
display: block;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
border-radius: 0;
background: none;
box-shadow: none;
color: inherit;
font: inherit;
letter-spacing: inherit;
line-height: inherit;
text-align: left;
text-transform: inherit;
cursor: text;
overflow: hidden;
appearance: none;
}
.kodza-inline-edit__view:focus:not(#_) {
outline: none;
}
.kodza-inline-edit.is-empty .kodza-inline-edit__view:not(#_) {
color: var(--kz-inline-edit-muted);
}
.kodza-inline-edit.is-ready .kodza-inline-edit__native:not(#_) {
position: absolute;
top: 0;
left: 0;
width: calc(100% + 2px);
height: 100%;
visibility: hidden;
}
.kodza-inline-edit.is-multiline .kodza-inline-edit__native:not(#_) {
width: 100%;
white-space: pre-wrap;
overflow-wrap: break-word;
}
.kodza-inline-edit.is-editing .kodza-inline-edit__native:not(#_) {
visibility: visible;
}
.kodza-inline-edit.is-editing .kodza-inline-edit__view:not(#_) {
visibility: hidden;
}
.kodza-inline-edit__check:not(#_) {
margin: calc(var(--kz-inline-edit-leading) * 0.5em - 8px) 0 0 calc(var(--kz-inline-edit-reach-x) + 6px);
}
.kodza-inline-edit__note:not(#_) {
display: grid;
margin: 0;
padding: 0;
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-inline-edit.is-disabled .kodza-inline-edit__view:not(#_) {
cursor: default;
}
@media (prefers-reduced-motion: reduce) {
.kodza-inline-edit *:not(#_),
.kodza-inline-edit *:not(#_)::before,
.kodza-inline-edit *:not(#_)::after {
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
}
}
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — Inline Edit
// Turns the real input or textarea into text you click to edit: a mirror of the value sizes the
// box, a click puts the caret under the pointer, Enter saves (a check draws itself), Escape rolls
// the old text back in. Tab and clicking away save too. The timings are the constants below.
(() => {
const SELECTOR = '.kodza-inline-edit';
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)';
const DRAW = 450; // ms, the outline drawing itself around the field once you are in
const ERASE = 350; // ms, and wiping off to the right when you leave
const ROLL = 450; // ms, Escape: the typed text rolls up and out, the old one comes in under it
const CHECK = 400; // ms, the check drawing itself after a save
const CHECK_DELAY = 150; // ms, once the outline has started to go
const CHECK_FADE = 300; // ms, the check undrawing itself from its start
const CHECK_HOLD = 1200; // ms the check stays, a reading time: not scaled by the pace
// The outline's clip: open around the box, or cut down to its left or its right edge
const CLIP_OPEN = 'inset(-6px)';
const CLIP_LEFT = 'inset(-6px calc(100% + 6px) -6px -6px)';
const CLIP_RIGHT = 'inset(-6px -6px -6px calc(100% + 6px))';
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// The site's pace, --kz-motion-scale (set on :root, read on the instance): every duration of the
// script times it, and 0 moves nothing, like reduced motion
const paceOf = (el) => {
const pace = parseFloat(getComputedStyle(el).getPropertyValue('--kz-motion-scale'));
return pace >= 0 ? pace : 1;
};
let uid = 0;
// One instance, with its own state: two copies on a page never interfere. Everything it
// starts is tied to `signal` (listeners get { signal }, observers and timers stop on its
// 'abort' event), so kodza:destroy stops it all.
function mount(root, signal) {
const pace = paceOf(root);
const still = reduceMotion || !pace; // the still path: no motion asked, by the visitor or the site
const field = root.querySelector('.kodza-inline-edit__native');
if (!field) return;
const label = root.querySelector('.kodza-inline-edit__label');
const prefix = root.querySelector('.kodza-inline-edit__prefix');
const note = root.querySelector('.kodza-inline-edit__note');
const error = root.querySelector('.kodza-inline-edit__error');
const multiline = field.tagName === 'TEXTAREA';
const id = `kodza-inline-edit-${++uid}`;
// The label and the note name and describe the field, without ids in the markup
if (label && !field.hasAttribute('aria-label') && !field.labels?.length) {
label.id = `${id}-label`;
field.setAttribute('aria-labelledby', label.id);
}
// The row: the box (its fill, its outline, the prefix, the text over the field), then the check
const row = document.createElement('div');
row.className = 'kodza-inline-edit__row';
const box = document.createElement('span');
box.className = 'kodza-inline-edit__box';
const fill = document.createElement('span');
fill.className = 'kodza-inline-edit__fill';
const ring = document.createElement('span');
ring.className = 'kodza-inline-edit__ring';
const cell = document.createElement('span');
cell.className = 'kodza-inline-edit__cell';
const view = document.createElement('button');
view.type = 'button';
view.className = 'kodza-inline-edit__view';
const text = document.createElement('span');
text.className = 'kodza-inline-edit__text';
view.append(text);
if (note) {
note.id = `${id}-note`;
field.setAttribute('aria-describedby', note.id);
view.setAttribute('aria-describedby', note.id);
}
(prefix || field).before(row);
box.append(fill, ring);
if (prefix) box.append(prefix);
cell.append(view, field);
box.append(cell);
row.append(box);
row.insertAdjacentHTML('beforeend', '<svg class="kodza-inline-edit__check" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M3.5 8.5l3 3 6-7" pathLength="1"/></svg>');
const check = row.lastElementChild;
root.classList.toggle('is-multiline', multiline);
let editing = false;
let previous = field.value;
let drawing = null;
let rolling = [];
let ghost = null;
let checks = [];
let checkTimer = 0;
const name = () => field.getAttribute('aria-label')
|| (field.getAttribute('aria-labelledby') && document.getElementById(field.getAttribute('aria-labelledby'))?.textContent.trim())
|| field.labels?.[0]?.textContent.trim()
|| field.name
|| 'text';
// The mirror: the value (or the placeholder) as plain text, which gives the box its size
const sync = () => {
const value = field.value;
const shown = value || field.placeholder || ' ';
// A last empty line still counts in a textarea
text.textContent = multiline && value.endsWith('\n') ? `${shown}` : shown;
root.classList.toggle('is-empty', !value);
view.setAttribute('aria-label', `Edit ${name()}, ${value || 'empty'}`);
const off = field.disabled || field.readOnly;
view.disabled = off;
root.classList.toggle('is-disabled', off);
// The field grows with the mirror: once the text fits, nothing should stay scrolled
if (!multiline && text.scrollWidth <= text.clientWidth) field.scrollLeft = 0;
};
// The character under the pointer, so the caret lands where you clicked
const offsetAt = (x, y) => {
const node = text.firstChild;
if (!node || !field.value) return 0;
const range = document.createRange();
const length = Math.min(node.length, field.value.length);
let best = length;
let bestScore = Infinity;
for (let i = 0; i < length; i += 1) {
range.setStart(node, i);
range.setEnd(node, i + 1);
const rect = range.getClientRects()[0];
if (!rect || !rect.width) continue;
const dy = y < rect.top ? rect.top - y : y > rect.bottom ? y - rect.bottom : 0;
const dx = x < rect.left ? rect.left - x : x > rect.right ? x - rect.right : 0;
const score = dy * 10000 + dx;
if (score < bestScore) {
bestScore = score;
best = x < rect.left + rect.width / 2 ? i : i + 1;
}
}
return best;
};
// The outline draws itself from its left edge, and wipes off to the right
const drawRing = (from, to, duration) => {
const current = drawing ? getComputedStyle(ring).clipPath : from;
drawing?.cancel();
drawing = null;
if (still) return;
drawing = ring.animate([{ clipPath: current }, { clipPath: to }], { duration: duration * pace, easing: EASE });
drawing.onfinish = () => { drawing = null; };
};
const hideCheck = () => {
clearTimeout(checkTimer);
checks.forEach((animation) => animation.cancel());
checks = [];
};
const showCheck = () => {
hideCheck();
const time = still ? 0 : pace;
checks.push(check.animate([{ strokeDashoffset: 1 }, { strokeDashoffset: 0 }], {
duration: CHECK * time, delay: CHECK_DELAY * time, easing: EASE, fill: 'forwards',
}));
checkTimer = setTimeout(() => {
const out = check.animate([{ strokeDashoffset: 0 }, { strokeDashoffset: -1 }], {
duration: CHECK_FADE * time, easing: 'cubic-bezier(0.65, 0, 0.35, 1)', fill: 'forwards',
});
out.onfinish = hideCheck;
checks.push(out);
}, (CHECK_DELAY + CHECK) * time + CHECK_HOLD);
};
const stopRoll = () => {
rolling.forEach((animation) => animation.cancel());
rolling = [];
ghost?.remove();
ghost = null;
root.classList.remove('is-rolling');
};
// Escape: the old value comes back, the typed one rolls up and out above it
const rollBack = () => {
const typed = field.value;
const width = cell.offsetWidth;
const height = cell.offsetHeight;
field.value = previous;
sync();
if (still) return;
stopRoll();
root.classList.add('is-rolling');
if (typed) {
ghost = document.createElement('span');
ghost.className = 'kodza-inline-edit__text kodza-inline-edit__ghost';
ghost.setAttribute('aria-hidden', 'true');
ghost.textContent = typed;
ghost.style.width = `${width}px`;
cell.append(ghost);
rolling.push(ghost.animate([{ transform: 'none', opacity: 1 }, { transform: 'translateY(-100%)', opacity: 0 }], { duration: ROLL * pace, easing: EASE }));
}
const back = view.animate([{ transform: 'translateY(100%)' }, { transform: 'none' }], { duration: ROLL * pace, easing: EASE });
rolling.push(back);
const to = { width: `${cell.offsetWidth}px`, height: `${cell.offsetHeight}px` };
rolling.push(cell.animate([{ width: `${width}px`, height: `${height}px` }, to], { duration: ROLL * pace, easing: EASE }));
back.onfinish = stopRoll;
};
const open = (caret) => {
if (editing || field.disabled || field.readOnly) return;
editing = true;
previous = field.value;
hideCheck();
stopRoll();
root.classList.add('is-editing');
field.focus({ preventScroll: true });
if (caret == null) field.select();
else field.setSelectionRange(caret, caret);
drawRing(CLIP_LEFT, CLIP_OPEN, DRAW);
};
const finish = (save, refocus) => {
editing = false; // first: the blur that the refocus below fires has nothing left to do
const changed = field.value !== previous;
if (!save && changed) rollBack();
field.removeAttribute('aria-invalid');
root.classList.remove('is-editing');
drawRing(CLIP_OPEN, CLIP_RIGHT, ERASE);
if (refocus) view.focus({ preventScroll: true });
sync();
if (save && changed) {
showCheck();
root.dispatchEvent(new CustomEvent('kodza:save', {
bubbles: true,
detail: { value: field.value, previous, name: field.name },
}));
}
};
const commit = (fromKey) => {
if (!editing) return;
const value = field.value;
if (value === previous) return finish(true, fromKey);
// Nothing left in a required field: the old value comes back
if (field.required && !value.trim()) return finish(false, fromKey);
if (!field.validity.valid) {
// Leaving with an invalid value puts the old one back; Enter says what is wrong and stays
if (!fromKey) return finish(false, false);
field.setAttribute('aria-invalid', 'true');
if (error) error.textContent = field.dataset.error || field.validationMessage;
else field.reportValidity();
return;
}
finish(true, fromKey);
};
view.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
if (!event.repeat) open(null);
}, { signal });
// A click from the pointer puts the caret under it; one from assistive tech selects it all
view.addEventListener('click', (event) => {
open(event.detail === 0 ? null : offsetAt(event.clientX, event.clientY));
}, { signal });
// While editing, a press on the box around the text keeps the field focused
box.addEventListener('mousedown', (event) => {
if (editing && event.target !== field) event.preventDefault();
}, { signal });
// The prefix, or the box past the text: in, with the caret at the end
box.addEventListener('click', (event) => {
if (event.target === field || view.contains(event.target)) return;
open(field.value.length);
}, { signal });
field.addEventListener('keydown', (event) => {
if (event.isComposing || !editing) return;
if (event.key === 'Tab') return; // Tab moves on: the blur below saves
if (event.key === 'Escape') {
event.preventDefault();
finish(false, true);
} else if (event.key === 'Enter' && !(multiline && event.shiftKey)) {
event.preventDefault();
if (!event.repeat) commit(true);
}
}, { signal });
// Leaving saves. Not when the whole window loses focus: the field gets it back on return
field.addEventListener('blur', () => {
if (editing && document.hasFocus()) commit(false);
}, { signal });
field.addEventListener('input', () => {
sync();
if (field.getAttribute('aria-invalid') === 'true' && field.validity.valid) field.removeAttribute('aria-invalid');
}, { signal });
field.form?.addEventListener('reset', () => {
// The form puts its values back after this event
setTimeout(() => { if (!signal.aborted) sync(); }, 0);
}, { signal });
signal.addEventListener('abort', () => {
hideCheck();
stopRoll();
drawing?.cancel();
}, { once: true });
sync();
}
// 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 — Inline Edit, for React. From https://kodza.dev/components/inline-edit/
// 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: inline-edit.css and inline-edit.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 './inline-edit.css';
type Variant = "title";
export type InlineEditProps = Omit<ComponentPropsWithoutRef<'div'>, "children" | "variant" | "vars" | "onSave"> & {
/** Modifier class(es): .kodza-inline-edit--title */
variant?: Variant | Variant[];
/** Settings, as CSS variables on the root */
vars?: {
/** default: #0a0a0a */
"--kz-inline-edit-fg"?: string | number;
/** label, prefix, placeholder, hint — default: rgba(10, 10, 10, 0.6) */
"--kz-inline-edit-muted"?: string | number;
/** the box under the pointer — default: rgba(10, 10, 10, 0.05) */
"--kz-inline-edit-hover"?: string | number;
/** the box while editing — default: #ffffff */
"--kz-inline-edit-bg"?: string | number;
/** its outline — default: #0a0a0a */
"--kz-inline-edit-focus"?: string | number;
/** default: rgba(10, 10, 10, 0.06) */
"--kz-inline-edit-halo"?: string | number;
/** default: #0a0a0a */
"--kz-inline-edit-error"?: string | number;
/** default: var(--kz-field-width, 420px) */
"--kz-inline-edit-width"?: string | number;
/** default: 15px */
"--kz-inline-edit-size"?: string | number;
/** default: 1.55 */
"--kz-inline-edit-leading"?: string | number;
/** default: calc(8px * var(--kz-radius-scale, 1)) */
"--kz-inline-edit-radius"?: string | number;
/** how far the box reaches past the text, up and down — default: max(6px, 0.25em) */
"--kz-inline-edit-reach-y"?: string | number;
/** and sideways — default: max(10px, 0.4em) */
"--kz-inline-edit-reach-x"?: string | number;
/** the outline drawing itself; the box and the note follow it. 0s = no motion — default: 0.45s */
"--kz-inline-edit-duration"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-inline-edit-ease"?: string | number;
};
/** "kodza:save", fired by the script (bubbles from the root) */
onSave?: (event: CustomEvent) => void;
className?: string;
};
export function InlineEdit({ variant, vars, onSave, className, style, ...rest }: InlineEditProps) {
const ref = useRef<ComponentRef<'div'>>(null);
const classes = ["kodza-inline-edit", "kodza-inline-edit--title", ...([] as string[]).concat(variant ?? []).map((v) => `kodza-inline-edit--${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('./inline-edit.js').then(() => {
if (live) document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root } }));
});
return () => {
live = false;
document.dispatchEvent(new CustomEvent('kodza:destroy', { detail: { root } }));
};
}, [key]);
useEffect(() => {
const root = ref.current;
if (!root || !onSave) return;
const listener = (event: Event) => onSave(event as CustomEvent);
root.addEventListener("kodza:save", listener);
return () => root.removeEventListener("kodza:save", listener);
}, [onSave, key]);
return (
<div key={key} {...rest} className={classes} data-kodza-manual style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<input className="kodza-inline-edit__native" type="text" name="project" defaultValue="Shoreline redesign" aria-label="Project name" required maxLength={60} autoComplete="off" />
</div>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.