Share Button
Press Share and the options slide out from behind it, the last one pushing the pill open, while the arrow takes off and a cross draws itself. Copy link turns into a check; X and LinkedIn open in a small window.
View codeA button that shows its work: its words roll from Deploy to Deploying while a ring comes in, then the ring closes on a tick for Deployed. The width never moves.
With your AI
Say it to your AI. It gets the exact code below, not its best guess, and knows where it goes.
Make the submit button of my form a Kodza Loading Button.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<button class="kodza-loading-button" type="button">
<span class="kodza-loading-button__icon" aria-hidden="true"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5" /><circle cx="12" cy="12" r="8.5" pathLength="100" /><path d="M7.6 12.4l3 3 5.8-6.2" pathLength="1" /></svg></span>
<span class="kodza-loading-button__label"><span>Deploy</span><span>Deploying</span><span>Deployed</span></span>
</button>
<button class="kodza-loading-button kodza-loading-button--soft" type="button">
<span class="kodza-loading-button__icon" aria-hidden="true"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5" /><circle cx="12" cy="12" r="8.5" pathLength="100" /><path d="M7.6 12.4l3 3 5.8-6.2" pathLength="1" /></svg></span>
<span class="kodza-loading-button__label"><span>Save changes</span><span>Saving</span><span>Saved</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 — Loading Button
A button that shows its work: clicked, its words roll to the second one while a ring comes
in on the left; when your code says done, the ring closes on a tick and the words roll to
the third one, then back to the first. The width never moves (the three share one cell).
From your code: listen to "kodza:loading", then el.kodzaLoading.done() (or .reset()).
Everything is tuned with the --kz-loading-* variables. */
.kodza-loading-button {
--kz-loading-bg: #0a0a0a;
--kz-loading-fg: #ffffff;
--kz-loading-height: 52px;
--kz-loading-pad-x: 28px;
--kz-loading-icon: 18px;
--kz-loading-gap: 10px;
--kz-loading-ease: cubic-bezier(0.22, 1, 0.36, 1);
/* Room for the ring is kept on both sides, so the words can stay centred at rest */
--kz-loading-room: calc((var(--kz-loading-icon) + var(--kz-loading-gap)) / 2);
position: relative;
display: inline-grid;
align-items: center;
height: var(--kz-loading-height);
padding: 0 calc(var(--kz-loading-pad-x) + var(--kz-loading-room));
border: 0;
border-radius: 999px;
background: var(--kz-loading-bg);
color: var(--kz-loading-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-loading-ease), background-color 0.3s ease;
}
.kodza-loading-button:active {
transform: scale(0.97);
}
.kodza-loading-button[aria-busy="true"] {
cursor: progress;
}
.kodza-loading-button:focus-visible {
outline: 2px solid var(--kz-loading-bg);
outline-offset: 3px;
}
.kodza-loading-button--soft {
--kz-loading-bg: rgba(10, 10, 10, 0.07);
--kz-loading-fg: #0a0a0a;
}
.kodza-loading-button--soft:focus-visible {
outline-color: var(--kz-loading-fg);
}
/* The words: three in one cell, the current one in its place, the others above or below */
.kodza-loading-button__label {
display: inline-grid;
grid-area: 1 / 1;
overflow: hidden;
padding-block: 0.15em;
transition: transform 0.55s var(--kz-loading-ease);
}
.kodza-loading-button__label > span {
grid-area: 1 / 1;
justify-self: center;
}
.kodza-loading-button__label > span:not(:first-child) {
transform: translateY(110%);
}
/* Busy or done: the words make room for the ring */
.kodza-loading-button:is([data-state="loading"], [data-state="done"]) .kodza-loading-button__label {
transform: translateX(var(--kz-loading-room));
}
/* The ring, then the tick */
.kodza-loading-button__icon {
position: absolute;
top: 50%;
left: calc(var(--kz-loading-pad-x) - 4px);
width: var(--kz-loading-icon);
height: var(--kz-loading-icon);
margin-top: calc(var(--kz-loading-icon) / -2);
opacity: 0;
transform: translateX(-8px) scale(0.6);
transition: opacity 0.25s ease, transform 0.5s var(--kz-loading-ease);
}
.kodza-loading-button:is([data-state="loading"], [data-state="done"]) .kodza-loading-button__icon {
opacity: 1;
transform: none;
}
.kodza-loading-button__icon svg {
display: block;
width: 100%;
height: 100%;
overflow: visible;
fill: none;
stroke: currentColor;
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
}
.kodza-loading-button__icon circle:first-child {
opacity: 0.22;
}
.kodza-loading-button__icon circle:nth-child(2) {
stroke-dasharray: 28 100;
transform: rotate(-90deg);
transform-origin: 50% 50%;
transition: stroke-dasharray 0.5s cubic-bezier(0.65, 0, 0.35, 1);
}
.kodza-loading-button[data-state="loading"] .kodza-loading-button__icon svg {
animation: kodza-loading-turn 0.9s linear infinite;
}
@keyframes kodza-loading-turn {
to {
transform: rotate(360deg);
}
}
.kodza-loading-button__icon path {
stroke-dasharray: 1;
stroke-dashoffset: 1;
transition: stroke-dashoffset 0.2s ease;
}
/* Done: the arc closes into a full ring, the tick draws itself */
.kodza-loading-button[data-state="done"] .kodza-loading-button__icon circle:nth-child(2) {
stroke-dasharray: 100 100;
}
.kodza-loading-button[data-state="done"] .kodza-loading-button__icon path {
stroke-dashoffset: 0;
transition: stroke-dashoffset 0.4s var(--kz-loading-ease) 0.3s;
}
.kodza-loading-button__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.kodza-loading-button[data-state="loading"] .kodza-loading-button__icon svg {
animation: none;
}
.kodza-loading-button__label,
.kodza-loading-button__icon,
.kodza-loading-button__icon circle,
.kodza-loading-button__icon path {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
}
}
/* Theming example — on a dark background:
.dark-section .kodza-loading-button {
--kz-loading-bg: #ffffff;
--kz-loading-fg: #0a0a0a;
}
.dark-section .kodza-loading-button--soft {
--kz-loading-bg: rgba(255, 255, 255, 0.1);
--kz-loading-fg: #ffffff;
}
*/
/* kodza-guard 10c004fa2401 — 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-loading-button):not(#_) {
outline: revert;
outline-offset: revert;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-loading-button: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-loading-button__icon:not(#_),
.kodza-loading-button__label:not(#_),
.kodza-loading-button__sr:not(#_),
.kodza-loading-button__label span:where(:not([class*="kodza-"])):not(#_) {
box-sizing: revert;
}
.kodza-loading-button__icon svg:where(:not([class*="kodza-"])):not(#_) {
border: revert;
margin: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
/* 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-loading-button:not(#_) {
position: relative;
display: inline-grid;
align-items: center;
height: var(--kz-loading-height);
padding: 0 calc(var(--kz-loading-pad-x) + var(--kz-loading-room));
border: 0;
border-radius: 999px;
background: var(--kz-loading-bg);
color: var(--kz-loading-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-loading-ease), background-color 0.3s ease;
}
.kodza-loading-button:active:not(#_) {
transform: scale(0.97);
}
.kodza-loading-button[aria-busy="true"]:not(#_) {
cursor: progress;
}
.kodza-loading-button:focus-visible:not(#_) {
outline: 2px solid var(--kz-loading-bg);
outline-offset: 3px;
}
.kodza-loading-button--soft:focus-visible:not(#_) {
outline-color: var(--kz-loading-fg);
}
.kodza-loading-button__icon svg:not(#_) {
display: block;
}
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — Loading Button
// Rolls the words from one state to the next (idle, loading, done, and back to idle from
// below), keeps aria-busy and a spoken status in step. A click starts loading and sends
// "kodza:loading"; call el.kodzaLoading.done() when the work is over (it rests 1.8s on the
// third words), or el.kodzaLoading.reset().
(() => {
const SELECTOR = '.kodza-loading-button';
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const ease = 'cubic-bezier(0.22, 1, 0.36, 1)';
const STATES = ['idle', 'loading', 'done'];
// One instance. Everything it starts is tied to `signal`, so kodza:destroy stops it all
function mount(button, signal) {
const words = [...button.querySelectorAll('.kodza-loading-button__label > span')];
if (words.length < 3) return;
const sr = Object.assign(document.createElement('span'), { className: 'kodza-loading-button__sr' });
sr.setAttribute('aria-live', 'polite');
button.append(sr);
let index = 0;
let rest = 0;
// The new words come up from below, the old ones leave through the top
const roll = (next) => {
const old = words[index];
const now = words[next];
index = next;
words.forEach((word) => word.getAnimations().forEach((a) => a.cancel()));
words.forEach((word, i) => (word.style.transform = i === next ? 'none' : 'translateY(110%)'));
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 set = (state) => {
const next = STATES.indexOf(state);
if (next === index) return;
roll(next);
button.dataset.state = state;
button.toggleAttribute('aria-busy', state === 'loading');
if (state === 'loading') button.setAttribute('aria-busy', 'true');
sr.textContent = state === 'idle' ? '' : words[next].textContent;
};
const done = () => {
if (button.dataset.state !== 'loading') return;
set('done');
clearTimeout(rest);
rest = setTimeout(() => set('idle'), 1800);
};
const reset = () => {
clearTimeout(rest);
set('idle');
};
button.addEventListener('click', () => {
if (button.dataset.state && button.dataset.state !== 'idle') return;
set('loading');
button.dispatchEvent(new CustomEvent('kodza:loading', { bubbles: true }));
}, { signal });
button.kodzaLoading = { done, reset };
button.dataset.state = 'idle';
signal.addEventListener('abort', () => {
clearTimeout(rest);
delete button.kodzaLoading;
});
}
// 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 — Loading Button, for React. From https://kodza.dev/components/loading-button/
// 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: loading-button.css and loading-button.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 './loading-button.css';
type Variant = "soft";
export type LoadingButtonProps = Omit<ComponentPropsWithoutRef<'button'>, "children" | "variant" | "vars" | "onLoading"> & {
/** Modifier class(es): .kodza-loading-button--soft */
variant?: Variant | Variant[];
/** Settings, as CSS variables on the root */
vars?: {
/** default: #0a0a0a */
"--kz-loading-bg"?: string | number;
/** default: #ffffff */
"--kz-loading-fg"?: string | number;
/** default: 52px */
"--kz-loading-height"?: string | number;
/** default: 28px */
"--kz-loading-pad-x"?: string | number;
/** default: 18px */
"--kz-loading-icon"?: string | number;
/** default: 10px */
"--kz-loading-gap"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-loading-ease"?: string | number;
/** default: calc((var(--kz-loading-icon) + var(--kz-loading-gap)) / 2) */
"--kz-loading-room"?: string | number;
};
/** "kodza:loading", fired by the script (bubbles from the root) */
onLoading?: (event: CustomEvent) => void;
className?: string;
};
export function LoadingButton({ variant, vars, onLoading, className, style, ...rest }: LoadingButtonProps) {
const ref = useRef<ComponentRef<'button'>>(null);
const classes = ["kodza-loading-button", ...([] as string[]).concat(variant ?? []).map((v) => `kodza-loading-button--${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('./loading-button.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 || !onLoading) return;
const listener = (event: Event) => onLoading(event as CustomEvent);
root.addEventListener("kodza:loading", listener);
return () => root.removeEventListener("kodza:loading", listener);
}, [onLoading, key]);
return (
<button type="button" key={key} {...rest} className={classes} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<span className="kodza-loading-button__icon" aria-hidden="true">
<svg viewBox="0 0 24 24">
<circle cx="12" cy="12" r="8.5" />
<circle cx="12" cy="12" r="8.5" pathLength="100" />
<path d="M7.6 12.4l3 3 5.8-6.2" pathLength="1" />
</svg>
</span>
{' '}
<span className="kodza-loading-button__label">
<span>Deploy</span>
<span>Deploying</span>
<span>Deployed</span>
</span>
</button>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.