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 codeWords light up one after the other as a paragraph scrolls by, and dim again on the way back. Fade, blur or rise. No GSAP.
With your AI
Say it to your AI. It gets the exact code below, not its best guess, and knows where it goes.
Make my manifesto paragraph a Kodza Scroll Text Reveal.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<p class="kodza-scroll-reveal">Most animation libraries ship forty kilobytes of just in case. This one is a paragraph and a few lines of JavaScript.</p>
<p class="kodza-scroll-reveal" data-reveal="blur">Every word starts out of focus and sharpens into place, as if the page was waking up.</p>
<p class="kodza-scroll-reveal" data-reveal="rise">Or let the words climb into line, one by one, at the exact speed you scroll.</p>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Scroll Text Reveal
Words light up one after the other as the paragraph scrolls through the screen,
and dim again when you scroll back. The motion follows the scroll, it never plays on its own.
Three styles, picked with data-reveal: (default) fade, "blur" and "rise".
Everything is tuned with the --kz-reveal-* variables. */
.kodza-scroll-reveal {
--kz-reveal-dim: 0.16;
--kz-reveal-blur: 10px;
--kz-reveal-rise: 0.45em;
}
.kodza-scroll-reveal__word {
display: inline-block;
opacity: calc(var(--kz-reveal-dim) + (1 - var(--kz-reveal-dim)) * var(--w, 1));
}
.kodza-scroll-reveal[data-reveal="blur"] .kodza-scroll-reveal__word {
filter: blur(calc((1 - var(--w, 1)) * var(--kz-reveal-blur)));
}
.kodza-scroll-reveal[data-reveal="rise"] .kodza-scroll-reveal__word {
transform: translateY(calc((1 - var(--w, 1)) * var(--kz-reveal-rise)));
}
@media (prefers-reduced-motion: reduce) {
.kodza-scroll-reveal__word {
opacity: 1;
filter: none !important;
transform: none !important;
}
}
/* kodza-guard b81aa08dd8f5 — 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-scroll-reveal: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-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-scroll-reveal__word:not(#_) {
box-sizing: revert;
}
.kodza-scroll-reveal:not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-scroll-reveal: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 — Scroll Text Reveal
// Splits each paragraph into words, then gives every word its own progress (--w, 0 to 1)
// from the paragraph's position on screen. Words overlap a little, so the light flows
// instead of jumping from one word to the next. Only paragraphs on screen are updated.
(() => {
const SELECTOR = '.kodza-scroll-reveal';
const OVERLAP = 0.3; // how much of the scroll each word takes to light up
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const clamp = (v) => Math.min(1, Math.max(0, v));
// One instance. Everything it starts is tied to `signal`, so kodza:destroy stops it all
function mount(root, signal) {
// Words become spans; the spaces between them stay plain text, so lines wrap as before
const parts = root.textContent.trim().split(/(\s+)/);
root.textContent = '';
const words = [];
parts.forEach((part) => {
if (/^\s+$/.test(part)) return root.append(part);
const word = document.createElement('span');
word.className = 'kodza-scroll-reveal__word';
word.textContent = part;
root.append(word);
words.push(word);
});
if (reduceMotion) return;
words.forEach((word) => word.style.setProperty('--w', 0));
let visible = false;
let ticking = false;
const update = () => {
ticking = false;
if (!visible) return;
const vh = window.innerHeight;
const rect = root.getBoundingClientRect();
// 0 when the top of the paragraph reaches 85% of the screen, 1 when its bottom passes 45%
const progress = clamp((vh * 0.85 - rect.top) / (rect.height + vh * 0.4));
words.forEach((word, i) => {
const start = (i / words.length) * (1 - OVERLAP);
word.style.setProperty('--w', clamp((progress - start) / OVERLAP).toFixed(3));
});
};
const request = () => {
if (!ticking) {
ticking = true;
requestAnimationFrame(update);
}
};
const observer = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
request();
});
observer.observe(root);
signal.addEventListener('abort', () => observer.disconnect());
window.addEventListener('scroll', request, { passive: true, signal });
window.addEventListener('resize', request, { 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 — Scroll Text Reveal, for React. From https://kodza.dev/components/animated-text-reveal/
// 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: animated-text-reveal.css and animated-text-reveal.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 './animated-text-reveal.css';
export type ScrollTextRevealProps = Omit<ComponentPropsWithoutRef<'p'>, "children" | "vars" | "reveal"> & {
/** data-reveal on the root */
reveal?: "blur" | "rise";
/** Settings, as CSS variables on the root */
vars?: {
/** default: 0.16 */
"--kz-reveal-dim"?: string | number;
/** default: 10px */
"--kz-reveal-blur"?: string | number;
/** default: 0.45em */
"--kz-reveal-rise"?: string | number;
};
/** The text, plain: the script reads it and rebuilds the markup around it */
children?: string;
className?: string;
};
export function ScrollTextReveal({ reveal, vars, children = "Most animation libraries ship forty kilobytes of just in case. This one is a paragraph and a few lines of JavaScript.", className, style, ...rest }: ScrollTextRevealProps) {
const ref = useRef<ComponentRef<'p'>>(null);
const classes = ["kodza-scroll-reveal", 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, reveal, 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('./animated-text-reveal.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} data-reveal={reveal} style={{ ...vars, ...style } as CSSProperties} ref={ref}>{children}</p>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.