Empty State
What a list shows when it is empty: a CSS drawing that answers when you come near, a line in a human voice and the next step. A second take for searches that find nothing.
View codeA message in the page: note, success, warning or error, told apart by the icon, not by color. The icon draws itself, a dismissed alert folds away and an error unfolds its log.
With your AI
Say it to your AI. It gets the exact code below, not its best guess, and knows where it goes.
Show my form errors with a Kodza Alert.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<div class="kodza-alert" role="note">
<svg class="kodza-alert__icon" viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="8" pathLength="1" /><path d="M10 9v4.6M10 6.4v.1" pathLength="1" /></svg>
<div class="kodza-alert__body">
<p class="kodza-alert__title">Zero dependencies</p>
<p class="kodza-alert__text">This component ships without a single package. Your bundle can relax.</p>
</div>
<button class="kodza-alert__close" type="button" aria-label="Dismiss"><svg viewBox="0 0 12 12" aria-hidden="true"><path d="M3 3l6 6M9 3 3 9" /></svg></button>
</div>
<div class="kodza-alert kodza-alert--success" role="note">
<svg class="kodza-alert__icon" viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="8" pathLength="1" /><path d="M6.6 10.3l2.3 2.3 4.6-5" pathLength="1" /></svg>
<div class="kodza-alert__body">
<p class="kodza-alert__title">Deployed to production</p>
<p class="kodza-alert__text">It is Friday, 5:58 pm. Bold. We respect it.</p>
</div>
<button class="kodza-alert__close" type="button" aria-label="Dismiss"><svg viewBox="0 0 12 12" aria-hidden="true"><path d="M3 3l6 6M9 3 3 9" /></svg></button>
</div>
<div class="kodza-alert kodza-alert--warning" role="note">
<svg class="kodza-alert__icon" viewBox="0 0 20 20" aria-hidden="true"><path d="M10 2.6 18 16.6H2Z" pathLength="1" /><path d="M10 8.2v4M10 14.4v.1" pathLength="1" /></svg>
<div class="kodza-alert__body">
<p class="kodza-alert__title">Deprecated prop</p>
<p class="kodza-alert__text"><code>size="huge"</code> goes away in v3. Switch to <code>size="lg"</code> before it switches you.</p>
</div>
<button class="kodza-alert__close" type="button" aria-label="Dismiss"><svg viewBox="0 0 12 12" aria-hidden="true"><path d="M3 3l6 6M9 3 3 9" /></svg></button>
</div>
<div class="kodza-alert kodza-alert--error" role="note">
<svg class="kodza-alert__icon" viewBox="0 0 20 20" aria-hidden="true"><path d="M7 2.4h6l4.6 4.6v6L13 17.6H7L2.4 13V7Z" pathLength="1" /><path d="M10 6.4v4.6M10 13.6v.1" pathLength="1" /></svg>
<div class="kodza-alert__body">
<p class="kodza-alert__title">Build failed</p>
<p class="kodza-alert__text">1 error, 0 regrets. The log knows more.</p>
<button class="kodza-alert__more" type="button" aria-expanded="false"><span>Show log</span><span>Hide log</span></button>
<div class="kodza-alert__details" hidden>
<pre><code>src/button.js:42:7
SyntaxError: Unexpected token '}'
41 | return label
> 42 | }}
| ^ one brace too many</code></pre>
</div>
</div>
<button class="kodza-alert__close" type="button" aria-label="Dismiss"><svg viewBox="0 0 12 12" aria-hidden="true"><path d="M3 3l6 6M9 3 3 9" /></svg></button>
</div>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Alert
A message that sits in the page: a note (default), --success, --warning or --error.
Monochrome: the kind is said by the icon's shape, and an error is inverted, ink on ink.
The icon draws itself when the alert shows up; dismissed, it folds away and what follows
slides up instead of jumping. An error can unfold its details (a log, a stack trace).
Remove [hidden] to show one again, it unfolds the same way.
Everything is tuned with the --kz-alert-* variables. */
.kodza-alert {
--kz-alert-bg: #ffffff;
--kz-alert-fg: #0a0a0a;
--kz-alert-muted: rgba(10, 10, 10, 0.64);
--kz-alert-line: rgba(10, 10, 10, 0.1);
--kz-alert-soft: rgba(10, 10, 10, 0.05);
--kz-alert-error-bg: #0a0a0a;
--kz-alert-error-fg: #ffffff;
--kz-alert-error-muted: rgba(255, 255, 255, 0.68);
--kz-alert-error-soft: rgba(255, 255, 255, 0.08);
--kz-alert-radius: 14px;
--kz-alert-ease: cubic-bezier(0.22, 1, 0.36, 1);
--kz-alert-tone: var(--kz-alert-fg);
--kz-alert-tone-muted: var(--kz-alert-muted);
--kz-alert-tone-soft: var(--kz-alert-soft);
display: grid;
box-sizing: border-box;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: start;
gap: 12px;
padding: 16px 14px 16px 18px;
border-radius: var(--kz-alert-radius);
background: var(--kz-alert-bg);
box-shadow: inset 0 0 0 1px var(--kz-alert-line);
color: var(--kz-alert-tone);
font-size: 14px;
line-height: 1.5;
}
.kodza-alert[hidden] {
display: none;
}
.kodza-alert--error {
--kz-alert-tone: var(--kz-alert-error-fg);
--kz-alert-tone-muted: var(--kz-alert-error-muted);
--kz-alert-tone-soft: var(--kz-alert-error-soft);
background: var(--kz-alert-error-bg);
box-shadow: none;
}
/* The icon draws itself (circle or shape first, then its mark) */
.kodza-alert__icon {
width: 20px;
height: 20px;
margin-top: 1px;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
}
.kodza-alert__icon > * {
stroke-dasharray: 1;
stroke-dashoffset: 0;
}
.kodza-alert.is-drawing .kodza-alert__icon > * {
animation: kodza-alert-draw 0.6s cubic-bezier(0.65, 0, 0.35, 1) 0.4s backwards;
}
.kodza-alert.is-drawing .kodza-alert__icon > :nth-child(2) {
animation-duration: 0.35s;
animation-delay: 0.85s;
}
@keyframes kodza-alert-draw {
from {
stroke-dashoffset: 1;
}
}
.kodza-alert__body {
display: grid;
justify-items: start;
gap: 2px;
min-width: 0;
}
.kodza-alert__title {
margin: 0;
font-size: 15px;
font-weight: 600;
letter-spacing: -0.01em;
}
.kodza-alert__text {
margin: 0;
color: var(--kz-alert-tone-muted);
text-wrap: pretty;
}
.kodza-alert code,
.kodza-alert pre {
font-family: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
font-size: 0.9em;
}
.kodza-alert__text code {
padding: 1px 5px;
border-radius: 5px;
background: var(--kz-alert-tone-soft);
color: var(--kz-alert-tone);
}
.kodza-alert__close {
display: grid;
place-items: center;
width: 28px;
height: 28px;
margin: -3px 0 0;
padding: 0;
border: 0;
border-radius: 8px;
background: none;
color: var(--kz-alert-tone-muted);
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease;
}
.kodza-alert__close:hover {
background: var(--kz-alert-tone-soft);
color: var(--kz-alert-tone);
}
.kodza-alert__close svg {
width: 12px;
height: 12px;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
}
.kodza-alert__close:focus-visible,
.kodza-alert__more:focus-visible {
outline: 2px solid var(--kz-alert-tone);
outline-offset: 2px;
}
/* Show log / Hide log: one cell, the words roll */
.kodza-alert__more {
display: inline-grid;
margin: 8px 0 0;
padding: 0 0 1px;
overflow: hidden;
border: 0;
border-bottom: 1px solid currentColor;
background: none;
color: var(--kz-alert-tone);
font: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
}
.kodza-alert__more > span {
grid-area: 1 / 1;
transition: transform 0.45s var(--kz-alert-ease), opacity 0.25s ease;
}
.kodza-alert__more > span:nth-child(2),
.kodza-alert__more[aria-expanded="true"] > span:first-child {
opacity: 0;
transform: translateY(-110%);
}
.kodza-alert__more > span:nth-child(2) {
transform: translateY(110%);
}
.kodza-alert__more[aria-expanded="true"] > span:nth-child(2) {
opacity: 1;
transform: none;
}
.kodza-alert__details {
justify-self: stretch;
overflow: hidden;
}
.kodza-alert__details pre {
margin: 12px 0 2px;
padding: 14px 16px;
overflow-x: auto;
border-radius: 10px;
background: var(--kz-alert-tone-soft);
color: var(--kz-alert-tone);
font-size: 12.5px;
line-height: 1.6;
}
@media (prefers-reduced-motion: reduce) {
.kodza-alert.is-drawing .kodza-alert__icon > * {
animation: none;
}
.kodza-alert__close,
.kodza-alert__more > span {
transition-duration: 0.01ms !important;
}
}
/* Theming example — on a dark background:
.dark-section .kodza-alert {
--kz-alert-bg: #161616;
--kz-alert-fg: #f2f2f0;
--kz-alert-muted: rgba(242, 242, 240, 0.64);
--kz-alert-line: rgba(255, 255, 255, 0.12);
--kz-alert-soft: rgba(255, 255, 255, 0.07);
--kz-alert-error-bg: #f2f2f0;
--kz-alert-error-fg: #0a0a0a;
--kz-alert-error-muted: rgba(10, 10, 10, 0.64);
--kz-alert-error-soft: rgba(10, 10, 10, 0.07);
}
*/
/* kodza-guard ed786f33d355 — 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-alert__close):not(#_),
:where(.kodza-alert__more):not(#_) {
outline: revert;
outline-offset: revert;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-alert__icon: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-right: revert;
margin-bottom: revert;
margin-left: 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-alert__body:not(#_),
.kodza-alert__details:not(#_),
.kodza-alert__more span:where(:not([class*="kodza-"])):not(#_) {
box-sizing: revert;
}
.kodza-alert__title: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;
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-style: revert;
line-height: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-alert__text: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;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: 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;
}
.kodza-alert__close:not(#_) {
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
box-shadow: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
white-space: revert;
min-height: revert;
}
.kodza-alert__more:not(#_) {
border-radius: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
box-shadow: revert;
appearance: revert;
max-width: revert;
max-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
white-space: revert;
min-height: revert;
}
.kodza-alert__close svg:where(:not([class*="kodza-"])):not(#_) {
border: revert;
margin: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-alert__text code:where(:not([class*="kodza-"])):not(#_) {
border: revert;
margin: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
box-shadow: 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-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-alert__details pre:where(:not([class*="kodza-"]):not(.kodza-alert__details [class*="kodza-"] *)):not(#_) {
border: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-weight: revert;
font-style: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
white-space: revert;
}
.kodza-alert__details code:where(:not([class*="kodza-"]):not(.kodza-alert__details [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-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-alert__icon:not(#_):where(:not([hidden])),
.kodza-alert__title:not(#_):where(:not([hidden])),
.kodza-alert__text:not(#_):where(:not([hidden])),
.kodza-alert__close svg:where(:not([class*="kodza-"])):not(#_):where(:not([hidden])),
.kodza-alert__text code:where(:not([class*="kodza-"])):not(#_):where(:not([hidden])),
.kodza-alert__details pre:where(:not([class*="kodza-"]):not(.kodza-alert__details [class*="kodza-"] *)):not(#_):where(:not([hidden])),
.kodza-alert__details code:where(:not([class*="kodza-"]):not(.kodza-alert__details [class*="kodza-"] *)):not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-alert__icon:not(#_):where([hidden]),
.kodza-alert__title:not(#_):where([hidden]),
.kodza-alert__text:not(#_):where([hidden]),
.kodza-alert__close svg:where(:not([class*="kodza-"])):not(#_):where([hidden]),
.kodza-alert__text code:where(:not([class*="kodza-"])):not(#_):where([hidden]),
.kodza-alert__details pre:where(:not([class*="kodza-"]):not(.kodza-alert__details [class*="kodza-"] *)):not(#_):where([hidden]),
.kodza-alert__details code:where(:not([class*="kodza-"]):not(.kodza-alert__details [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-alert__icon:not(#_) {
margin-top: 1px;
}
.kodza-alert__title:not(#_) {
margin: 0;
font-size: 15px;
font-weight: 600;
letter-spacing: -0.01em;
}
.kodza-alert__text:not(#_) {
margin: 0;
color: var(--kz-alert-tone-muted);
}
.kodza-alert code:not(#_),
.kodza-alert pre:not(#_) {
font-family: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
font-size: 0.9em;
}
.kodza-alert__text code:not(#_) {
padding: 1px 5px;
border-radius: 5px;
background: var(--kz-alert-tone-soft);
color: var(--kz-alert-tone);
}
.kodza-alert__close:not(#_) {
display: grid;
place-items: center;
width: 28px;
height: 28px;
margin: -3px 0 0;
padding: 0;
border: 0;
border-radius: 8px;
background: none;
color: var(--kz-alert-tone-muted);
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease;
}
.kodza-alert__close:hover:not(#_) {
background: var(--kz-alert-tone-soft);
color: var(--kz-alert-tone);
}
.kodza-alert__close:focus-visible:not(#_),
.kodza-alert__more:focus-visible:not(#_) {
outline: 2px solid var(--kz-alert-tone);
outline-offset: 2px;
}
.kodza-alert__more:not(#_) {
display: inline-grid;
margin: 8px 0 0;
padding: 0 0 1px;
overflow: hidden;
border: 0;
border-bottom: 1px solid currentColor;
background: none;
color: var(--kz-alert-tone);
font: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
}
.kodza-alert__details pre:not(#_) {
margin: 12px 0 2px;
padding: 14px 16px;
border-radius: 10px;
background: var(--kz-alert-tone-soft);
color: var(--kz-alert-tone);
font-size: 12.5px;
line-height: 1.6;
}
@media (prefers-reduced-motion: reduce) {
.kodza-alert__close:not(#_) {
transition-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 — Alert
// Draws the icon when an alert shows up, folds it away when dismissed (its height, its padding
// and the gap after it, so what follows slides up), and unfolds it the same way when [hidden]
// is removed. A dismissed alert sends "kodza:alert-close". The details of an error open and
// close at their real height.
(() => {
const SELECTOR = '.kodza-alert';
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const ease = 'cubic-bezier(0.22, 1, 0.36, 1)';
const fold = 'cubic-bezier(0.65, 0, 0.35, 1)';
let uid = 0;
// The space between the alert and what follows it (or precedes it, for the last one)
const space = (el) => {
const next = el.nextElementSibling;
const prev = el.previousElementSibling;
if (next && !next.hidden) return { side: 'marginBottom', px: next.getBoundingClientRect().top - el.getBoundingClientRect().bottom };
if (prev && !prev.hidden) return { side: 'marginTop', px: el.getBoundingClientRect().top - prev.getBoundingClientRect().bottom };
return { side: 'marginBottom', px: 0 };
};
const draw = (root) => {
root.classList.remove('is-drawing');
void root.offsetWidth;
root.classList.add('is-drawing');
};
// One instance. Everything it starts is tied to `signal`, so kodza:destroy stops it all
function mount(root, signal) {
const close = root.querySelector('.kodza-alert__close');
const more = root.querySelector('.kodza-alert__more');
const details = root.querySelector('.kodza-alert__details');
let busy = false;
let keyboard = false;
document.addEventListener('keydown', () => (keyboard = true), { capture: true, signal });
document.addEventListener('pointerdown', () => (keyboard = false), { capture: true, signal });
// The scale of a transformed parent, so the gap is read in the alert's own pixels
const scale = () => root.getBoundingClientRect().height / root.offsetHeight || 1;
const dismiss = () => {
if (busy) return;
busy = true;
const hadFocus = root.contains(document.activeElement) && keyboard;
const after = () => {
if (signal.aborted) return;
root.getAnimations().forEach((animation) => animation.cancel());
busy = false;
// Without it, the icon would play once on showing again, then once more when drawn
root.classList.remove('is-drawing');
root.hidden = true;
root.dispatchEvent(new CustomEvent('kodza:alert-close', { bubbles: true }));
// Focus goes on to whatever came next, not back to the top of the page
if (hadFocus) {
const focusables = [...document.querySelectorAll('a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])')];
focusables.find((el) => root.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING && el.offsetParent)?.focus();
}
};
if (reduceMotion) return after();
const k = scale();
const gap = space(root);
const style = getComputedStyle(root);
root.animate([
{ opacity: 1, transform: 'none', height: `${root.offsetHeight}px`, paddingTop: style.paddingTop, paddingBottom: style.paddingBottom, [gap.side]: '0px', overflow: 'hidden' },
{ opacity: 0, transform: 'translateX(14px)', offset: 0.35, height: `${root.offsetHeight}px`, paddingTop: style.paddingTop, paddingBottom: style.paddingBottom, [gap.side]: '0px', overflow: 'hidden' },
{ opacity: 0, transform: 'translateX(14px)', height: '0px', paddingTop: '0px', paddingBottom: '0px', [gap.side]: `${-gap.px / k}px`, overflow: 'hidden' },
], { duration: 620, easing: fold, fill: 'forwards' }).onfinish = after;
};
const unfold = () => {
if (reduceMotion) return;
const k = scale();
const gap = space(root);
const style = getComputedStyle(root);
root.animate([
{ opacity: 0, transform: 'translateY(6px)', height: '0px', paddingTop: '0px', paddingBottom: '0px', [gap.side]: `${-gap.px / k}px`, overflow: 'hidden' },
{ opacity: 0, transform: 'translateY(6px)', offset: 0.5, height: `${root.offsetHeight}px`, paddingTop: style.paddingTop, paddingBottom: style.paddingBottom, [gap.side]: '0px', overflow: 'hidden' },
{ opacity: 1, transform: 'none', height: `${root.offsetHeight}px`, paddingTop: style.paddingTop, paddingBottom: style.paddingBottom, [gap.side]: '0px', overflow: 'hidden' },
], { duration: 760, easing: ease });
// Drawn from the start, held empty (CSS delay) until the alert has come in
draw(root);
};
close?.addEventListener('click', dismiss, { signal });
const observer = new MutationObserver(() => !root.hidden && !busy && unfold());
observer.observe(root, { attributes: true, attributeFilter: ['hidden'] });
// A fold still playing on the root (held by fill: forwards) would outlive the restored markup
signal.addEventListener('abort', () => {
observer.disconnect();
root.getAnimations().forEach((animation) => animation.cancel());
});
if (more && details) {
details.id = details.id || `kodza-alert-details-${++uid}`;
more.setAttribute('aria-controls', details.id);
more.addEventListener('click', () => {
const open = more.getAttribute('aria-expanded') !== 'true';
more.setAttribute('aria-expanded', String(open));
details.getAnimations().forEach((animation) => animation.cancel());
const from = details.hidden ? 0 : details.offsetHeight;
details.hidden = false;
const to = open ? details.offsetHeight : 0;
if (reduceMotion) return (details.hidden = !open);
details.animate([
{ height: `${from}px`, opacity: open ? 0 : 1 },
{ height: `${to}px`, opacity: open ? 1 : 0 },
], { duration: open ? 520 : 420, easing: open ? ease : fold }).onfinish = () => (details.hidden = !open);
}, { 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.
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 — Alert, for React. From https://kodza.dev/components/alert/
// 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: alert.css and alert.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 './alert.css';
type Variant = "error" | "success" | "warning";
export type AlertProps = Omit<ComponentPropsWithoutRef<'div'>, "children" | "variant" | "vars" | "onAlertClose"> & {
/** Modifier class(es): .kodza-alert--error, .kodza-alert--success, .kodza-alert--warning */
variant?: Variant | Variant[];
/** Settings, as CSS variables on the root */
vars?: {
/** default: #ffffff */
"--kz-alert-bg"?: string | number;
/** default: #0a0a0a */
"--kz-alert-fg"?: string | number;
/** default: rgba(10, 10, 10, 0.64) */
"--kz-alert-muted"?: string | number;
/** default: rgba(10, 10, 10, 0.1) */
"--kz-alert-line"?: string | number;
/** default: rgba(10, 10, 10, 0.05) */
"--kz-alert-soft"?: string | number;
/** default: #0a0a0a */
"--kz-alert-error-bg"?: string | number;
/** default: #ffffff */
"--kz-alert-error-fg"?: string | number;
/** default: rgba(255, 255, 255, 0.68) */
"--kz-alert-error-muted"?: string | number;
/** default: rgba(255, 255, 255, 0.08) */
"--kz-alert-error-soft"?: string | number;
/** default: 14px */
"--kz-alert-radius"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-alert-ease"?: string | number;
/** default: var(--kz-alert-fg) */
"--kz-alert-tone"?: string | number;
/** default: var(--kz-alert-muted) */
"--kz-alert-tone-muted"?: string | number;
/** default: var(--kz-alert-soft) */
"--kz-alert-tone-soft"?: string | number;
};
/** "kodza:alert-close", fired by the script (bubbles from the root) */
onAlertClose?: (event: CustomEvent) => void;
className?: string;
};
export function Alert({ variant, vars, onAlertClose, className, style, ...rest }: AlertProps) {
const ref = useRef<ComponentRef<'div'>>(null);
const classes = ["kodza-alert", ...([] as string[]).concat(variant ?? []).map((v) => `kodza-alert--${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('./alert.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 || !onAlertClose) return;
const listener = (event: Event) => onAlertClose(event as CustomEvent);
root.addEventListener("kodza:alert-close", listener);
return () => root.removeEventListener("kodza:alert-close", listener);
}, [onAlertClose, key]);
return (
<div role="note" key={key} {...rest} className={classes} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<svg className="kodza-alert__icon" viewBox="0 0 20 20" aria-hidden="true">
<circle cx="10" cy="10" r="8" pathLength="1" />
<path d="M10 9v4.6M10 6.4v.1" pathLength="1" />
</svg>
<div className="kodza-alert__body">
<p className="kodza-alert__title">Zero dependencies</p>
<p className="kodza-alert__text">This component ships without a single package. Your bundle can relax.</p>
</div>
<button className="kodza-alert__close" type="button" aria-label="Dismiss">
<svg viewBox="0 0 12 12" aria-hidden="true">
<path d="M3 3l6 6M9 3 3 9" />
</svg>
</button>
</div>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.