Eye Glyph
Any letter with a hole becomes an eye: the pupil follows the pointer, lashes grow on top, and real lids close it when it blinks. Poke one and it winks.
View codeOne word of a sentence keeps changing: the next one's letters rise as the old ones leave, or the word flips over. The space follows the word, the sentence moves along.
With your AI
Say it to your AI. It gets the exact code below, not its best guess, and knows where it goes.
Rotate the last word of my hero title with Kodza Word Rotator.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<p class="kodza-rotator">Ship it on <span class="kodza-rotator__words"><span>Friday</span><span>a Monday</span><span>a Tuesday, like adults</span><span>your birthday</span></span></p>
<p class="kodza-rotator kodza-rotator--flip">Made for <span class="kodza-rotator__words"><span>designers</span><span>developers</span><span>WordPress</span><span>your future self</span></span></p>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Word Rotator
One word of a sentence keeps changing: the letters of the next one rise from below as the
old ones leave through the top, one after the other (--flip turns the whole word over
instead). The space follows the word, so the sentence moves along rather than jumping.
It waits while out of view; screen readers get every word once.
Everything is tuned with the --kz-rotator-* variables (and data-interval, in ms). */
.kodza-rotator {
--kz-rotator-ease: cubic-bezier(0.22, 1, 0.36, 1);
}
/* The window: clipped without becoming a box of its own, so the word keeps the line */
.kodza-rotator__words {
display: inline-grid;
overflow: clip;
/* As tall as the letters, so a leaving one is gone once it clears them */
padding-block: 0.08em 0.14em;
line-height: 1;
vertical-align: baseline;
white-space: nowrap;
}
.kodza-rotator__word {
grid-area: 1 / 1;
justify-self: start;
}
.kodza-rotator__word > span {
display: inline-block;
white-space: pre;
}
.kodza-rotator--flip .kodza-rotator__words {
perspective: 600px;
}
.kodza-rotator--flip .kodza-rotator__word {
transform-origin: 50% 50% -0.5em;
backface-visibility: hidden;
}
.kodza-rotator__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
/* kodza-guard 5697f341cbbb — 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, on everything the component leaves to it */
.kodza-rotator:not(#_) {
border: revert;
padding: revert;
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;
color: revert;
font-family: revert;
font-style: revert;
text-transform: revert;
text-underline-offset: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-rotator__words:not(#_),
.kodza-rotator__word:not(#_),
.kodza-rotator__sr:not(#_),
.kodza-rotator__word span:where(:not([class*="kodza-"])):not(#_) {
box-sizing: revert;
}
.kodza-rotator:not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-rotator:not(#_):where([hidden]) {
display: none;
}
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — Word Rotator
// Turns the words into a rotating slot: letters rise in and out one after the other (or the
// word flips with --flip), the slot eases to the new word's width, and the cycle pauses
// while the sentence is out of view or the tab hidden. With reduced motion, the first word
// simply stays. Screen readers get the words once, as a list.
(() => {
const SELECTOR = '.kodza-rotator';
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const ease = 'cubic-bezier(0.22, 1, 0.36, 1)';
const leave = 'cubic-bezier(0.55, 0, 0.75, 0.2)';
// One instance. Everything it starts is tied to `signal`, so kodza:destroy stops it all
function mount(root, signal) {
const slot = root.querySelector('.kodza-rotator__words');
if (!slot) return;
const words = [...slot.children].map((el) => el.textContent);
if (words.length < 2) return;
const flip = root.classList.contains('kodza-rotator--flip');
const interval = parseInt(root.dataset.interval, 10) || 2400;
let index = 0;
let timer = 0;
let visible = true;
const sr = Object.assign(document.createElement('span'), { className: 'kodza-rotator__sr', textContent: words.join(', ') });
slot.after(sr);
slot.setAttribute('aria-hidden', 'true');
const make = (text) => {
const word = Object.assign(document.createElement('span'), { className: 'kodza-rotator__word' });
word.append(...[...text].map((char) => Object.assign(document.createElement('span'), { textContent: char })));
return word;
};
slot.replaceChildren(make(words[0]));
const next = () => {
const old = slot.lastElementChild;
index = (index + 1) % words.length;
const word = make(words[index]);
const from = slot.offsetWidth;
slot.append(word);
// The slot eases to the new word's width; the old one, in the same cell, is cut by it
const pad = slot.offsetWidth - Math.max(old.offsetWidth, word.offsetWidth);
const to = word.offsetWidth + pad;
slot.animate([{ width: `${from}px` }, { width: `${to}px` }], { duration: 700, easing: ease });
if (flip) {
old.animate([{ transform: 'none', opacity: 1 }, { transform: 'rotateX(90deg)', opacity: 0 }], { duration: 520, easing: leave, fill: 'forwards' }).finished.then(() => old.remove());
word.animate([{ transform: 'rotateX(-90deg)', opacity: 0 }, { transform: 'none', opacity: 1 }], { duration: 760, delay: 140, easing: ease, fill: 'backwards' });
} else {
const gone = [...old.children].map((char, i) => char.animate([{ transform: 'none' }, { transform: 'translateY(-112%)' }], { duration: 420, delay: i * 14, easing: leave, fill: 'forwards' }).finished);
Promise.all(gone).then(() => old.remove());
[...word.children].forEach((char, i) => char.animate([{ transform: 'translateY(112%)' }, { transform: 'none' }], { duration: 640, delay: 90 + i * 24, easing: ease, fill: 'backwards' }));
}
};
const loop = () => {
clearTimeout(timer);
if (!visible || document.hidden) return;
timer = setTimeout(() => {
next();
loop();
}, interval);
};
if (!reduceMotion) {
const observer = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
loop();
});
observer.observe(root);
signal.addEventListener('abort', () => {
observer.disconnect();
clearTimeout(timer);
});
document.addEventListener('visibilitychange', loop, { 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 — Word Rotator, for React. From https://kodza.dev/components/word-rotator/
// 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: word-rotator.css and word-rotator.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 './word-rotator.css';
type Variant = "flip";
export type WordRotatorProps = Omit<ComponentPropsWithoutRef<'p'>, "children" | "variant" | "vars"> & {
/** Modifier class(es): .kodza-rotator--flip */
variant?: Variant | Variant[];
/** Settings, as CSS variables on the root */
vars?: {
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-rotator-ease"?: string | number;
};
className?: string;
};
export function WordRotator({ variant, vars, className, style, ...rest }: WordRotatorProps) {
const ref = useRef<ComponentRef<'p'>>(null);
const classes = ["kodza-rotator", ...([] as string[]).concat(variant ?? []).map((v) => `kodza-rotator--${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('./word-rotator.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 (
<p key={key} {...rest} className={classes} style={{ ...vars, ...style } as CSSProperties} ref={ref}>Ship it on <span className="kodza-rotator__words"> <span>Friday</span> <span>a Monday</span> <span>a Tuesday, like adults</span> <span>your birthday</span> </span></p>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.