Card
An editorial card, clickable as a whole through one link: the image drifts closer, the border firms up and an arrow slides in.
View codeCards that stack as you scroll, each covered card stepping back and darkening. Plain position: sticky and a few lines of JS. 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.
Stack my testimonials as Kodza Sticky Cards.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<div class="kodza-stack">
<article class="kodza-stack__card">
<div class="kodza-stack__media"><img src="https://kodza.dev/media/kz-steps-2400.webp" alt="A concrete staircase that climbs nine steps and stops in mid-air" loading="lazy" /></div>
<div class="kodza-stack__body">
<p class="kodza-stack__index">01</p>
<h3 class="kodza-stack__title">Copy</h3>
<p class="kodza-stack__text">Find the effect, open the code, take what you need. No account, no paywall.</p>
</div>
</article>
<article class="kodza-stack__card">
<div class="kodza-stack__media"><img src="https://kodza.dev/media/kz-block-2400.webp" alt="A tall concrete slab standing alone, its long shadow stretching toward the viewer" loading="lazy" /></div>
<div class="kodza-stack__body">
<p class="kodza-stack__index">02</p>
<h3 class="kodza-stack__title">Paste</h3>
<p class="kodza-stack__text">One HTML block, one CSS file, a few lines of JS. It drops into any page or theme.</p>
</div>
</article>
<article class="kodza-stack__card">
<div class="kodza-stack__media"><img src="https://kodza.dev/media/kz-columns-2400.webp" alt="A row of square concrete pillars under a flat roof, casting striped shadows" loading="lazy" /></div>
<div class="kodza-stack__body">
<p class="kodza-stack__index">03</p>
<h3 class="kodza-stack__title">Tweak</h3>
<p class="kodza-stack__text">Colors, sizes and timings are CSS variables. Make it yours in thirty seconds.</p>
</div>
</article>
<article class="kodza-stack__card">
<div class="kodza-stack__media"><img src="https://kodza.dev/media/kz-float-2400.webp" alt="A concrete block hovering above the ground, over its own shadow" loading="lazy" /></div>
<div class="kodza-stack__body">
<p class="kodza-stack__index">04</p>
<h3 class="kodza-stack__title">Ship</h3>
<p class="kodza-stack__text">Push it live, close the laptop, and go home early.</p>
</div>
</article>
</div>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Sticky Cards
Cards that stack as you scroll: each one sticks a little below the previous, and the card
being covered steps back and darkens. The stacking is plain CSS (position: sticky); the
script only measures how much each card is covered.
Everything is tuned with the --kz-stack-* variables. */
.kodza-stack {
--kz-stack-bg: #ffffff;
--kz-stack-fg: #0a0a0a;
--kz-stack-muted: rgba(10, 10, 10, 0.62);
--kz-stack-border: rgba(10, 10, 10, 0.1);
--kz-stack-shade: #0a0a0a;
--kz-stack-top: 10vh;
--kz-stack-offset: 18px;
--kz-stack-height: 72vh;
--kz-stack-gap: 12vh;
--kz-stack-scale: 0.08;
--kz-stack-radius: 16px;
--kz-stack-pad: 28px;
display: grid;
gap: var(--kz-stack-gap);
padding-bottom: var(--kz-stack-gap);
}
.kodza-stack__card {
position: sticky;
top: calc(var(--kz-stack-top) + var(--i, 0) * var(--kz-stack-offset));
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
height: var(--kz-stack-height);
overflow: hidden;
border-radius: var(--kz-stack-radius);
background: var(--kz-stack-bg);
box-shadow: inset 0 0 0 1px var(--kz-stack-border);
color: var(--kz-stack-fg);
transform: scale(calc(1 - var(--p, 0) * var(--kz-stack-scale)));
transform-origin: 50% 0;
}
/* The shade that darkens a card as the next one covers it */
.kodza-stack__card::after {
content: "";
position: absolute;
inset: 0;
background: var(--kz-stack-shade);
opacity: calc(var(--p, 0) * 0.45);
pointer-events: none;
}
.kodza-stack__media {
overflow: hidden;
}
.kodza-stack__media img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.kodza-stack__body {
display: flex;
flex-direction: column;
gap: 10px;
padding: var(--kz-stack-pad);
}
.kodza-stack__index {
margin: 0 0 auto;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
color: var(--kz-stack-muted);
}
.kodza-stack__title {
margin: 0;
font-size: 22px;
font-weight: 600;
line-height: 1.2;
letter-spacing: -0.02em;
}
.kodza-stack__text {
max-width: 34ch;
margin: 0;
font-size: 15px;
line-height: 1.55;
color: var(--kz-stack-muted);
}
@media (max-width: 720px) {
.kodza-stack__card {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) auto;
}
}
@media (prefers-reduced-motion: reduce) {
.kodza-stack__card {
transform: none;
}
.kodza-stack__card::after {
opacity: 0;
}
}
/* Theming example — on a dark background:
.dark-section .kodza-stack {
--kz-stack-bg: #161616;
--kz-stack-fg: #f2f2f0;
--kz-stack-muted: rgba(242, 242, 240, 0.62);
--kz-stack-border: rgba(255, 255, 255, 0.12);
--kz-stack-shade: #000000;
}
*/
/* kodza-guard 88f52bb515aa — 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-stack:not(#_),
.kodza-stack__media:not(#_),
.kodza-stack__body:not(#_) {
box-sizing: revert;
}
.kodza-stack__card:not(#_) {
border: revert;
margin: revert;
padding: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
outline-offset: revert;
appearance: revert;
max-width: 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-stack__index: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-stack__title: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;
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-stack__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;
font-family: revert;
font-weight: revert;
font-style: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-stack__media img:where(:not([class*="kodza-"])):not(#_) {
border: revert;
margin: 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;
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-stack__index:not(#_):where(:not([hidden])),
.kodza-stack__title:not(#_):where(:not([hidden])),
.kodza-stack__text:not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-stack__index:not(#_):where([hidden]),
.kodza-stack__title:not(#_):where([hidden]),
.kodza-stack__text:not(#_):where([hidden]) {
display: none;
}
.kodza-stack__card:not(#_)::after {
box-sizing: revert;
}
/* 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-stack__card:not(#_) {
position: sticky;
display: grid;
border-radius: var(--kz-stack-radius);
background: var(--kz-stack-bg);
box-shadow: inset 0 0 0 1px var(--kz-stack-border);
color: var(--kz-stack-fg);
}
.kodza-stack__media img:not(#_) {
display: block;
}
.kodza-stack__index:not(#_) {
margin: 0 0 auto;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
color: var(--kz-stack-muted);
}
.kodza-stack__title:not(#_) {
margin: 0;
font-size: 22px;
font-weight: 600;
line-height: 1.2;
letter-spacing: -0.02em;
}
.kodza-stack__text:not(#_) {
max-width: 34ch;
margin: 0;
font-size: 15px;
line-height: 1.55;
color: var(--kz-stack-muted);
}
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — Sticky Cards
// Gives each card its place in the stack (--i) and, while the stack is on screen, how much
// the next card covers it (--p, 0 to 1). The stacking itself is position: sticky.
(() => {
const SELECTOR = '.kodza-stack';
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// One instance. Everything it starts is tied to `signal`, so kodza:destroy stops it all
function mount(root, signal) {
if (reduceMotion) return;
const cards = [...root.querySelectorAll('.kodza-stack__card')];
cards.forEach((card, i) => card.style.setProperty('--i', i));
let visible = false;
let ticking = false;
const update = () => {
ticking = false;
if (!visible) return;
cards.forEach((card, i) => {
const next = cards[i + 1];
if (!next) return;
const a = card.getBoundingClientRect();
const b = next.getBoundingClientRect();
// 0 while the next card is still a card's height below, 1 once it sits on top
const p = Math.min(1, Math.max(0, 1 - (b.top - a.top) / a.height));
card.style.setProperty('--p', p.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 — Sticky Cards, for React. From https://kodza.dev/components/sticky-cards/
// 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-cards.css and sticky-cards.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-cards.css';
export type StickyCardsProps = Omit<ComponentPropsWithoutRef<'div'>, "children" | "vars"> & {
/** Settings, as CSS variables on the root */
vars?: {
/** default: #ffffff */
"--kz-stack-bg"?: string | number;
/** default: #0a0a0a */
"--kz-stack-fg"?: string | number;
/** default: rgba(10, 10, 10, 0.62) */
"--kz-stack-muted"?: string | number;
/** default: rgba(10, 10, 10, 0.1) */
"--kz-stack-border"?: string | number;
/** default: #0a0a0a */
"--kz-stack-shade"?: string | number;
/** default: 10vh */
"--kz-stack-top"?: string | number;
/** default: 18px */
"--kz-stack-offset"?: string | number;
/** default: 72vh */
"--kz-stack-height"?: string | number;
/** default: 12vh */
"--kz-stack-gap"?: string | number;
/** default: 0.08 */
"--kz-stack-scale"?: string | number;
/** default: 16px */
"--kz-stack-radius"?: string | number;
/** default: 28px */
"--kz-stack-pad"?: string | number;
};
className?: string;
};
export function StickyCards({ vars, className, style, ...rest }: StickyCardsProps) {
const ref = useRef<ComponentRef<'div'>>(null);
const classes = ["kodza-stack", 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-cards.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 (
<div key={key} {...rest} className={classes} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<article className="kodza-stack__card">
<div className="kodza-stack__media">
<img src="https://kodza.dev/media/kz-steps-2400.webp" alt="A concrete staircase that climbs nine steps and stops in mid-air" loading="lazy" />
</div>
<div className="kodza-stack__body">
<p className="kodza-stack__index">01</p>
<h3 className="kodza-stack__title">Copy</h3>
<p className="kodza-stack__text">Find the effect, open the code, take what you need. No account, no paywall.</p>
</div>
</article>
<article className="kodza-stack__card">
<div className="kodza-stack__media">
<img src="https://kodza.dev/media/kz-block-2400.webp" alt="A tall concrete slab standing alone, its long shadow stretching toward the viewer" loading="lazy" />
</div>
<div className="kodza-stack__body">
<p className="kodza-stack__index">02</p>
<h3 className="kodza-stack__title">Paste</h3>
<p className="kodza-stack__text">One HTML block, one CSS file, a few lines of JS. It drops into any page or theme.</p>
</div>
</article>
<article className="kodza-stack__card">
<div className="kodza-stack__media">
<img src="https://kodza.dev/media/kz-columns-2400.webp" alt="A row of square concrete pillars under a flat roof, casting striped shadows" loading="lazy" />
</div>
<div className="kodza-stack__body">
<p className="kodza-stack__index">03</p>
<h3 className="kodza-stack__title">Tweak</h3>
<p className="kodza-stack__text">Colors, sizes and timings are CSS variables. Make it yours in thirty seconds.</p>
</div>
</article>
<article className="kodza-stack__card">
<div className="kodza-stack__media">
<img src="https://kodza.dev/media/kz-float-2400.webp" alt="A concrete block hovering above the ground, over its own shadow" loading="lazy" />
</div>
<div className="kodza-stack__body">
<p className="kodza-stack__index">04</p>
<h3 className="kodza-stack__title">Ship</h3>
<p className="kodza-stack__text">Push it live, close the laptop, and go home early.</p>
</div>
</article>
</div>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.