OTP Input
A one-time code field: one real input drawn as cells. Digits rise into place, paste and SMS autofill just work, and the cells turn to ink once complete.
View codeReal radio buttons drawn as stars: they light up one after the other under the pointer, the word rolls from Meh to Shipped it, and a static score fills in view.
With your AI
Say it to your AI. It gets the exact code below, not its best guess, and knows where it goes.
Add a Kodza Star Rating to my feedback form.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<fieldset class="kodza-rating">
<legend class="kodza-rating__legend">How was the copy-paste?</legend>
<div class="kodza-rating__stars">
<input class="kodza-rating__input" type="radio" name="rating" value="1" aria-label="1 star: Meh">
<input class="kodza-rating__input" type="radio" name="rating" value="2" aria-label="2 stars: Fine">
<input class="kodza-rating__input" type="radio" name="rating" value="3" aria-label="3 stars: Good">
<input class="kodza-rating__input" type="radio" name="rating" value="4" aria-label="4 stars: Great">
<input class="kodza-rating__input" type="radio" name="rating" value="5" aria-label="5 stars: Shipped it">
</div>
<p class="kodza-rating__word" aria-hidden="true"><span>Pick a star</span></p>
</fieldset>
<div class="kodza-rating kodza-rating--static" role="img" aria-label="Rated 4.6 out of 5 by 2,148 developers" style="--kz-score: 4.6">
<span class="kodza-rating__score">4.6</span>
<span class="kodza-rating__display" aria-hidden="true"></span>
<span class="kodza-rating__count">2,148 devs, zero bug reports. Mostly.</span>
</div>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Star Rating
Real radio buttons drawn as stars. Hover lights the stars up to the pointer, one after the
other, and the word rolls to match ("Meh" to "Shipped it"); picking makes them pop in a
little wave. The static version (--static, with --kz-score) fills to the exact score, the
fifth star cut where it should be, and counts up when it comes into view.
Everything is tuned with the --kz-rating-* variables. */
.kodza-rating {
--kz-rating-fg: #0a0a0a;
--kz-rating-muted: rgba(10, 10, 10, 0.6);
--kz-rating-rail: rgba(10, 10, 10, 0.14);
--kz-rating-ink: #0a0a0a;
--kz-rating-size: 30px;
--kz-rating-gap: 6px;
--kz-rating-ease: cubic-bezier(0.22, 1, 0.36, 1);
--kz-rating-star: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 2.6l2.9 5.9 6.5.9-4.7 4.6 1.1 6.5L12 17.4l-5.8 3.1 1.1-6.5L2.6 9.4l6.5-.9z' stroke='black' stroke-width='1.2' stroke-linejoin='round'/%3E%3C/svg%3E");
display: grid;
justify-items: start;
gap: 10px;
margin: 0;
padding: 0;
border: 0;
color: var(--kz-rating-fg);
}
.kodza-rating__legend {
margin-bottom: 10px;
padding: 0;
font-size: 15px;
font-weight: 500;
}
.kodza-rating__stars {
display: flex;
gap: var(--kz-rating-gap);
border-radius: 8px;
}
.kodza-rating__stars:has(.kodza-rating__input:focus-visible) {
outline: 2px solid var(--kz-rating-fg);
outline-offset: 6px;
}
.kodza-rating__input {
width: var(--kz-rating-size);
height: var(--kz-rating-size);
margin: 0;
background: var(--kz-rating-rail);
cursor: pointer;
appearance: none;
-webkit-mask: var(--kz-rating-star) center / contain no-repeat;
mask: var(--kz-rating-star) center / contain no-repeat;
transition: background-color 0.15s ease;
}
.kodza-rating__input:focus-visible {
outline: none;
}
/* Lit: one after the other when lighting up, all together when going out */
.kodza-rating__input.is-lit {
background: var(--kz-rating-ink);
transition-delay: calc(var(--i, 0) * 40ms);
}
.kodza-rating__input.is-preview:not(.is-lit) {
background: var(--kz-rating-muted);
transition-delay: calc(var(--i, 0) * 40ms);
}
.kodza-rating__word {
display: inline-grid;
margin: 0;
overflow: hidden;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
color: var(--kz-rating-muted);
}
.kodza-rating__word > span {
grid-area: 1 / 1;
}
.kodza-rating__word .is-chosen {
color: var(--kz-rating-fg);
}
/* --static: a score, filled to the exact value */
.kodza-rating--static {
grid-template-columns: auto auto;
align-items: center;
column-gap: 14px;
row-gap: 4px;
}
.kodza-rating__score {
grid-row: span 2;
font-size: 44px;
font-weight: 600;
line-height: 1;
letter-spacing: -0.04em;
font-variant-numeric: tabular-nums;
}
.kodza-rating__display {
display: block;
width: calc(var(--kz-rating-size) * 5 * 0.6 + var(--kz-rating-gap) * 4 * 0.6);
height: calc(var(--kz-rating-size) * 0.6);
background:
linear-gradient(var(--kz-rating-ink), var(--kz-rating-ink)) 0 0 / calc(var(--kz-score, 0) / 5 * 100%) 100% no-repeat,
var(--kz-rating-rail);
-webkit-mask: var(--kz-rating-star) 0 0 / calc(var(--kz-rating-size) * 0.6 + var(--kz-rating-gap) * 0.6) 100% repeat-x;
mask: var(--kz-rating-star) 0 0 / calc(var(--kz-rating-size) * 0.6 + var(--kz-rating-gap) * 0.6) 100% repeat-x;
}
/* With the script, it fills when it comes into view */
.kodza-rating--static.is-armed .kodza-rating__display {
background-size: 0% 100%, auto;
transition: background-size 1.1s var(--kz-rating-ease);
}
.kodza-rating--static.is-armed.is-in .kodza-rating__display {
background-size: calc(var(--kz-score, 0) / 5 * 100%) 100%, auto;
}
.kodza-rating__count {
font-size: 13px;
color: var(--kz-rating-muted);
}
@media (prefers-reduced-motion: reduce) {
.kodza-rating__input,
.kodza-rating--static.is-armed .kodza-rating__display {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
}
}
/* Theming example — on a dark background:
.dark-section .kodza-rating {
--kz-rating-fg: #f2f2f0;
--kz-rating-muted: rgba(242, 242, 240, 0.62);
--kz-rating-rail: rgba(255, 255, 255, 0.16);
--kz-rating-ink: #f2f2f0;
}
*/
/* kodza-guard 97c0f2a3f4d9 — 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-rating__input):not(#_) {
outline: revert;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-rating:not(#_) {
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-height: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-rating__legend:not(#_) {
border: 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-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-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;
}
.kodza-rating__stars:not(#_),
.kodza-rating__score:not(#_),
.kodza-rating__display:not(#_),
.kodza-rating__count:not(#_),
.kodza-rating__word span:where(:not([class*="kodza-"])):not(#_) {
box-sizing: revert;
}
.kodza-rating__input:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
box-shadow: revert;
outline-offset: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
white-space: revert;
min-height: revert;
}
.kodza-rating__word: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-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-rating__legend:not(#_):where(:not([hidden])),
.kodza-rating__input:not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-rating__legend:not(#_):where([hidden]),
.kodza-rating__input: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-rating:where(fieldset):not(#_) {
display: grid;
padding: 0;
border: 0;
color: var(--kz-rating-fg);
}
.kodza-rating__legend:not(#_) {
margin-bottom: 10px;
padding: 0;
font-size: 15px;
font-weight: 500;
}
.kodza-rating__input:not(#_) {
width: var(--kz-rating-size);
height: var(--kz-rating-size);
margin: 0;
background: var(--kz-rating-rail);
cursor: pointer;
appearance: none;
-webkit-mask: var(--kz-rating-star) center / contain no-repeat;
mask: var(--kz-rating-star) center / contain no-repeat;
transition: background-color 0.15s ease;
}
.kodza-rating__input:focus-visible:not(#_) {
outline: none;
}
.kodza-rating__input.is-lit:not(#_) {
background: var(--kz-rating-ink);
transition-delay: calc(var(--i, 0) * 40ms);
}
.kodza-rating__input.is-preview:not(.is-lit):not(#_) {
background: var(--kz-rating-muted);
transition-delay: calc(var(--i, 0) * 40ms);
}
.kodza-rating__word:not(#_) {
display: inline-grid;
margin: 0;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
color: var(--kz-rating-muted);
}
@media (prefers-reduced-motion: reduce) {
.kodza-rating__input: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 — Star Rating
// Lights the stars up to the one under the pointer (or the checked one), rolls the word, and
// makes the stars pop in a wave when you pick. The static version fills and counts up to its
// score the first time it comes into view.
(() => {
const SELECTOR = '.kodza-rating';
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const ease = 'cubic-bezier(0.22, 1, 0.36, 1)';
const roll = (box, text, chosen, dir = 1) => {
const prev = box.lastElementChild;
if (prev && prev.textContent === text && prev.classList.contains('is-chosen') === chosen) return;
const span = Object.assign(document.createElement('span'), { textContent: text });
span.classList.toggle('is-chosen', chosen);
box.append(span);
if (!prev) return;
if (reduceMotion || prev.textContent === text) return prev.remove();
const timing = { duration: 320, easing: ease };
prev.animate([{ transform: 'none' }, { transform: `translateY(${dir * -110}%)` }], { ...timing, fill: 'forwards' }).finished.then(() => prev.remove());
span.animate([{ transform: `translateY(${dir * 110}%)` }, { transform: 'none' }], timing);
};
// The interactive stars
function mountStars(root, signal) {
const stars = [...root.querySelectorAll('.kodza-rating__input')];
const word = root.querySelector('.kodza-rating__word');
if (!stars.length) return;
const words = stars.map((star) => (star.getAttribute('aria-label') || '').split(':').pop().trim());
const idle = word?.textContent.trim() || '';
stars.forEach((star, i) => star.style.setProperty('--i', i));
let shown = 0;
const light = (count, preview) => {
const chosen = stars.findIndex((star) => star.checked) + 1;
stars.forEach((star, i) => {
star.classList.toggle('is-lit', i < (preview ? Math.min(count, chosen) : count));
star.classList.toggle('is-preview', preview && i < count);
});
if (word) roll(word, count ? words[count - 1] : idle, !preview && count > 0, count >= shown ? 1 : -1);
shown = count;
};
const rest = () => light(stars.findIndex((star) => star.checked) + 1, false);
stars.forEach((star, i) => {
star.addEventListener('pointerenter', () => light(i + 1, true), { signal });
star.addEventListener('change', () => {
light(i + 1, false);
if (reduceMotion) return;
stars.slice(0, i + 1).forEach((s, k) => s.animate([{ transform: 'scale(1)' }, { transform: 'scale(1.28)' }, { transform: 'scale(1)' }], { duration: 380, delay: k * 45, easing: ease }));
}, { signal });
});
root.querySelector('.kodza-rating__stars')?.addEventListener('pointerleave', rest, { signal });
rest();
}
// The static score fills and counts up once, when it comes into view
function mountScore(root, signal) {
const score = parseFloat(getComputedStyle(root).getPropertyValue('--kz-score')) || 0;
const number = root.querySelector('.kodza-rating__score');
if (reduceMotion || !('IntersectionObserver' in window)) return;
root.classList.add('is-armed');
if (number) number.textContent = '0.0';
const io = new IntersectionObserver(([entry]) => {
if (!entry.isIntersecting) return;
io.disconnect();
root.classList.add('is-in');
const start = performance.now();
const step = (now) => {
const t = Math.min(1, (now - start) / 1100);
if (number) number.textContent = (score * (1 - Math.pow(1 - t, 3))).toFixed(1);
if (t < 1 && !signal.aborted) requestAnimationFrame(step);
};
requestAnimationFrame(step);
}, { threshold: 0.6 });
io.observe(root);
signal.addEventListener('abort', () => io.disconnect());
}
// One instance. Everything it starts is tied to `signal`, so kodza:destroy stops it all
function mount(root, signal) {
if (root.classList.contains('kodza-rating--static')) mountScore(root, signal);
else mountStars(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.
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 — Star Rating, for React. From https://kodza.dev/components/rating-component/
// 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: rating-component.css and rating-component.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 './rating-component.css';
type Variant = "static";
export type StarRatingProps = Omit<ComponentPropsWithoutRef<'fieldset'>, "children" | "variant" | "vars"> & {
/** Modifier class(es): .kodza-rating--static */
variant?: Variant | Variant[];
/** Settings, as CSS variables on the root */
vars?: {
/** default: #0a0a0a */
"--kz-rating-fg"?: string | number;
/** default: rgba(10, 10, 10, 0.6) */
"--kz-rating-muted"?: string | number;
/** default: rgba(10, 10, 10, 0.14) */
"--kz-rating-rail"?: string | number;
/** default: #0a0a0a */
"--kz-rating-ink"?: string | number;
/** default: 30px */
"--kz-rating-size"?: string | number;
/** default: 6px */
"--kz-rating-gap"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-rating-ease"?: string | number;
/** default: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 2.6l2.9 5.9 6.5.9-4.7 4.6 1.1 6.5L12 17.4l-5.8 3.1 1.1-6.5L2.6 9.4l6.5-.9z' stroke='black' stroke-width='1.2' stroke-linejoin='round'/%3E%3C/svg%3E") */
"--kz-rating-star"?: string | number;
};
className?: string;
};
export function StarRating({ variant, vars, className, style, ...rest }: StarRatingProps) {
const ref = useRef<ComponentRef<'fieldset'>>(null);
const classes = ["kodza-rating", ...([] as string[]).concat(variant ?? []).map((v) => `kodza-rating--${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('./rating-component.js').then(() => {
if (live) document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root } }));
});
return () => {
live = false;
document.dispatchEvent(new CustomEvent('kodza:destroy', { detail: { root } }));
};
}, [key]);
return (
<fieldset key={key} {...rest} className={classes} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<legend className="kodza-rating__legend">How was the copy-paste?</legend>
<div className="kodza-rating__stars">
<input className="kodza-rating__input" type="radio" name="rating" defaultValue="1" aria-label="1 star: Meh" />
{' '}
<input className="kodza-rating__input" type="radio" name="rating" defaultValue="2" aria-label="2 stars: Fine" />
{' '}
<input className="kodza-rating__input" type="radio" name="rating" defaultValue="3" aria-label="3 stars: Good" />
{' '}
<input className="kodza-rating__input" type="radio" name="rating" defaultValue="4" aria-label="4 stars: Great" />
{' '}
<input className="kodza-rating__input" type="radio" name="rating" defaultValue="5" aria-label="5 stars: Shipped it" />
</div>
<p className="kodza-rating__word" aria-hidden="true">
<span>Pick a star</span>
</p>
</fieldset>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.