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 codeA block that pins itself while big statements take turns as you scroll: each line leaves through its own mask while the next one rises.
With your AI
Say it to your AI. It gets the exact code below, not its best guess, and knows where it goes.
Pin my three big promises in a Kodza Sticky Scroll Reveal.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<section class="kodza-sticky-reveal" aria-label="Why Kodza">
<div class="kodza-sticky-reveal__stage">
<p class="kodza-sticky-reveal__label">Why Kodza</p>
<div class="kodza-sticky-reveal__texts">
<p class="kodza-sticky-reveal__text">No framework needed.</p>
<p class="kodza-sticky-reveal__text">Just vanilla JavaScript.</p>
<p class="kodza-sticky-reveal__text">Simple. Fast. Clean.</p>
<p class="kodza-sticky-reveal__text">Welcome to Kodza.</p>
</div>
<div class="kodza-sticky-reveal__foot" aria-hidden="true">
<span class="kodza-sticky-reveal__count"><span class="kodza-sticky-reveal__num">01</span><span class="kodza-sticky-reveal__total">/ 04</span></span>
<span class="kodza-sticky-reveal__bar"><span></span></span>
</div>
</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 — Sticky Scroll Reveal
A block that pins itself while a few big statements take turns as you scroll: each line of
the current one leaves upwards through its own mask while the next one rises from below
(the other way round when scrolling back). One statement per gesture: a big scroll never
skips one. The counter rolls and the bar follows. Without JS, the statements simply stack.
Everything is tuned with the --kz-sreveal-* variables. */
.kodza-sticky-reveal {
--kz-sreveal-fg: #0a0a0a;
--kz-sreveal-muted: rgba(10, 10, 10, 0.62);
--kz-sreveal-line: rgba(10, 10, 10, 0.12);
--kz-sreveal-size: clamp(44px, 7.4vw, 108px);
--kz-sreveal-segment: 75vh;
--kz-sreveal-gutter: max(24px, calc((100vw - 1080px) / 2));
--kz-sreveal-duration: 0.9s;
--kz-sreveal-ease: cubic-bezier(0.22, 1, 0.36, 1);
--kz-sreveal-stagger: 70ms;
position: relative;
color: var(--kz-sreveal-fg);
}
.kodza-sticky-reveal.is-ready {
height: calc(100vh + var(--kz-sreveal-segment) * (var(--kz-sreveal-n, 4) - 1));
}
.kodza-sticky-reveal__stage {
display: grid;
grid-template-rows: auto 1fr auto;
gap: 32px;
padding: 12vh var(--kz-sreveal-gutter) 10vh;
box-sizing: border-box;
}
.kodza-sticky-reveal.is-ready .kodza-sticky-reveal__stage {
position: sticky;
top: 0;
height: 100vh;
}
.kodza-sticky-reveal__label {
margin: 0;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
color: var(--kz-sreveal-muted);
}
/* The statements share one cell */
.kodza-sticky-reveal__texts {
display: grid;
align-content: center;
}
.kodza-sticky-reveal__text {
max-width: 11em;
margin: 0;
font-size: var(--kz-sreveal-size);
font-weight: 600;
line-height: 1;
letter-spacing: -0.055em;
text-wrap: balance;
}
.kodza-sticky-reveal.is-ready .kodza-sticky-reveal__text {
grid-area: 1 / 1;
}
/* Each line in its own window: above, in, or below */
.kodza-sticky-reveal__line {
display: block;
overflow: hidden;
padding-bottom: 0.12em;
margin-bottom: -0.12em;
}
.kodza-sticky-reveal__line > span {
display: block;
transform: translateY(110%);
transition: transform var(--kz-sreveal-duration) var(--kz-sreveal-ease);
transition-delay: calc(var(--kz-line, 0) * var(--kz-sreveal-stagger));
}
.kodza-sticky-reveal__text.is-above .kodza-sticky-reveal__line > span {
transform: translateY(-110%);
}
.kodza-sticky-reveal__text.is-active .kodza-sticky-reveal__line > span {
transform: none;
transition-delay: calc(120ms + var(--kz-line, 0) * var(--kz-sreveal-stagger));
}
/* The foot: the counter rolls, the bar follows the scroll */
.kodza-sticky-reveal__foot {
display: flex;
align-items: center;
gap: 20px;
}
.kodza-sticky-reveal__count {
display: flex;
gap: 6px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.kodza-sticky-reveal__num {
display: inline-grid;
overflow: hidden;
}
.kodza-sticky-reveal__num > span {
grid-area: 1 / 1;
}
.kodza-sticky-reveal__total {
color: var(--kz-sreveal-muted);
}
.kodza-sticky-reveal__bar {
flex: 1;
height: 1px;
background: var(--kz-sreveal-line);
}
.kodza-sticky-reveal__bar span {
display: block;
height: 100%;
background: var(--kz-sreveal-fg);
transform: scaleX(var(--kz-sreveal-progress, 0));
transform-origin: left;
transition: transform 0.5s var(--kz-sreveal-ease);
}
.kodza-sticky-reveal:not(.is-ready) .kodza-sticky-reveal__foot {
display: none;
}
@media (prefers-reduced-motion: reduce) {
.kodza-sticky-reveal__line > span,
.kodza-sticky-reveal__bar span {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
}
}
/* Theming example — on a dark background:
.dark-section .kodza-sticky-reveal {
--kz-sreveal-fg: #f2f2f0;
--kz-sreveal-muted: rgba(242, 242, 240, 0.62);
--kz-sreveal-line: rgba(255, 255, 255, 0.16);
}
*/
/* kodza-guard 22518db15200 — 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-sticky-reveal:not(#_),
.kodza-sticky-reveal__texts:not(#_),
.kodza-sticky-reveal__line:not(#_),
.kodza-sticky-reveal__foot:not(#_),
.kodza-sticky-reveal__count:not(#_),
.kodza-sticky-reveal__num:not(#_),
.kodza-sticky-reveal__total:not(#_),
.kodza-sticky-reveal__bar:not(#_),
.kodza-sticky-reveal__line span:where(:not([class*="kodza-"])):not(#_),
.kodza-sticky-reveal__num span:where(:not([class*="kodza-"])):not(#_),
.kodza-sticky-reveal__bar span:where(:not([class*="kodza-"])):not(#_) {
box-sizing: revert;
}
.kodza-sticky-reveal__label: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-sticky-reveal__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-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-sticky-reveal__label:not(#_):where(:not([hidden])),
.kodza-sticky-reveal__text:not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-sticky-reveal__label:not(#_):where([hidden]),
.kodza-sticky-reveal__text: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-sticky-reveal__label:not(#_) {
margin: 0;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
color: var(--kz-sreveal-muted);
}
.kodza-sticky-reveal__text:not(#_) {
max-width: 11em;
margin: 0;
font-size: var(--kz-sreveal-size);
font-weight: 600;
line-height: 1;
letter-spacing: -0.055em;
}
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — Sticky Scroll Reveal
// Gives the block one scroll segment per statement, pins it, and shows the statement of the
// current segment: the statements before it wait above their masks, the ones after below, so
// lines always leave and arrive in the direction of the scroll. One statement per gesture:
// a big scroll never skips one. Lines are cut at the current width (again on resize).
// Fires "kodza:statement" with { index } on the block.
(() => {
const SELECTOR = '.kodza-sticky-reveal';
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const pad = (n) => String(n).padStart(2, '0');
// One instance. Everything it starts is tied to `signal`, so kodza:destroy stops it all
function mount(root, signal) {
const texts = [...root.querySelectorAll('.kodza-sticky-reveal__text')];
const num = root.querySelector('.kodza-sticky-reveal__num');
const total = root.querySelector('.kodza-sticky-reveal__total');
if (!texts.length) return;
const originals = texts.map((text) => text.textContent.trim());
root.style.setProperty('--kz-sreveal-n', texts.length);
if (total) total.textContent = `/ ${pad(texts.length)}`;
if (num) num.replaceChildren(Object.assign(document.createElement('span'), { textContent: pad(1) }));
// Cut each statement into lines, each in its own window
const split = () => {
texts.forEach((text, t) => {
text.textContent = '';
const spans = originals[t].split(/\s+/).map((word) => {
const span = document.createElement('span');
span.textContent = `${word} `;
text.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] });
});
text.textContent = '';
lines.forEach((line, i) => {
const outer = document.createElement('span');
const inner = document.createElement('span');
outer.className = 'kodza-sticky-reveal__line';
inner.textContent = line.words.join('').trim();
inner.style.setProperty('--kz-line', i);
outer.append(inner);
text.append(outer, ' ');
});
});
};
// The counter rolls with the direction of the scroll
const roll = (value, dir) => {
if (!num) return;
const prev = num.lastElementChild;
const span = Object.assign(document.createElement('span'), { textContent: value });
num.append(span);
if (reduceMotion) return prev.remove();
const timing = { duration: 500, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' };
prev.animate([{ transform: 'none' }, { transform: `translateY(${dir * -110}%)` }], { ...timing, fill: 'forwards' }).finished.then(() => prev.remove());
span.animate([{ transform: `translateY(${dir * 110}%)` }, { transform: 'none' }], timing);
};
let current = -1;
const show = (index) => {
if (index === current) return;
const dir = index > current ? 1 : -1;
texts.forEach((text, i) => {
text.classList.toggle('is-active', i === index);
text.classList.toggle('is-above', i < index);
text.setAttribute('aria-hidden', String(i !== index));
});
if (current >= 0) roll(pad(index + 1), dir);
current = index;
root.dispatchEvent(new CustomEvent('kodza:statement', { detail: { index }, bubbles: true }));
};
const update = () => {
const box = root.getBoundingClientRect();
const length = Math.max(1, box.height - innerHeight);
const progress = Math.min(1, Math.max(0, -box.top / length));
root.style.setProperty('--kz-sreveal-progress', progress.toFixed(4));
show(Math.min(texts.length - 1, Math.floor(progress * texts.length)));
};
// One statement per gesture: while the block is pinned, a wheel flick, a swipe or a key
// moves exactly one statement on, and the rest of the gesture (trackpad inertia) is absorbed.
// On the first and last statements, the page simply scrolls on, out of the block.
// data-step="false" turns this off and lets the scroll drive the statements freely.
if (root.dataset.step !== 'false') {
const pinned = () => {
const box = root.getBoundingClientRect();
return box.top <= 1 && box.bottom >= innerHeight - 1;
};
const goTo = (index) => {
const top = root.getBoundingClientRect().top + window.scrollY;
const length = Math.max(1, root.offsetHeight - innerHeight);
window.scrollTo({ top: Math.round(top + (length * (index + 0.5)) / texts.length), behavior: 'instant' });
update();
};
let lockedUntil = 0;
let lastWheel = 0;
let lastDelta = 0;
const step = (dir) => {
const target = current + dir;
if (target < 0 || target >= texts.length) return false;
lockedUntil = performance.now() + 700;
goTo(target);
return true;
};
let passing = false;
window.addEventListener('wheel', (event) => {
if (event.ctrlKey) return;
const dir = Math.sign(event.deltaY);
const now = performance.now();
// A new push: after a pause, or clearly stronger than the fading inertia. Every wheel
// event counts, so the momentum that carries the page into the block is not a new push.
const fresh = now - lastWheel > 180 || Math.abs(event.deltaY) > lastDelta * 1.6 + 4;
lastWheel = now;
lastDelta = Math.abs(event.deltaY);
if (fresh) passing = false;
if (!dir || !pinned()) return;
const inside = current + dir >= 0 && current + dir < texts.length;
// Past the first or last statement, a new push leaves the block, momentum included
if (!inside && (passing || (fresh && now > lockedUntil))) {
passing = true;
return;
}
event.preventDefault();
if (fresh && now > lockedUntil) step(dir);
}, { passive: false, signal });
window.addEventListener('keydown', (event) => {
if (!pinned() || event.target.closest?.('input, textarea, select, [contenteditable]')) return;
const down = ['ArrowDown', 'PageDown'].includes(event.key) || (event.key === ' ' && !event.shiftKey);
const up = ['ArrowUp', 'PageUp'].includes(event.key) || (event.key === ' ' && event.shiftKey);
if ((down || up) && step(down ? 1 : -1)) event.preventDefault();
}, { signal });
let touchY = null;
let touchDir = 0;
root.addEventListener('touchstart', (event) => {
touchY = event.touches[0].clientY;
touchDir = 0;
}, { passive: true, signal });
root.addEventListener('touchmove', (event) => {
if (touchY === null || !pinned()) return;
const dy = touchY - event.touches[0].clientY;
if (!touchDir && Math.abs(dy) > 6) touchDir = Math.sign(dy);
const target = current + touchDir;
if (touchDir && target >= 0 && target < texts.length) event.preventDefault();
}, { passive: false, signal });
root.addEventListener('touchend', (event) => {
if (touchY !== null && touchDir && pinned() && Math.abs(touchY - event.changedTouches[0].clientY) > 30) step(touchDir);
touchY = null;
}, { passive: true, signal });
}
let ticking = false;
window.addEventListener('scroll', () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
if (signal.aborted) return;
ticking = false;
update();
});
}, { passive: true, signal });
let width = innerWidth;
window.addEventListener('resize', () => {
if (innerWidth !== width) {
width = innerWidth;
split();
}
update();
}, { signal });
root.classList.add('is-ready');
split();
document.fonts?.ready.then(split);
update();
}
// 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 — Sticky Scroll Reveal, for React. From https://kodza.dev/components/sticky-scroll-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: sticky-scroll-reveal.css and sticky-scroll-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 './sticky-scroll-reveal.css';
export type StickyScrollRevealProps = Omit<ComponentPropsWithoutRef<'section'>, "children" | "vars" | "onStatement"> & {
/** Settings, as CSS variables on the root */
vars?: {
/** default: #0a0a0a */
"--kz-sreveal-fg"?: string | number;
/** default: rgba(10, 10, 10, 0.62) */
"--kz-sreveal-muted"?: string | number;
/** default: rgba(10, 10, 10, 0.12) */
"--kz-sreveal-line"?: string | number;
/** default: clamp(44px, 7.4vw, 108px) */
"--kz-sreveal-size"?: string | number;
/** default: 75vh */
"--kz-sreveal-segment"?: string | number;
/** default: max(24px, calc((100vw - 1080px) / 2)) */
"--kz-sreveal-gutter"?: string | number;
/** default: 0.9s */
"--kz-sreveal-duration"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-sreveal-ease"?: string | number;
/** default: 70ms */
"--kz-sreveal-stagger"?: string | number;
};
/** "kodza:statement", fired by the script (bubbles from the root) */
onStatement?: (event: CustomEvent) => void;
className?: string;
};
export function StickyScrollReveal({ vars, onStatement, className, style, ...rest }: StickyScrollRevealProps) {
const ref = useRef<ComponentRef<'section'>>(null);
const classes = ["kodza-sticky-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, 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('./sticky-scroll-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]);
useEffect(() => {
const root = ref.current;
if (!root || !onStatement) return;
const listener = (event: Event) => onStatement(event as CustomEvent);
root.addEventListener("kodza:statement", listener);
return () => root.removeEventListener("kodza:statement", listener);
}, [onStatement, key]);
return (
<section aria-label="Why Kodza" key={key} {...rest} className={classes} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<div className="kodza-sticky-reveal__stage">
<p className="kodza-sticky-reveal__label">Why Kodza</p>
<div className="kodza-sticky-reveal__texts">
<p className="kodza-sticky-reveal__text">No framework needed.</p>
<p className="kodza-sticky-reveal__text">Just vanilla JavaScript.</p>
<p className="kodza-sticky-reveal__text">Simple. Fast. Clean.</p>
<p className="kodza-sticky-reveal__text">Welcome to Kodza.</p>
</div>
<div className="kodza-sticky-reveal__foot" aria-hidden="true">
<span className="kodza-sticky-reveal__count">
<span className="kodza-sticky-reveal__num">01</span>
<span className="kodza-sticky-reveal__total">/ 04</span>
</span>
{' '}
<span className="kodza-sticky-reveal__bar">
<span />
</span>
</div>
</div>
</section>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.