Ink Like
Like button whose heart fills with ink, a small wave riding the surface, and drains when the like is taken back. The count rolls digit by digit with carries (999 → 1,000) and rolls back if the request fails.
View codeAdd-to-cart button that confirms with a tick while a copy of the product picture flies into the cart icon and its count rolls up by one. Rolls back if the request fails.
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 Add to Cart to my page.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<a class="kodza-add-to-cart__cart" href="/cart/" aria-label="Cart, 0 items" data-count="0">
<svg class="kodza-add-to-cart__bag" viewBox="0 0 24 24" aria-hidden="true"><path d="M5.2 8.2h13.6l-1 11a2 2 0 0 1-2 1.8H8.2a2 2 0 0 1-2-1.8z" /><path d="M9 8.2V6.8a3 3 0 0 1 6 0v1.4" /></svg>
<span class="kodza-add-to-cart__count" aria-hidden="true">0</span>
</a>
<button class="kodza-add-to-cart" type="button">
<span class="kodza-add-to-cart__label">
<span>Add to cart</span>
<span aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M6 12.5l4 4 8-9" pathLength="1" /></svg>Added</span>
<span aria-hidden="true"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5" /><path d="M12 7.8v5" /><path d="M12 16.2v.01" /></svg>Try again</span>
</span>
</button>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Add to Cart
Two pieces: the button that goes on each product, and the cart icon that goes in the header.
Clicked, the button rolls to "Added" with a tick, a copy of the product picture flies in an
arc into the bag, and the count rolls up by one, digit by digit. The count lives in the
cart's data-count: set it from your code (cart fragments, a page load) and it rolls too.
From your code: listen to "kodza:add" and pass your request to event.detail.wait(promise);
if it fails, the count rolls back and the button says "Try again".
Everything is tuned with the --kz-add-to-cart-* variables. */
.kodza-add-to-cart {
--kz-add-to-cart-bg: #0a0a0a;
--kz-add-to-cart-fg: #ffffff;
--kz-add-to-cart-height: 52px;
--kz-add-to-cart-pad-x: 28px;
--kz-add-to-cart-ease: cubic-bezier(0.22, 1, 0.36, 1);
position: relative;
display: inline-grid;
align-items: center;
height: var(--kz-add-to-cart-height);
padding: 0 var(--kz-add-to-cart-pad-x);
border: 0;
border-radius: 999px;
background: var(--kz-add-to-cart-bg);
color: var(--kz-add-to-cart-fg);
font: inherit;
font-weight: 500;
letter-spacing: -0.01em;
white-space: nowrap;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: transform 0.25s var(--kz-add-to-cart-ease);
}
.kodza-add-to-cart:active {
transform: scale(0.97);
}
.kodza-add-to-cart:focus-visible {
outline: 2px solid var(--kz-add-to-cart-bg);
outline-offset: 3px;
}
.kodza-add-to-cart--soft {
--kz-add-to-cart-bg: rgba(10, 10, 10, 0.07);
--kz-add-to-cart-fg: #0a0a0a;
}
.kodza-add-to-cart--soft:focus-visible {
outline-color: var(--kz-add-to-cart-fg);
}
/* The words: three in one cell, so the width is the widest one's and never moves */
.kodza-add-to-cart__label {
display: inline-grid;
grid-area: 1 / 1;
overflow: hidden;
padding-block: 0.15em;
}
.kodza-add-to-cart__label > span {
display: inline-flex;
grid-area: 1 / 1;
align-items: center;
justify-self: center;
gap: 8px;
}
.kodza-add-to-cart__label > span:not(:first-child) {
transform: translateY(110%);
}
.kodza-add-to-cart__label svg {
display: block;
flex: none;
width: 18px;
height: 18px;
margin-left: -4px;
overflow: visible;
fill: none;
stroke: currentColor;
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
}
/* The tick draws itself once "Added" is in */
.kodza-add-to-cart__label > span:nth-child(2) path {
stroke-dasharray: 1;
stroke-dashoffset: 1;
}
.kodza-add-to-cart[data-state="added"] .kodza-add-to-cart__label > span:nth-child(2) path {
stroke-dashoffset: 0;
transition: stroke-dashoffset 0.38s var(--kz-add-to-cart-ease) 0.16s;
}
.kodza-add-to-cart__label > span:nth-child(3) svg {
stroke-width: 1.8;
}
.kodza-add-to-cart__label > span:nth-child(3) path {
stroke-width: 2.2;
}
/* The cart: a round icon link with its count on the corner */
.kodza-add-to-cart__cart {
--kz-add-to-cart-ink: #0a0a0a;
--kz-add-to-cart-paper: #ffffff;
--kz-add-to-cart-cart-size: 44px;
--kz-add-to-cart-cart-hover: rgba(10, 10, 10, 0.06);
position: relative;
display: inline-grid;
place-items: center;
width: var(--kz-add-to-cart-cart-size);
height: var(--kz-add-to-cart-cart-size);
border-radius: 999px;
color: var(--kz-add-to-cart-ink);
text-decoration: none;
-webkit-tap-highlight-color: transparent;
transition: background-color 0.2s ease;
}
.kodza-add-to-cart__cart:hover {
background: var(--kz-add-to-cart-cart-hover);
}
.kodza-add-to-cart__cart:focus-visible {
outline: 2px solid var(--kz-add-to-cart-ink);
outline-offset: 2px;
}
.kodza-add-to-cart__bag {
display: block;
width: 22px;
height: 22px;
overflow: visible;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
transform-origin: 50% 85%;
}
/* The count: hidden at zero, pops in with the first item */
.kodza-add-to-cart__count {
position: absolute;
top: 3px;
left: calc(50% + 3px);
display: inline-flex;
justify-content: center;
box-sizing: border-box;
min-width: 18px;
height: 18px;
padding: 0 5px;
overflow: hidden;
border-radius: 999px;
background: var(--kz-add-to-cart-ink);
color: var(--kz-add-to-cart-paper);
font-size: 11px;
font-weight: 600;
line-height: 18px;
letter-spacing: 0;
font-variant-numeric: tabular-nums;
pointer-events: none;
transition: transform 0.45s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.2s ease;
}
.kodza-add-to-cart__cart[data-count="0"] .kodza-add-to-cart__count {
opacity: 0;
transform: scale(0.3);
transition: transform 0.25s cubic-bezier(0.65, 0, 0.35, 1), opacity 0.2s ease 0.05s;
}
/* One cell per digit, so only the digits that change roll */
.kodza-add-to-cart__digit {
position: relative;
display: block;
height: 18px;
overflow: hidden;
}
.kodza-add-to-cart__digit > span {
display: block;
}
.kodza-add-to-cart__digit > span + span {
position: absolute;
top: 0;
left: 0;
}
.kodza-add-to-cart__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.kodza-add-to-cart,
.kodza-add-to-cart__count,
.kodza-add-to-cart__label path {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
}
}
/* Theming example — on a dark background:
.dark-section .kodza-add-to-cart {
--kz-add-to-cart-bg: #ffffff;
--kz-add-to-cart-fg: #0a0a0a;
}
.dark-section .kodza-add-to-cart__cart {
--kz-add-to-cart-ink: #ffffff;
--kz-add-to-cart-paper: #0a0a0a;
--kz-add-to-cart-cart-hover: rgba(255, 255, 255, 0.1);
}
*/
/* kodza-guard 7903578edcb7 — 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-add-to-cart__cart):not(#_) {
outline: revert;
background-color: revert;
background-image: revert;
outline-offset: revert;
}
:where(.kodza-add-to-cart):not(#_) {
outline: revert;
outline-offset: revert;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-add-to-cart__cart:not(#_) {
border: revert;
padding: revert;
list-style: revert;
box-sizing: revert;
box-shadow: 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-add-to-cart__bag: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-add-to-cart:not(#_) {
text-decoration: revert;
list-style: revert;
box-sizing: revert;
box-shadow: revert;
appearance: revert;
max-height: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
min-height: revert;
}
.kodza-add-to-cart__label:not(#_),
.kodza-add-to-cart__label span:where(:not([class*="kodza-"])):not(#_) {
box-sizing: revert;
}
.kodza-add-to-cart__label svg:where(:not([class*="kodza-"])):not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
margin-top: revert;
margin-right: revert;
margin-bottom: 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;
}
/* 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-add-to-cart:not(#_) {
position: relative;
display: inline-grid;
align-items: center;
height: var(--kz-add-to-cart-height);
padding: 0 var(--kz-add-to-cart-pad-x);
border: 0;
border-radius: 999px;
background: var(--kz-add-to-cart-bg);
color: var(--kz-add-to-cart-fg);
font: inherit;
font-weight: 500;
letter-spacing: -0.01em;
white-space: nowrap;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: transform 0.25s var(--kz-add-to-cart-ease);
}
.kodza-add-to-cart:active:not(#_) {
transform: scale(0.97);
}
.kodza-add-to-cart:focus-visible:not(#_) {
outline: 2px solid var(--kz-add-to-cart-bg);
outline-offset: 3px;
}
.kodza-add-to-cart__label svg:not(#_) {
display: block;
margin-left: -4px;
}
.kodza-add-to-cart__cart:not(#_) {
position: relative;
display: inline-grid;
border-radius: 999px;
color: var(--kz-add-to-cart-ink);
text-decoration: none;
transition: background-color 0.2s ease;
}
.kodza-add-to-cart__cart:hover:not(#_) {
background: var(--kz-add-to-cart-cart-hover);
}
.kodza-add-to-cart__cart:focus-visible:not(#_) {
outline: 2px solid var(--kz-add-to-cart-ink);
outline-offset: 2px;
}
.kodza-add-to-cart__bag:not(#_) {
display: block;
}
@media (prefers-reduced-motion: reduce) {
.kodza-add-to-cart: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 — Add to Cart
// The button rolls to "Added", flies a copy of the product picture in an arc into the bag and
// adds one to the cart; the cart rolls its count digit by digit whenever data-count changes,
// from the button or from your code. "kodza:add" bubbles from the button and can be cancelled:
// pass your request to event.detail.wait(promise), and if it rejects the count rolls back and
// the button says "Try again". data-image (a selector) picks the picture, data-cart the carts,
// data-quantity how many it adds.
(() => {
const SELECTOR = '.kodza-add-to-cart, .kodza-add-to-cart__cart';
const CART = '.kodza-add-to-cart__cart';
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const ease = 'cubic-bezier(0.22, 1, 0.36, 1)';
const STATES = ['idle', 'added', 'failed'];
const countOf = (cart) => Math.max(0, parseInt(cart.dataset.count, 10) || 0);
const format = (n) => new Intl.NumberFormat(document.documentElement.lang || undefined).format(n);
// The cart: its count follows data-count, rolling only the digits that change (999 → 1,000
// carries), and the bag gives a little bump each time something lands in it
function mountCart(cart, signal) {
const count = cart.querySelector('.kodza-add-to-cart__count');
const bag = cart.querySelector('.kodza-add-to-cart__bag');
let shown = countOf(cart);
const label = (n) => {
const text = cart.getAttribute('aria-label');
if (!text) return;
cart.setAttribute('aria-label', text.replace(/\d[\d., ]*/, format(n)).replace(/\bitems?\b/, n === 1 ? 'item' : 'items'));
};
// Old and new numbers aligned on the right: a digit that changes rolls (up when the count
// grows, down when it shrinks), the units first, and a digit that appears opens its room
const draw = (from, to) => {
const a = format(from);
const b = format(to);
const n = Math.max(a.length, b.length);
const was = a.padStart(n);
const now = b.padStart(n);
const k = to > from ? 1 : -1;
count.textContent = '';
for (let i = 0; i < n; i++) {
const cell = Object.assign(document.createElement('span'), { className: 'kodza-add-to-cart__digit' });
const next = Object.assign(document.createElement('span'), { textContent: now[i].trim() });
cell.append(next);
count.append(cell);
if (was[i] === now[i] || reduceMotion) continue;
const old = Object.assign(document.createElement('span'), { textContent: was[i].trim() });
cell.append(old);
const timing = { duration: 460, delay: (n - 1 - i) * 60, easing: ease };
next.animate([{ transform: `translateY(${100 * k}%)` }, { transform: 'none' }], { ...timing, fill: 'backwards' });
const leave = old.animate([{ transform: 'none' }, { transform: `translateY(${-100 * k}%)` }], { ...timing, fill: 'forwards' });
if (was[i] === ' ') cell.animate([{ width: '0px' }, { width: `${cell.offsetWidth}px` }], timing);
if (now[i] === ' ') cell.animate([{ width: `${old.offsetWidth}px` }, { width: '0px' }], { ...timing, fill: 'forwards' });
// Once out of sight the old digit goes, and so does a cell left empty
leave.finished.then(() => (now[i] === ' ' ? cell : old).remove(), () => {});
}
};
const observer = new MutationObserver(() => {
const next = countOf(cart);
if (next === shown) return;
const from = shown;
shown = next;
label(next);
if (!count) return;
// From zero the badge pops in with its number; to zero it shrinks away with the last one
if (from === 0) count.textContent = format(next);
else if (next > 0) draw(from, next);
if (reduceMotion || next < from) return;
bag?.animate([
{ transform: 'none' },
{ transform: 'translateY(1.5px) scale(1.08, 0.9)', offset: 0.3 },
{ transform: 'translateY(-1px) scale(0.97, 1.04)', offset: 0.65 },
{ transform: 'none' },
], { duration: 520, easing: 'ease-out' });
if (from > 0) count.animate([{ transform: 'none' }, { transform: 'translateY(-3px) scale(1.12)', offset: 0.35 }, { transform: 'none' }], { duration: 420, easing: 'ease-out' });
});
observer.observe(cart, { attributes: true, attributeFilter: ['data-count'] });
signal.addEventListener('abort', () => observer.disconnect());
if (count) count.textContent = format(shown);
label(shown);
}
// The button: words rolling between three states, the flight, and the request it waits for
function mountButton(button, signal) {
const words = [...button.querySelectorAll('.kodza-add-to-cart__label > span')];
if (words.length < 3) return;
// The spoken status sits right after the button, and leaves with it
const status = Object.assign(document.createElement('span'), { className: 'kodza-add-to-cart__sr' });
status.setAttribute('role', 'status');
button.after(status);
const flights = new Set();
let index = 0;
let rest = 0;
let sayTimer = 0;
// The new words come up from below, the old ones leave through the top
const roll = (next) => {
if (next === index) return;
const old = words[index];
const now = words[next];
index = next;
words.forEach((word, i) => {
word.getAnimations().forEach((a) => a.cancel());
word.style.transform = i === next ? 'none' : 'translateY(110%)';
if (i === next) word.removeAttribute('aria-hidden');
else word.setAttribute('aria-hidden', 'true');
});
if (reduceMotion) return;
const timing = { duration: 520, easing: ease };
old.animate([{ transform: 'none' }, { transform: 'translateY(-110%)' }], timing);
now.animate([{ transform: 'translateY(110%)' }, { transform: 'none' }], timing);
};
const settle = (state) => {
roll(STATES.indexOf(state));
button.dataset.state = state;
clearTimeout(rest);
if (state !== 'idle') rest = setTimeout(() => settle('idle'), state === 'added' ? 2400 : 3200);
};
// Emptied first, then filled: a screen reader reads the same message twice in a row too
const say = (message) => {
status.textContent = '';
clearTimeout(sayTimer);
sayTimer = setTimeout(() => { status.textContent = message(); }, 60);
};
// The carts it adds to: data-cart, or the ones nearest to the button in the page
const cartsFor = () => {
if (button.dataset.cart) return [...document.querySelectorAll(button.dataset.cart)];
for (let el = button.parentElement; el; el = el.parentElement) {
const found = el.querySelectorAll(CART);
if (found.length) return [...found];
}
return [];
};
// The picture that flies: data-image, or the first one shown around the button, but never
// past a block that holds other buttons (a grid of products)
const pictureFor = () => {
const wanted = button.dataset.image;
for (let el = button.parentElement, depth = 0; el && el !== document.documentElement; el = el.parentElement, depth++) {
if (wanted) {
const found = el.querySelector(wanted);
if (found) return found;
continue;
}
if (depth > 3 || el.querySelectorAll('.kodza-add-to-cart').length > 1) return null;
const img = [...el.querySelectorAll('img')].find((image) => !image.closest(CART) && image.getBoundingClientRect().width > 0);
if (img) return img;
}
return null;
};
const visible = (el) => {
const r = el.getBoundingClientRect();
return r.width > 0 && r.bottom > 0 && r.top < innerHeight;
};
// A picture mostly scrolled out of sight (a phone, the button far below it) would fly in from
// nowhere: then the dot leaves the button instead
const seen = (el) => {
const r = el?.getBoundingClientRect();
return r && r.width > 0 && Math.min(r.bottom, innerHeight) - Math.max(r.top, 0) > r.height / 4;
};
// A copy of the picture lifts, flies in an arc above the cart and drops into the bag,
// turning into a small round on the way. Without a picture, a dot leaves the button.
const fly = (picture, cart) => {
const bag = cart.querySelector('.kodza-add-to-cart__bag') || cart;
const own = button.getBoundingClientRect();
const start = picture ? picture.getBoundingClientRect() : new DOMRect(own.left + own.width / 2 - 7, own.top + own.height / 2 - 7, 14, 14);
const end = bag.getBoundingClientRect();
const { width: w, height: h } = start;
const side = Math.min(w, h);
const radius = picture ? parseFloat(getComputedStyle(picture).borderTopLeftRadius) || 0 : 7;
const p0 = { x: start.left + w / 2, y: start.top + h / 2 };
const p2 = { x: end.left + end.width / 2, y: end.top + end.height * 0.42 };
const dx = p2.x - p0.x;
const lift = Math.min(140, Math.max(36, Math.hypot(dx, p2.y - p0.y) * 0.22));
const p1 = { x: p0.x + dx * 0.62, y: Math.min(p0.y, p2.y) - lift };
// It ends about the size of the bag, whatever scale the page is drawn at
const small = Math.min(1, (end.width * 0.9) / side);
const wrap = document.createElement('span');
const pic = document.createElement('span');
wrap.setAttribute('aria-hidden', 'true');
Object.assign(wrap.style, {
position: 'fixed', left: `${start.left}px`, top: `${start.top}px`, width: `${w}px`, height: `${h}px`,
display: 'block', margin: '0', padding: '0', zIndex: '2147483000', pointerEvents: 'none',
filter: 'drop-shadow(0 10px 14px rgba(10, 10, 10, 0.22))',
});
Object.assign(pic.style, {
display: 'block', width: '100%', height: '100%', margin: '0',
background: picture ? `center / cover no-repeat url("${(picture.currentSrc || picture.src).replace(/"/g, '%22')}")` : getComputedStyle(button).backgroundColor,
});
wrap.append(pic);
document.body.append(wrap);
// Sampled along the curve: a short lift, then the flight, sped up in the middle
const LIFT = 140;
const TRAVEL = 720;
const at = LIFT / (LIFT + TRAVEL);
const clip = (c) => `inset(${((h - side) / 2) * c}px ${((w - side) / 2) * c}px round ${radius + (side / 2 - radius) * c}px)`;
const frames = [
{ offset: 0, transform: 'none', clipPath: clip(0), opacity: 1, easing: 'ease-out' },
{ offset: at, transform: 'translateY(-6px) scale(1.04)', clipPath: clip(0), opacity: 1 },
];
for (let i = 1; i <= 24; i++) {
const u = i / 24;
const p = (1 - Math.cos(Math.PI * u)) / 2;
const x = (1 - p) ** 2 * p0.x + 2 * (1 - p) * p * p1.x + p ** 2 * p2.x - p0.x;
const y = (1 - p) ** 2 * p0.y + 2 * (1 - p) * p * p1.y + p ** 2 * p2.y - p0.y - 6 * (1 - u);
const shrink = 1 - (1 - Math.min(1, u / 0.8)) ** 2;
const scale = 1.04 + (small - 1.04) * shrink;
const tilt = 7 * Math.sign(dx) * Math.sin(Math.PI * u);
frames.push({
offset: at + (1 - at) * u,
transform: `translate(${x}px, ${y}px) rotate(${tilt}deg) scale(${u > 0.86 ? scale * (1 - ((u - 0.86) / 0.14) * 0.55) : scale})`,
clipPath: clip(Math.min(1, u / 0.45)),
opacity: u > 0.86 ? 1 - (u - 0.86) / 0.14 : 1,
});
}
const flight = wrap.animate(frames, { duration: LIFT + TRAVEL, fill: 'forwards' });
const done = () => {
flights.delete(stop);
wrap.remove();
};
// A refused request while it is still in the air: it falls away instead of landing
const drop = () => {
flight.pause();
const now = getComputedStyle(wrap).transform;
wrap.getAnimations().forEach((a) => a.cancel());
wrap.animate([{ transform: now, opacity: 1 }, { transform: `${now === 'none' ? '' : now} translateY(24px)`, opacity: 0 }], { duration: 280, easing: 'ease-in', fill: 'forwards' }).finished.then(done, done);
};
const stop = () => {
flight.cancel();
done();
};
flights.add(stop);
return { landed: flight.finished.then(() => done()), drop };
};
// Adds to every cart it found (a header and its mobile copy stay in step)
const add = (carts, quantity) => carts.forEach((cart) => {
cart.dataset.count = String(Math.max(0, countOf(cart) + quantity));
});
button.addEventListener('click', () => {
const quantity = Math.max(1, parseInt(button.dataset.quantity, 10) || 1);
let request = null;
const event = new CustomEvent('kodza:add', {
bubbles: true,
cancelable: true,
detail: { quantity, wait: (promise) => { request = Promise.resolve(promise); } },
});
if (!button.dispatchEvent(event)) return;
const carts = cartsFor();
// It flies to a cart in sight. None on screen (a header scrolled away): nothing flies off into
// nowhere, the button says it and the count changes
const target = carts.find(visible);
let landed = false;
let failed = false;
const land = () => {
if (failed || signal.aborted) return;
landed = true;
add(carts, quantity);
say(() => [words[1].textContent.trim(), (target || carts[0])?.getAttribute('aria-label')].filter(Boolean).join('. ') + '.');
};
settle('added');
const picture = pictureFor();
const flight = !reduceMotion && target ? fly(seen(picture) ? picture : null, target) : null;
if (flight) flight.landed.then(land, () => {});
else land();
request?.catch(() => {
if (signal.aborted) return;
failed = true;
if (landed) add(carts, -quantity);
else flight?.drop();
settle('failed');
say(() => `${words[2].textContent.trim()}.`);
});
}, { signal });
button.dataset.state = 'idle';
signal.addEventListener('abort', () => {
clearTimeout(rest);
clearTimeout(sayTimer);
flights.forEach((stop) => stop());
status.remove();
});
}
// One instance per button and per cart, each with its own state. Everything it starts is
// tied to `signal`, so kodza:destroy stops it all.
function mount(root, signal) {
if (root.matches(CART)) mountCart(root, signal);
else mountButton(root, signal);
}
// 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.
const KODZA = Symbol.for('kodza');
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) => {
if (root.classList.contains('is-ready')) return; // already running, maybe from another copy of this script
const controller = new AbortController();
const saved = { html: written(root), attrs: attrsOf(root), stop: () => destroy(root) };
instances.set(root, { controller, saved });
root[KODZA] = saved;
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);
// 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[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 — Add to Cart, for React. From https://kodza.dev/components/add-to-cart/
// 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: add-to-cart.css and add-to-cart.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.
// 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 './add-to-cart.css';
export type AddToCartProps = Omit<ComponentPropsWithoutRef<'a'>, "children" | "vars" | "count" | "onAdd"> & {
/** data-count on the root (default: 0) */
count?: "0";
/** Settings, as CSS variables on the root */
vars?: {
/** default: #0a0a0a */
"--kz-add-to-cart-bg"?: string | number;
/** default: #ffffff */
"--kz-add-to-cart-fg"?: string | number;
/** default: 52px */
"--kz-add-to-cart-height"?: string | number;
/** default: 28px */
"--kz-add-to-cart-pad-x"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-add-to-cart-ease"?: string | number;
/** default: #0a0a0a */
"--kz-add-to-cart-ink"?: string | number;
/** default: #ffffff */
"--kz-add-to-cart-paper"?: string | number;
/** default: 44px */
"--kz-add-to-cart-cart-size"?: string | number;
/** default: rgba(10, 10, 10, 0.06) */
"--kz-add-to-cart-cart-hover"?: string | number;
};
/** The text, plain: the script reads it and rebuilds the markup around it */
children?: string;
/** "kodza:add", fired by the script (bubbles from the root) */
onAdd?: (event: CustomEvent) => void;
className?: string;
};
export function AddToCart({ count = "0", vars, children = "0", onAdd, className, style, ...rest }: AddToCartProps) {
const ref = useRef<ComponentRef<'a'>>(null);
const classes = ["kodza-add-to-cart__cart", 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, count, 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('./add-to-cart.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 || !onAdd) return;
const listener = (event: Event) => onAdd(event as CustomEvent);
root.addEventListener("kodza:add", listener);
return () => root.removeEventListener("kodza:add", listener);
}, [onAdd, key]);
return (
<a href="/cart/" aria-label="Cart, 0 items" key={key} {...rest} className={classes} data-count={count} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<svg className="kodza-add-to-cart__bag" viewBox="0 0 24 24" aria-hidden="true">
<path d="M5.2 8.2h13.6l-1 11a2 2 0 0 1-2 1.8H8.2a2 2 0 0 1-2-1.8z" />
<path d="M9 8.2V6.8a3 3 0 0 1 6 0v1.4" />
</svg>
{' '}
<span className="kodza-add-to-cart__count" aria-hidden="true">{children}</span>
</a>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.