Code Block
Code to read and copy: file name, line numbers, highlighted lines and a monochrome syntax in weight and light. Copy rolls to Copied; a terminal copies its commands only.
View codeA filterable grid: the ink pill slides to the filter, leaving cards step back, the others glide to their new place and the new ones arrive in turn.
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 portfolio grid filterable with Kodza Filter Transitions.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<div class="kodza-filter">
<div class="kodza-filter__bar">
<div class="kodza-filter__pills" role="group" aria-label="Filter by category">
<span class="kodza-filter__indicator" aria-hidden="true"></span>
<button class="kodza-filter__pill" type="button" data-filter="all" aria-pressed="true">All</button>
<button class="kodza-filter__pill" type="button" data-filter="buttons" aria-pressed="false">Buttons</button>
<button class="kodza-filter__pill" type="button" data-filter="text" aria-pressed="false">Text</button>
<button class="kodza-filter__pill" type="button" data-filter="images" aria-pressed="false">Images</button>
<button class="kodza-filter__pill" type="button" data-filter="cards" aria-pressed="false">Cards</button>
</div>
<p class="kodza-filter__count" aria-live="polite"><span class="kodza-filter__num">8</span> components</p>
</div>
<ul class="kodza-filter__grid">
<li class="kodza-filter__item" data-category="buttons"><a class="kodza-filter__card" href="#"><span class="kodza-filter__media"><img src="https://kodza.dev/media/kz-block-1400.webp" alt="" loading="lazy"></span><span class="kodza-filter__meta"><span class="kodza-filter__name">Fill Button</span><span class="kodza-filter__tag">Buttons</span></span></a></li>
<li class="kodza-filter__item" data-category="text"><a class="kodza-filter__card" href="#"><span class="kodza-filter__media"><img src="https://kodza.dev/media/kz-stack-1400.webp" alt="" loading="lazy"></span><span class="kodza-filter__meta"><span class="kodza-filter__name">Text Shimmer</span><span class="kodza-filter__tag">Text</span></span></a></li>
<li class="kodza-filter__item" data-category="images"><a class="kodza-filter__card" href="#"><span class="kodza-filter__media"><img src="https://kodza.dev/media/kz-arch-1400.webp" alt="" loading="lazy"></span><span class="kodza-filter__meta"><span class="kodza-filter__name">Image Gallery</span><span class="kodza-filter__tag">Images</span></span></a></li>
<li class="kodza-filter__item" data-category="cards"><a class="kodza-filter__card" href="#"><span class="kodza-filter__media"><img src="https://kodza.dev/media/kz-columns-1400.webp" alt="" loading="lazy"></span><span class="kodza-filter__meta"><span class="kodza-filter__name">Pricing Plans</span><span class="kodza-filter__tag">Cards</span></span></a></li>
<li class="kodza-filter__item" data-category="buttons"><a class="kodza-filter__card" href="#"><span class="kodza-filter__media"><img src="https://kodza.dev/media/kz-float-1400.webp" alt="" loading="lazy"></span><span class="kodza-filter__meta"><span class="kodza-filter__name">Arrow Button</span><span class="kodza-filter__tag">Buttons</span></span></a></li>
<li class="kodza-filter__item" data-category="text"><a class="kodza-filter__card" href="#"><span class="kodza-filter__media"><img src="https://kodza.dev/media/kz-steps-1400.webp" alt="" loading="lazy"></span><span class="kodza-filter__meta"><span class="kodza-filter__name">Typewriter</span><span class="kodza-filter__tag">Text</span></span></a></li>
<li class="kodza-filter__item" data-category="images"><a class="kodza-filter__card" href="#"><span class="kodza-filter__media"><img src="https://kodza.dev/media/kz-block-1400.webp" alt="" loading="lazy"></span><span class="kodza-filter__meta"><span class="kodza-filter__name">Custom Cursor</span><span class="kodza-filter__tag">Images</span></span></a></li>
<li class="kodza-filter__item" data-category="cards"><a class="kodza-filter__card" href="#"><span class="kodza-filter__media"><img src="https://kodza.dev/media/kz-stack-1400.webp" alt="" loading="lazy"></span><span class="kodza-filter__meta"><span class="kodza-filter__name">Sticky Cards</span><span class="kodza-filter__tag">Cards</span></span></a></li>
</ul>
</div>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Filter Transitions
A filterable grid: an ink pill slides to the chosen filter (its label turning white right at
the pill's edges), the cards that go step back, the ones that stay glide to their new place,
the new ones come in one after the other, and the grid eases to its new height. The count
rolls. Give each item a data-category, each pill a data-filter ("all" shows everything).
Everything is tuned with the --kz-filter-* variables. */
/* Fills its column at zero weight: a theme that sizes its content blocks (Blocksy puts the page
gutter in each block's width) keeps the last word */
:where(.kodza-filter) {
width: 100%;
}
.kodza-filter {
--kz-filter-bg: #ffffff;
--kz-filter-fg: #0a0a0a;
--kz-filter-muted: rgba(10, 10, 10, 0.62);
--kz-filter-line: rgba(10, 10, 10, 0.1);
--kz-filter-ink: #0a0a0a;
--kz-filter-on-ink: #ffffff;
--kz-filter-columns: 4;
--kz-filter-gap: 16px;
--kz-filter-radius: 16px;
--kz-filter-ease: cubic-bezier(0.22, 1, 0.36, 1);
color: var(--kz-filter-fg);
}
.kodza-filter__bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
/* The pills: one ink pill cut to the chosen one, and a white copy of the labels cut the same */
.kodza-filter__pills {
position: relative;
display: inline-flex;
max-width: 100%;
padding: 4px;
overflow-x: auto;
border-radius: 999px;
background: var(--kz-filter-bg);
box-shadow: inset 0 0 0 1px var(--kz-filter-line);
scrollbar-width: none;
}
.kodza-filter__indicator,
.kodza-filter__mirror {
position: absolute;
inset: 0;
border-radius: 999px;
clip-path: inset(4px var(--kz-ind-r, 100%) 4px var(--kz-ind-l, 0) round 999px);
pointer-events: none;
}
.kodza-filter__indicator {
background: var(--kz-filter-ink);
}
.kodza-filter__mirror {
display: flex;
padding: 4px;
color: var(--kz-filter-on-ink);
}
.kodza-filter.is-live .kodza-filter__indicator,
.kodza-filter.is-live .kodza-filter__mirror {
transition: clip-path 0.5s var(--kz-filter-ease);
}
.kodza-filter__pill,
.kodza-filter__ghost {
display: inline-flex;
align-items: center;
height: 36px;
padding: 0 16px;
font-size: 15px;
font-weight: 500;
white-space: nowrap;
}
.kodza-filter__pill {
position: relative;
border: 0;
border-radius: 999px;
background: none;
color: var(--kz-filter-muted);
font-family: inherit;
cursor: pointer;
transition: color 0.3s ease;
}
.kodza-filter__pill:hover,
.kodza-filter__pill[aria-pressed="true"] {
color: var(--kz-filter-fg);
}
.kodza-filter__pill:focus-visible {
outline: 2px solid var(--kz-filter-fg);
outline-offset: -2px;
}
.kodza-filter__count {
margin: 0;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
color: var(--kz-filter-muted);
}
.kodza-filter__num {
display: inline-grid;
overflow: hidden;
color: var(--kz-filter-fg);
vertical-align: bottom;
}
.kodza-filter__num > span {
grid-area: 1 / 1;
}
/* The grid */
.kodza-filter__grid {
display: grid;
grid-template-columns: repeat(var(--kz-filter-columns), minmax(0, 1fr));
gap: var(--kz-filter-gap);
margin: 0;
padding: 0;
list-style: none;
}
.kodza-filter__item[hidden] {
display: none;
}
.kodza-filter__card {
display: grid;
gap: 12px;
border-radius: var(--kz-filter-radius);
color: inherit;
text-decoration: none;
}
.kodza-filter__card:focus-visible {
outline: 2px solid var(--kz-filter-fg);
outline-offset: 4px;
}
.kodza-filter__media {
display: block;
overflow: hidden;
border-radius: var(--kz-filter-radius);
aspect-ratio: 4 / 3;
}
.kodza-filter__media img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.7s var(--kz-filter-ease);
}
.kodza-filter__card:hover .kodza-filter__media img {
transform: scale(1.05);
}
.kodza-filter__meta {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.kodza-filter__name {
font-size: 15px;
font-weight: 600;
letter-spacing: -0.01em;
}
.kodza-filter__tag {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
color: var(--kz-filter-muted);
}
@media (max-width: 760px) {
.kodza-filter {
--kz-filter-columns: 2;
}
}
@media (prefers-reduced-motion: reduce) {
.kodza-filter.is-live .kodza-filter__indicator,
.kodza-filter.is-live .kodza-filter__mirror,
.kodza-filter__pill,
.kodza-filter__media img {
transition-duration: 0.01ms !important;
}
}
/* Theming example — on a dark background:
.dark-section .kodza-filter {
--kz-filter-bg: #161616;
--kz-filter-fg: #f2f2f0;
--kz-filter-muted: rgba(242, 242, 240, 0.62);
--kz-filter-line: rgba(255, 255, 255, 0.12);
--kz-filter-ink: #f2f2f0;
--kz-filter-on-ink: #0a0a0a;
}
*/
/* kodza-guard dd1521f9b10b — 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-filter__pill):not(#_),
:where(.kodza-filter__card):not(#_) {
outline: revert;
outline-offset: revert;
}
:where(.kodza-filter__item):not(#_):where(:not([hidden])) {
display: revert;
}
:where(.kodza-filter__item):not(#_):where([hidden]) {
display: none;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-filter:not(#_),
.kodza-filter__bar:not(#_),
.kodza-filter__pills:not(#_),
.kodza-filter__indicator:not(#_),
.kodza-filter__mirror:not(#_),
.kodza-filter__ghost:not(#_),
.kodza-filter__num:not(#_),
.kodza-filter__media:not(#_),
.kodza-filter__meta:not(#_),
.kodza-filter__name:not(#_),
.kodza-filter__tag:not(#_),
.kodza-filter__num span:where(:not([class*="kodza-"])):not(#_) {
box-sizing: revert;
}
.kodza-filter__pill:not(#_) {
margin: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
box-shadow: revert;
appearance: revert;
max-width: revert;
max-height: 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;
min-height: revert;
}
.kodza-filter__count: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-filter__grid:not(#_) {
border: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
transition: 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-filter__item: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-filter__card:not(#_) {
border: revert;
margin: revert;
padding: revert;
transition: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: 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-filter__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-filter__count:not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-filter__count: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-filter__pill:not(#_) {
display: inline-flex;
align-items: center;
height: 36px;
padding: 0 16px;
font-size: 15px;
font-weight: 500;
white-space: nowrap;
}
.kodza-filter__pill:not(#_) {
position: relative;
border: 0;
border-radius: 999px;
background: none;
color: var(--kz-filter-muted);
font-family: inherit;
cursor: pointer;
transition: color 0.3s ease;
}
.kodza-filter__pill:hover:not(#_),
.kodza-filter__pill[aria-pressed="true"]:not(#_) {
color: var(--kz-filter-fg);
}
.kodza-filter__pill:focus-visible:not(#_) {
outline: 2px solid var(--kz-filter-fg);
outline-offset: -2px;
}
.kodza-filter__count:not(#_) {
margin: 0;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px;
color: var(--kz-filter-muted);
}
.kodza-filter__grid:not(#_) {
display: grid;
margin: 0;
padding: 0;
list-style: none;
}
.kodza-filter__item[hidden]:not(#_) {
display: none;
}
.kodza-filter__card:not(#_) {
display: grid;
border-radius: var(--kz-filter-radius);
color: inherit;
text-decoration: none;
}
.kodza-filter__card:focus-visible:not(#_) {
outline: 2px solid var(--kz-filter-fg);
outline-offset: 4px;
}
.kodza-filter__media img:not(#_) {
display: block;
transition: transform 0.7s var(--kz-filter-ease);
}
@media (prefers-reduced-motion: reduce) {
.kodza-filter__pill:not(#_) {
transition-duration: 0.01ms !important;
}
}
@media (prefers-reduced-motion: reduce) {
.kodza-filter__media img:not(#_) {
transition-duration: 0.01ms !important;
}
}
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — Filter Transitions
// Moves the pill to the chosen filter, then plays the change in two beats: the cards that go
// step back, then the layout changes and every card that stays glides from where it was to
// where it is now (measured before and after), while the new ones come in one by one and the
// grid eases to its new height. A new click finishes the running change first.
// Fires "kodza:filter" with { filter, count } on the component.
(() => {
const SELECTOR = '.kodza-filter';
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const ease = 'cubic-bezier(0.22, 1, 0.36, 1)';
// One instance. Everything it starts is tied to `signal`, so kodza:destroy stops it all
function mount(root, signal) {
const pillsBox = root.querySelector('.kodza-filter__pills');
const pills = [...root.querySelectorAll('.kodza-filter__pill')];
const grid = root.querySelector('.kodza-filter__grid');
const items = [...root.querySelectorAll('.kodza-filter__item')];
const num = root.querySelector('.kodza-filter__num');
if (!pillsBox || !pills.length || !grid) return;
// The white copy of the labels, cut to the pill's shape
const mirror = document.createElement('span');
mirror.className = 'kodza-filter__mirror';
mirror.setAttribute('aria-hidden', 'true');
mirror.append(...pills.map((pill) => Object.assign(document.createElement('span'), { className: 'kodza-filter__ghost', textContent: pill.textContent })));
pillsBox.append(mirror);
const place = (pill) => {
pillsBox.style.setProperty('--kz-ind-l', `${pill.offsetLeft}px`);
pillsBox.style.setProperty('--kz-ind-r', `${pillsBox.scrollWidth - pill.offsetLeft - pill.offsetWidth}px`);
};
// The count rolls to its new number
if (num) num.replaceChildren(Object.assign(document.createElement('span'), { textContent: num.textContent.trim() }));
const roll = (value) => {
if (!num) return;
const prev = num.lastElementChild;
if (prev.textContent === String(value)) return;
const span = Object.assign(document.createElement('span'), { textContent: value });
num.append(span);
if (reduceMotion) return prev.remove();
const timing = { duration: 450, easing: ease };
prev.animate([{ transform: 'none' }, { transform: 'translateY(-110%)' }], { ...timing, fill: 'forwards' }).finished.then(() => prev.remove());
span.animate([{ transform: 'translateY(110%)' }, { transform: 'none' }], timing);
};
let running = [];
let outs = [];
let token = 0;
const matches = (item, filter) => filter === 'all' || (item.dataset.category || '').split(/\s+/).includes(filter);
const apply = async (filter) => {
const id = ++token;
// A change still running: the cards on their way out come back, the rest lands at once
outs.forEach((animation) => animation.cancel());
running.forEach((animation) => animation.finish());
outs = [];
running = [];
const leaving = items.filter((item) => !item.hidden && !matches(item, filter));
const entering = items.filter((item) => item.hidden && matches(item, filter));
const staying = items.filter((item) => !item.hidden && matches(item, filter));
roll(staying.length + entering.length);
if (reduceMotion) {
items.forEach((item) => (item.hidden = !matches(item, filter)));
return;
}
// Beat one: the cards that go step back
if (leaving.length) {
outs = leaving.map((item) => item.animate([{ opacity: 1, transform: 'none' }, { opacity: 0, transform: 'scale(0.94)' }], { duration: 220, easing: 'ease-in', fill: 'forwards' }));
await Promise.all(outs.map((animation) => animation.finished.catch(() => {})));
if (id !== token) return;
}
// Beat two: the layout changes; what stays glides there, what comes arrives, the grid eases
const before = new Map(staying.map((item) => [item, item.getBoundingClientRect()]));
const from = grid.offsetHeight;
leaving.forEach((item) => (item.hidden = true));
outs.forEach((animation) => animation.cancel());
outs = [];
entering.forEach((item) => (item.hidden = false));
const to = grid.offsetHeight;
// Screen pixels back to the grid's own (a parent may be scaled with a transform)
const k = grid.getBoundingClientRect().width / grid.offsetWidth || 1;
staying.forEach((item) => {
const a = before.get(item);
const b = item.getBoundingClientRect();
if (Math.abs(a.left - b.left) + Math.abs(a.top - b.top) < 1) return;
running.push(item.animate([{ transform: `translate(${(a.left - b.left) / k}px, ${(a.top - b.top) / k}px)` }, { transform: 'none' }], { duration: 650, easing: ease }));
});
entering
.sort((a, b) => a.offsetTop - b.offsetTop || a.offsetLeft - b.offsetLeft)
.forEach((item, i) => running.push(item.animate([{ opacity: 0, transform: 'translateY(16px) scale(0.96)' }, { opacity: 1, transform: 'none' }], { duration: 650, delay: 80 + i * 60, easing: ease, fill: 'backwards' })));
if (from !== to) running.push(grid.animate([{ height: `${from}px` }, { height: `${to}px` }], { duration: 650, easing: ease }));
};
const select = (pill) => {
pills.forEach((other) => other.setAttribute('aria-pressed', String(other === pill)));
place(pill);
const filter = pill.dataset.filter || 'all';
apply(filter);
root.dispatchEvent(new CustomEvent('kodza:filter', { detail: { filter, count: items.filter((item) => matches(item, filter)).length }, bubbles: true }));
};
pills.forEach((pill) => pill.addEventListener('click', () => pill.getAttribute('aria-pressed') !== 'true' && select(pill), { signal }));
const current = () => pills.find((pill) => pill.getAttribute('aria-pressed') === 'true') || pills[0];
const start = current().dataset.filter || 'all';
items.forEach((item) => (item.hidden = !matches(item, start)));
roll(items.filter((item) => !item.hidden).length);
place(current());
const observer = new ResizeObserver(() => place(current()));
observer.observe(pillsBox);
signal.addEventListener('abort', () => observer.disconnect());
document.fonts?.ready.then(() => place(current()));
requestAnimationFrame(() => !signal.aborted && root.classList.add('is-live'));
}
// 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 — Filter Transitions, for React. From https://kodza.dev/components/filter-transitions/
// 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: filter-transitions.css and filter-transitions.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 './filter-transitions.css';
export type FilterTransitionsProps = Omit<ComponentPropsWithoutRef<'div'>, "children" | "vars" | "onFilter"> & {
/** Settings, as CSS variables on the root */
vars?: {
/** default: #ffffff */
"--kz-filter-bg"?: string | number;
/** default: #0a0a0a */
"--kz-filter-fg"?: string | number;
/** default: rgba(10, 10, 10, 0.62) */
"--kz-filter-muted"?: string | number;
/** default: rgba(10, 10, 10, 0.1) */
"--kz-filter-line"?: string | number;
/** default: #0a0a0a */
"--kz-filter-ink"?: string | number;
/** default: #ffffff */
"--kz-filter-on-ink"?: string | number;
/** default: 4 */
"--kz-filter-columns"?: string | number;
/** default: 16px */
"--kz-filter-gap"?: string | number;
/** default: 16px */
"--kz-filter-radius"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-filter-ease"?: string | number;
};
/** "kodza:filter", fired by the script (bubbles from the root) */
onFilter?: (event: CustomEvent) => void;
className?: string;
};
export function FilterTransitions({ vars, onFilter, className, style, ...rest }: FilterTransitionsProps) {
const ref = useRef<ComponentRef<'div'>>(null);
const classes = ["kodza-filter", 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('./filter-transitions.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 || !onFilter) return;
const listener = (event: Event) => onFilter(event as CustomEvent);
root.addEventListener("kodza:filter", listener);
return () => root.removeEventListener("kodza:filter", listener);
}, [onFilter, key]);
return (
<div key={key} {...rest} className={classes} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<div className="kodza-filter__bar">
<div className="kodza-filter__pills" role="group" aria-label="Filter by category">
<span className="kodza-filter__indicator" aria-hidden="true" />
{' '}
<button className="kodza-filter__pill" type="button" data-filter="all" aria-pressed="true">All</button>
{' '}
<button className="kodza-filter__pill" type="button" data-filter="buttons" aria-pressed="false">Buttons</button>
{' '}
<button className="kodza-filter__pill" type="button" data-filter="text" aria-pressed="false">Text</button>
{' '}
<button className="kodza-filter__pill" type="button" data-filter="images" aria-pressed="false">Images</button>
{' '}
<button className="kodza-filter__pill" type="button" data-filter="cards" aria-pressed="false">Cards</button>
</div>
<p className="kodza-filter__count" aria-live="polite"><span className="kodza-filter__num">8</span> components</p>
</div>
<ul className="kodza-filter__grid">
<li className="kodza-filter__item" data-category="buttons">
<a className="kodza-filter__card" href="#">
<span className="kodza-filter__media">
<img src="https://kodza.dev/media/kz-block-1400.webp" alt="" loading="lazy" />
</span>
<span className="kodza-filter__meta">
<span className="kodza-filter__name">Fill Button</span>
<span className="kodza-filter__tag">Buttons</span>
</span>
</a>
</li>
<li className="kodza-filter__item" data-category="text">
<a className="kodza-filter__card" href="#">
<span className="kodza-filter__media">
<img src="https://kodza.dev/media/kz-stack-1400.webp" alt="" loading="lazy" />
</span>
<span className="kodza-filter__meta">
<span className="kodza-filter__name">Text Shimmer</span>
<span className="kodza-filter__tag">Text</span>
</span>
</a>
</li>
<li className="kodza-filter__item" data-category="images">
<a className="kodza-filter__card" href="#">
<span className="kodza-filter__media">
<img src="https://kodza.dev/media/kz-arch-1400.webp" alt="" loading="lazy" />
</span>
<span className="kodza-filter__meta">
<span className="kodza-filter__name">Image Gallery</span>
<span className="kodza-filter__tag">Images</span>
</span>
</a>
</li>
<li className="kodza-filter__item" data-category="cards">
<a className="kodza-filter__card" href="#">
<span className="kodza-filter__media">
<img src="https://kodza.dev/media/kz-columns-1400.webp" alt="" loading="lazy" />
</span>
<span className="kodza-filter__meta">
<span className="kodza-filter__name">Pricing Plans</span>
<span className="kodza-filter__tag">Cards</span>
</span>
</a>
</li>
<li className="kodza-filter__item" data-category="buttons">
<a className="kodza-filter__card" href="#">
<span className="kodza-filter__media">
<img src="https://kodza.dev/media/kz-float-1400.webp" alt="" loading="lazy" />
</span>
<span className="kodza-filter__meta">
<span className="kodza-filter__name">Arrow Button</span>
<span className="kodza-filter__tag">Buttons</span>
</span>
</a>
</li>
<li className="kodza-filter__item" data-category="text">
<a className="kodza-filter__card" href="#">
<span className="kodza-filter__media">
<img src="https://kodza.dev/media/kz-steps-1400.webp" alt="" loading="lazy" />
</span>
<span className="kodza-filter__meta">
<span className="kodza-filter__name">Typewriter</span>
<span className="kodza-filter__tag">Text</span>
</span>
</a>
</li>
<li className="kodza-filter__item" data-category="images">
<a className="kodza-filter__card" href="#">
<span className="kodza-filter__media">
<img src="https://kodza.dev/media/kz-block-1400.webp" alt="" loading="lazy" />
</span>
<span className="kodza-filter__meta">
<span className="kodza-filter__name">Custom Cursor</span>
<span className="kodza-filter__tag">Images</span>
</span>
</a>
</li>
<li className="kodza-filter__item" data-category="cards">
<a className="kodza-filter__card" href="#">
<span className="kodza-filter__media">
<img src="https://kodza.dev/media/kz-stack-1400.webp" alt="" loading="lazy" />
</span>
<span className="kodza-filter__meta">
<span className="kodza-filter__name">Sticky Cards</span>
<span className="kodza-filter__tag">Cards</span>
</span>
</a>
</li>
</ul>
</div>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.