Back to Top
A round button that shows up a screen down, its ring filling as you read. Its label unrolls on hover, the arrow takes off when you click.
View codeBlocks make their entrance as they scroll in: a fade, lines rising from a mask, or an image unveiled by clip-path. Blocks that arrive together take turns.
With your AI
Say it to your AI. It gets the exact code below, not its best guess, and knows where it goes.
Add Kodza Scroll Reveal to the sections of my landing page.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<section class="kodza-reveal-sample">
<p class="kodza-reveal kodza-reveal-sample__label">01 · Entrances</p>
<h2 class="kodza-reveal kodza-reveal--mask kodza-reveal-sample__title">Things show up when you get there, not a second before.</h2>
<p class="kodza-reveal kodza-reveal-sample__text">Fade, mask or clip: one class per block picks the entrance. Blocks that arrive together take turns, the others do not wait.</p>
<div class="kodza-reveal kodza-reveal--clip kodza-reveal-sample__wide">
<img src="https://kodza.dev/media/kz-steps-2400.webp" alt="Concrete steps climbing into a pale wall">
</div>
<div class="kodza-reveal-sample__row">
<figure>
<div class="kodza-reveal kodza-reveal--clip"><img src="https://kodza.dev/media/kz-block-1400.webp" alt="A concrete block"></div>
<figcaption class="kodza-reveal">display: block</figcaption>
</figure>
<figure>
<div class="kodza-reveal kodza-reveal--clip"><img src="https://kodza.dev/media/kz-stack-1400.webp" alt="Stacked concrete slabs"></div>
<figcaption class="kodza-reveal">z-index: 9999</figcaption>
</figure>
<figure>
<div class="kodza-reveal kodza-reveal--clip"><img src="https://kodza.dev/media/kz-float-1400.webp" alt="A floating concrete slab"></div>
<figcaption class="kodza-reveal">position: absolute</figcaption>
</figure>
</div>
</section>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Scroll Reveal
Blocks make their entrance as they scroll into view. One class per block picks it:
.kodza-reveal fades in and rises
.kodza-reveal--mask text: each line rises from behind its own mask
.kodza-reveal--clip a box (image inside): unveiled from the top, the image settling
.kodza-reveal--repeat plays again each time it comes back from below
Blocks that arrive together take turns; a block arriving alone does not wait. Without JS or
with reduced motion, everything is simply there. The sample markup is only an example: put
the classes on your own blocks.
Everything is tuned with the --kz-reveal-* variables. */
.kodza-reveal {
--kz-reveal-distance: 28px;
--kz-reveal-duration: 0.9s;
--kz-reveal-ease: cubic-bezier(0.22, 1, 0.36, 1);
--kz-reveal-stagger: 90ms;
--kz-reveal-radius: 16px;
}
/* Fade: armed by the script, then in (with no entrance when already scrolled past: is-instant) */
.kodza-reveal.is-armed:not(.kodza-reveal--mask):not(.kodza-reveal--clip) {
opacity: 0;
transform: translateY(var(--kz-reveal-distance));
}
.kodza-reveal.is-armed.is-in:not(.kodza-reveal--mask):not(.kodza-reveal--clip) {
opacity: 1;
transform: none;
}
.kodza-reveal.is-armed.is-in:not(.is-instant):not(.kodza-reveal--mask):not(.kodza-reveal--clip) {
transition:
opacity calc(var(--kz-reveal-duration) * 0.8) ease calc(var(--i, 0) * var(--kz-reveal-stagger)),
transform var(--kz-reveal-duration) var(--kz-reveal-ease) calc(var(--i, 0) * var(--kz-reveal-stagger));
}
/* Mask: each line in its own window */
.kodza-reveal__line {
display: block;
overflow: hidden;
padding-bottom: 0.1em;
margin-bottom: -0.1em;
}
.kodza-reveal__line > span {
display: block;
}
.kodza-reveal.is-armed .kodza-reveal__line > span {
transform: translateY(110%);
}
.kodza-reveal.is-armed.is-in .kodza-reveal__line > span {
transform: none;
}
.kodza-reveal.is-armed.is-in:not(.is-instant) .kodza-reveal__line > span {
transition: transform calc(var(--kz-reveal-duration) * 1.1) var(--kz-reveal-ease) calc((var(--i, 0) + var(--kz-line, 0)) * var(--kz-reveal-stagger));
}
/* Clip: the box opens from the top, the image inside settles from a slight zoom */
.kodza-reveal--clip {
overflow: hidden;
border-radius: var(--kz-reveal-radius);
}
.kodza-reveal--clip > img {
display: block;
width: 100%;
height: auto;
}
.kodza-reveal--clip.is-armed {
clip-path: inset(0 0 100% 0 round var(--kz-reveal-radius));
}
.kodza-reveal--clip.is-armed > img {
transform: scale(1.14);
}
.kodza-reveal--clip.is-armed.is-in {
clip-path: inset(0 0 0 0 round var(--kz-reveal-radius));
}
.kodza-reveal--clip.is-armed.is-in:not(.is-instant) {
transition: clip-path calc(var(--kz-reveal-duration) * 1.2) cubic-bezier(0.65, 0, 0.35, 1) calc(var(--i, 0) * var(--kz-reveal-stagger));
}
.kodza-reveal--clip.is-armed.is-in > img {
transform: none;
}
.kodza-reveal--clip.is-armed.is-in:not(.is-instant) > img {
transition: transform calc(var(--kz-reveal-duration) * 1.9) var(--kz-reveal-ease) calc(var(--i, 0) * var(--kz-reveal-stagger));
}
@media (prefers-reduced-motion: reduce) {
.kodza-reveal.is-armed,
.kodza-reveal.is-armed .kodza-reveal__line > span,
.kodza-reveal--clip.is-armed > img {
transition: none !important;
}
}
/* kodza-guard 974609b64736 — 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-reveal img:where(:not([class*="kodza-"]))):not(#_) {
transition: revert;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-reveal:not(#_) {
border: revert;
padding: revert;
outline: revert;
text-decoration: 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-style: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-reveal__line:not(#_),
.kodza-reveal__line span:where(:not([class*="kodza-"])):not(#_) {
box-sizing: revert;
}
.kodza-reveal img:where(:not([class*="kodza-"])):not(#_) {
border: revert;
margin: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: 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-reveal:not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-reveal: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-reveal.is-armed.is-in:not(.is-instant):not(.kodza-reveal--mask):not(.kodza-reveal--clip):where(p, h2, figcaption):not(#_) {
transition: opacity calc(var(--kz-reveal-duration) * 0.8) ease calc(var(--i, 0) * var(--kz-reveal-stagger)),
transform var(--kz-reveal-duration) var(--kz-reveal-ease) calc(var(--i, 0) * var(--kz-reveal-stagger));
}
.kodza-reveal--clip > img:not(#_) {
display: block;
}
.kodza-reveal--clip.is-armed.is-in:not(.is-instant) > img:not(#_) {
transition: transform calc(var(--kz-reveal-duration) * 1.9) var(--kz-reveal-ease) calc(var(--i, 0) * var(--kz-reveal-stagger));
}
@media (prefers-reduced-motion: reduce) {
.kodza-reveal.is-armed:where(p, h2, figcaption):not(#_),
.kodza-reveal--clip.is-armed > img:not(#_) {
transition: none !important;
}
}
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — Scroll Reveal
// Arms every .kodza-reveal (hidden state), then plays its entrance when it scrolls into view.
// Blocks entering together get a turn each (by position, top to bottom, left to right); a block
// entering alone starts at once. Mask blocks are cut into lines at their current width, and
// given back their original text once in, so a later resize reflows them normally.
(() => {
const SELECTOR = '.kodza-reveal';
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const blocks = new WeakMap(); // block → { text (mask blocks: their plain text), signal }
// Cut a text block into lines, each in its own window (plain text only)
const split = (el, text) => {
el.textContent = '';
const spans = text.split(/\s+/).map((word) => {
const span = document.createElement('span');
span.textContent = `${word} `;
el.append(span);
return span;
});
const lines = [];
spans.forEach((span) => {
const last = lines[lines.length - 1];
if (last && Math.abs(last.top - span.offsetTop) < 2) last.words.push(span.textContent);
else lines.push({ top: span.offsetTop, words: [span.textContent] });
});
el.textContent = '';
lines.forEach((line, i) => {
const outer = document.createElement('span');
const inner = document.createElement('span');
outer.className = 'kodza-reveal__line';
inner.textContent = line.words.join('').trim();
inner.style.setProperty('--kz-line', i);
outer.append(inner);
el.append(outer, ' ');
});
};
const reveal = (el, i, instant) => {
el.style.setProperty('--i', i);
el.classList.toggle('is-instant', instant);
el.classList.add('is-in');
if (instant) requestAnimationFrame(() => requestAnimationFrame(() => el.classList.remove('is-instant')));
// Once a mask has played, it gets its plain text back
const { text, signal } = blocks.get(el);
if (text !== null && !el.classList.contains('kodza-reveal--repeat')) {
const duration = parseFloat(getComputedStyle(el).getPropertyValue('--kz-reveal-duration')) || 0.9;
const lines = el.querySelectorAll('.kodza-reveal__line').length;
const timer = setTimeout(() => (el.textContent = text), instant ? 0 : (duration * 1.1 + 0.09 * (i + lines)) * 1000 + 100);
signal.addEventListener('abort', () => clearTimeout(timer));
}
};
// One observer for every block, so the blocks entering together take turns
let observer = null;
const watch = (entries) => {
const arriving = [];
entries.forEach((entry) => {
const el = entry.target;
if (entry.isIntersecting) {
if (!el.classList.contains('is-in')) arriving.push(entry);
} else if (entry.boundingClientRect.top < 0) {
// Scrolled past it (or loaded below it): it is simply there
if (!el.classList.contains('is-in')) reveal(el, 0, true);
} else if (el.classList.contains('kodza-reveal--repeat')) {
el.classList.remove('is-in');
}
});
arriving
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top || a.boundingClientRect.left - b.boundingClientRect.left)
.forEach((entry, i) => reveal(entry.target, i, false));
entries.forEach((entry) => {
if (entry.target.classList.contains('is-in') && !entry.target.classList.contains('kodza-reveal--repeat')) observer.unobserve(entry.target);
});
};
// One instance. Everything it starts is tied to `signal`, so kodza:destroy stops it all
function mount(el, signal) {
if (reduceMotion || !('IntersectionObserver' in window)) return;
const mask = el.classList.contains('kodza-reveal--mask') && !el.children.length;
const text = mask ? el.textContent.trim() : null;
blocks.set(el, { text, signal });
if (mask) {
split(el, text);
document.fonts?.ready.then(() => !signal.aborted && !el.classList.contains('is-in') && split(el, text));
let width = innerWidth;
window.addEventListener('resize', () => {
if (innerWidth === width) return;
width = innerWidth;
if (!el.classList.contains('is-in')) split(el, text);
}, { signal });
}
el.classList.add('is-armed');
observer ||= new IntersectionObserver(watch, { rootMargin: '0px 0px -12% 0px' });
observer.observe(el);
signal.addEventListener('abort', () => observer.unobserve(el));
}
// 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 Reveal, for React. From https://kodza.dev/components/fade-in-scroll/
// 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: fade-in-scroll.css and fade-in-scroll.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 './fade-in-scroll.css';
type Variant = "clip" | "mask" | "repeat";
export type ScrollRevealProps = Omit<ComponentPropsWithoutRef<'p'>, "children" | "variant" | "vars"> & {
/** Modifier class(es): .kodza-reveal--clip, .kodza-reveal--mask, .kodza-reveal--repeat */
variant?: Variant | Variant[];
/** Settings, as CSS variables on the root */
vars?: {
/** default: 28px */
"--kz-reveal-distance"?: string | number;
/** default: 0.9s */
"--kz-reveal-duration"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-reveal-ease"?: string | number;
/** default: 90ms */
"--kz-reveal-stagger"?: string | number;
/** default: 16px */
"--kz-reveal-radius"?: string | number;
};
/** The text, plain: the script reads it and rebuilds the markup around it */
children?: string;
className?: string;
};
export function ScrollReveal({ variant, vars, children = "01 · Entrances", className, style, ...rest }: ScrollRevealProps) {
const ref = useRef<ComponentRef<'p'>>(null);
const classes = ["kodza-reveal", ...([] as string[]).concat(variant ?? []).map((v) => `kodza-reveal--${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, 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('./fade-in-scroll.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}>{children}</p>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.