Sticky Cards
Cards that stack as you scroll, each covered card stepping back and darkening. Plain position: sticky and a few lines of JS. No GSAP.
View codeAn editorial card, clickable as a whole through one link: the image drifts closer, the border firms up and an arrow slides in.
With your AI
Say it to your AI. It gets the exact code below, not its best guess, and knows where it goes.
Lay out my blog posts as Kodza Cards.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<article class="kodza-card">
<div class="kodza-card__media">
<img src="https://kodza.dev/media/kz-arch-1400.webp" alt="A concrete wall with a round-topped opening, sunlight pouring through it" loading="lazy" />
</div>
<div class="kodza-card__body">
<p class="kodza-card__meta">Field notes · 4 min</p>
<h3 class="kodza-card__title"><a class="kodza-card__link" href="#">We deleted our config files</a></h3>
<p class="kodza-card__text">Nothing broke. Two people cried. Here is what we kept.</p>
</div>
</article>
<article class="kodza-card kodza-card--horizontal">
<div class="kodza-card__media">
<img src="https://kodza.dev/media/kz-columns-1400.webp" alt="A row of square concrete pillars under a flat roof, casting striped shadows" loading="lazy" />
</div>
<div class="kodza-card__body">
<p class="kodza-card__meta">Changelog · v2.0</p>
<h3 class="kodza-card__title"><a class="kodza-card__link" href="#">Seven columns, zero frameworks</a></h3>
<p class="kodza-card__text">The grid you wanted in 2015, now in plain CSS and a lot less arguing.</p>
</div>
</article>
<article class="kodza-card kodza-card--text">
<div class="kodza-card__body">
<p class="kodza-card__meta">Opinion · 2 min</p>
<h3 class="kodza-card__title"><a class="kodza-card__link" href="#">Not everything needs React</a></h3>
<p class="kodza-card__text">A button is a button. It was fine. It is still fine.</p>
</div>
</article>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Card
An editorial card, clickable as a whole through its title link (one link, one tab stop).
On hover the image drifts closer, the border firms up and an arrow slides in.
Variants: (default) image on top, --horizontal, --text.
Everything is tuned with the --kz-card-* variables. */
.kodza-card {
--kz-card-bg: #ffffff;
--kz-card-fg: #0a0a0a;
--kz-card-muted: rgba(10, 10, 10, 0.62);
--kz-card-border: rgba(10, 10, 10, 0.1);
--kz-card-border-hover: rgba(10, 10, 10, 0.32);
--kz-card-radius: 16px;
--kz-card-pad: 28px;
--kz-card-ratio: 4 / 3;
--kz-card-duration: 0.9s;
--kz-card-ease: cubic-bezier(0.22, 1, 0.36, 1);
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: var(--kz-card-radius);
background: var(--kz-card-bg);
box-shadow: inset 0 0 0 1px var(--kz-card-border);
color: var(--kz-card-fg);
transition: box-shadow 0.35s ease;
}
.kodza-card:hover,
.kodza-card:focus-within {
box-shadow: inset 0 0 0 1px var(--kz-card-border-hover);
}
.kodza-card__media {
position: relative;
aspect-ratio: var(--kz-card-ratio);
overflow: hidden;
}
.kodza-card__media img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform var(--kz-card-duration) var(--kz-card-ease);
}
.kodza-card:hover .kodza-card__media img,
.kodza-card:focus-within .kodza-card__media img {
transform: scale(1.05);
}
.kodza-card__body {
display: grid;
gap: 8px;
padding: var(--kz-card-pad);
}
.kodza-card__meta {
margin: 0;
font-size: 13px;
color: var(--kz-card-muted);
}
.kodza-card__title {
margin: 0;
padding-right: 28px;
font-size: 22px;
font-weight: 600;
line-height: 1.2;
letter-spacing: -0.02em;
}
.kodza-card__text {
margin: 0;
font-size: 15px;
line-height: 1.55;
color: var(--kz-card-muted);
}
/* The link covers the whole card */
.kodza-card__link {
color: inherit;
text-decoration: none;
}
.kodza-card__link::after {
content: "";
position: absolute;
inset: 0;
z-index: 1;
}
/* The arrow, top right of the text */
.kodza-card__body {
position: relative;
}
.kodza-card__body::after {
content: "";
position: absolute;
top: var(--kz-card-pad);
right: var(--kz-card-pad);
width: 16px;
height: 16px;
background: currentColor;
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4 12L12 4M5 4h7v7' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4 12L12 4M5 4h7v7' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / contain no-repeat;
opacity: 0;
transform: translate(-6px, 6px);
transition: opacity 0.3s ease, transform 0.5s var(--kz-card-ease);
}
.kodza-card:hover .kodza-card__body::after,
.kodza-card:focus-within .kodza-card__body::after {
opacity: 1;
transform: none;
}
.kodza-card:has(.kodza-card__link:focus-visible) {
outline: 2px solid var(--kz-card-fg);
outline-offset: 3px;
}
.kodza-card__link:focus-visible {
outline: none;
}
/* Horizontal: the image on the left, the text on the right */
.kodza-card--horizontal {
flex-direction: row;
}
.kodza-card--horizontal .kodza-card__media {
flex: 0 0 42%;
aspect-ratio: auto;
}
.kodza-card--horizontal .kodza-card__body {
flex: 1;
align-content: end;
}
@media (max-width: 560px) {
.kodza-card--horizontal {
flex-direction: column;
}
.kodza-card--horizontal .kodza-card__media {
aspect-ratio: var(--kz-card-ratio);
}
}
/* Text only: the content sits at the bottom of the card */
.kodza-card--text .kodza-card__body {
flex: 1;
align-content: end;
min-height: 180px;
}
@media (prefers-reduced-motion: reduce) {
.kodza-card,
.kodza-card__media img,
.kodza-card__body::after {
transition-duration: 0.01ms !important;
}
}
/* Theming example — on a dark background:
.dark-section .kodza-card {
--kz-card-bg: #161616;
--kz-card-fg: #f2f2f0;
--kz-card-muted: rgba(242, 242, 240, 0.62);
--kz-card-border: rgba(255, 255, 255, 0.12);
--kz-card-border-hover: rgba(255, 255, 255, 0.4);
}
*/
/* kodza-guard 7b70c66bb290 — 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-card):not(#_) {
outline: revert;
outline-offset: revert;
}
:where(.kodza-card__link):not(#_) {
outline: revert;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-card:not(#_) {
border: revert;
padding: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
appearance: 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-card__media:not(#_),
.kodza-card__body:not(#_) {
box-sizing: revert;
}
.kodza-card__meta: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-family: 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-card__title:not(#_) {
border: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
padding-top: revert;
padding-bottom: revert;
padding-left: 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-card__link:not(#_) {
border: revert;
margin: revert;
padding: revert;
border-radius: 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-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-card__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-width: 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-card__media 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-card__meta:not(#_):where(:not([hidden])),
.kodza-card__title:not(#_):where(:not([hidden])),
.kodza-card__link:not(#_):where(:not([hidden])),
.kodza-card__text:not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-card__meta:not(#_):where([hidden]),
.kodza-card__title:not(#_):where([hidden]),
.kodza-card__link:not(#_):where([hidden]),
.kodza-card__text:not(#_):where([hidden]) {
display: none;
}
.kodza-card__link:not(#_)::after,
.kodza-card__body: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-card:not(#_) {
position: relative;
display: flex;
border-radius: var(--kz-card-radius);
background: var(--kz-card-bg);
box-shadow: inset 0 0 0 1px var(--kz-card-border);
color: var(--kz-card-fg);
transition: box-shadow 0.35s ease;
}
.kodza-card:hover:not(#_),
.kodza-card:focus-within:not(#_) {
box-shadow: inset 0 0 0 1px var(--kz-card-border-hover);
}
.kodza-card__media img:not(#_) {
display: block;
transition: transform var(--kz-card-duration) var(--kz-card-ease);
}
.kodza-card__meta:not(#_) {
margin: 0;
font-size: 13px;
color: var(--kz-card-muted);
}
.kodza-card__title:not(#_) {
margin: 0;
padding-right: 28px;
font-size: 22px;
font-weight: 600;
line-height: 1.2;
letter-spacing: -0.02em;
}
.kodza-card__text:not(#_) {
margin: 0;
font-size: 15px;
line-height: 1.55;
color: var(--kz-card-muted);
}
.kodza-card__link:not(#_) {
color: inherit;
text-decoration: none;
}
.kodza-card:has(.kodza-card__link:focus-visible):not(#_) {
outline: 2px solid var(--kz-card-fg);
outline-offset: 3px;
}
.kodza-card__link:focus-visible:not(#_) {
outline: none;
}
@media (prefers-reduced-motion: reduce) {
.kodza-card:not(#_),
.kodza-card__media img:not(#_) {
transition-duration: 0.01ms !important;
}
}
'use client';
// Kodza — Card, for React. From https://kodza.dev/components/card/
// 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: card.css, the component's CSS from Kodza, as they are.
// Yours to edit: the markup is plain JSX below.
import { useRef, type ComponentPropsWithoutRef, type ComponentRef, type CSSProperties } from 'react';
import './card.css';
type Variant = "horizontal" | "text";
export type CardProps = Omit<ComponentPropsWithoutRef<'article'>, "children" | "variant" | "vars"> & {
/** Modifier class(es): .kodza-card--horizontal, .kodza-card--text */
variant?: Variant | Variant[];
/** Settings, as CSS variables on the root */
vars?: {
/** default: #ffffff */
"--kz-card-bg"?: string | number;
/** default: #0a0a0a */
"--kz-card-fg"?: string | number;
/** default: rgba(10, 10, 10, 0.62) */
"--kz-card-muted"?: string | number;
/** default: rgba(10, 10, 10, 0.1) */
"--kz-card-border"?: string | number;
/** default: rgba(10, 10, 10, 0.32) */
"--kz-card-border-hover"?: string | number;
/** default: 16px */
"--kz-card-radius"?: string | number;
/** default: 28px */
"--kz-card-pad"?: string | number;
/** default: 4 / 3 */
"--kz-card-ratio"?: string | number;
/** default: 0.9s */
"--kz-card-duration"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-card-ease"?: string | number;
};
className?: string;
};
export function Card({ variant, vars, className, style, ...rest }: CardProps) {
const ref = useRef<ComponentRef<'article'>>(null);
const classes = ["kodza-card", ...([] as string[]).concat(variant ?? []).map((v) => `kodza-card--${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]);
return (
<article key={key} {...rest} className={classes} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<div className="kodza-card__media">
<img src="https://kodza.dev/media/kz-arch-1400.webp" alt="A concrete wall with a round-topped opening, sunlight pouring through it" loading="lazy" />
</div>
<div className="kodza-card__body">
<p className="kodza-card__meta">Field notes · 4 min</p>
<h3 className="kodza-card__title">
<a className="kodza-card__link" href="#">We deleted our config files</a>
</h3>
<p className="kodza-card__text">Nothing broke. Two people cried. Here is what we kept.</p>
</div>
</article>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.