Patterns Medium
View Transition Grid Filter Identity
Filterable grid that preserves card identity while items reorder.
Open in Lab
MCP
view-transitions filter css js
Targets: JS HTML
Code
:root {
--bg: #0a0f18;
--panel: #131c2e;
--line: #2a3751;
--text: #eef4ff;
--muted: #bfd0e8;
--accent: #8de3ff;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
color: var(--text);
font-family: "Avenir Next", "Segoe UI", sans-serif;
background: radial-gradient(circle at 10% 8%, rgba(88, 140, 255, 0.24), transparent 40%),
radial-gradient(circle at 84% 80%, rgba(174, 90, 255, 0.22), transparent 42%), var(--bg);
}
.topbar {
padding: 0.85rem 1rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.topbar a {
color: var(--accent);
font-weight: 700;
text-decoration: none;
}
.support {
margin: 0;
color: var(--muted);
font-size: 0.86rem;
}
.support.ok {
color: #aff0cc;
}
.support.warn {
color: #ffd6ad;
}
main {
width: min(1120px, 94%);
margin: 0 auto 2.5rem;
}
.heading {
display: grid;
gap: 0.5rem;
}
.eyebrow {
margin: 0;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: 0.8rem;
}
h1,
p {
margin: 0;
}
.heading p:last-child {
color: var(--muted);
}
.controls {
margin-top: 1rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.filter {
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 999px;
padding: 0.35rem 0.7rem;
background: rgba(255, 255, 255, 0.04);
color: var(--text);
cursor: pointer;
}
.filter.active {
border-color: rgba(141, 227, 255, 0.7);
box-shadow: 0 0 0 1px rgba(141, 227, 255, 0.38) inset;
}
.meta {
margin-top: 0.75rem;
color: var(--muted);
}
.grid {
margin-top: 1rem;
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
}
.card {
border: 1px solid var(--line);
border-radius: 16px;
background: linear-gradient(160deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.02));
padding: 0.85rem;
display: grid;
gap: 0.55rem;
}
.thumb {
min-height: 120px;
border-radius: 12px;
}
.tag {
width: fit-content;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 999px;
padding: 0.12rem 0.5rem;
font-size: 0.74rem;
color: #d9e7fb;
}
.card p {
color: var(--muted);
font-size: 0.92rem;
}
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 280ms;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
}if (!window.MotionPreference) {
const __mql = window.matchMedia("(prefers-reduced-motion: reduce)");
const __listeners = new Set();
const MotionPreference = {
prefersReducedMotion() {
return __mql.matches;
},
setOverride(value) {
const reduced = Boolean(value);
document.documentElement.classList.toggle("reduced-motion", reduced);
window.dispatchEvent(new CustomEvent("motion-preference", { detail: { reduced } }));
for (const listener of __listeners) {
try {
listener({ reduced, override: reduced, systemReduced: __mql.matches });
} catch {}
}
},
onChange(listener) {
__listeners.add(listener);
try {
listener({
reduced: __mql.matches,
override: null,
systemReduced: __mql.matches,
});
} catch {}
return () => __listeners.delete(listener);
},
getState() {
return { reduced: __mql.matches, override: null, systemReduced: __mql.matches };
},
};
window.MotionPreference = MotionPreference;
}
const items = [
{
id: "n1",
title: "Pulse Launcher",
category: "product",
blurb: "Launch-focused hero with CTA pacing.",
color: "linear-gradient(130deg,#59c8ff,#294bff)",
},
{
id: "n2",
title: "Studio Grid",
category: "portfolio",
blurb: "Project archive with retained card identity.",
color: "linear-gradient(130deg,#ff88e9,#8f3fff)",
},
{
id: "n3",
title: "Arena Promo",
category: "campaign",
blurb: "High-energy campaign teaser cards.",
color: "linear-gradient(130deg,#7da3d8,#293f63)",
},
{
id: "n4",
title: "Nova Metrics",
category: "dashboard",
blurb: "Data cards opening deep detail routes.",
color: "linear-gradient(130deg,#ffd983,#ff8f3b)",
},
{
id: "n5",
title: "Cover Story",
category: "campaign",
blurb: "Magazine-style featured narrative block.",
color: "linear-gradient(130deg,#7ce0ff,#00a8c7)",
},
{
id: "n6",
title: "Studio Case",
category: "portfolio",
blurb: "Portfolio details with animation fallback.",
color: "linear-gradient(130deg,#cb8aff,#7642cf)",
},
{
id: "n7",
title: "Ops Console",
category: "dashboard",
blurb: "Operational cards with dense metadata.",
color: "linear-gradient(130deg,#ffe7a9,#d38d30)",
},
{
id: "n8",
title: "Nova Device",
category: "product",
blurb: "Product card with staged reveal sections.",
color: "linear-gradient(130deg,#98f0ff,#4e82ff)",
},
];
const filters = ["all", "product", "portfolio", "campaign", "dashboard"];
const grid = document.getElementById("grid");
const controls = document.getElementById("controls");
const meta = document.getElementById("meta");
const support = document.getElementById("support");
const reduced = window.MotionPreference.prefersReducedMotion();
const state = { filter: "all" };
function transition(update) {
if (!reduced && document.startViewTransition) {
document.startViewTransition(update);
} else {
update();
}
}
function filtered() {
if (state.filter === "all") return items;
return items.filter((item) => item.category === state.filter);
}
function renderControls() {
controls.innerHTML = "";
filters.forEach((name) => {
const button = document.createElement("button");
button.className = `filter ${state.filter === name ? "active" : ""}`;
button.textContent = name;
button.addEventListener("click", () => {
if (state.filter === name) return;
transition(() => {
state.filter = name;
render();
});
});
controls.appendChild(button);
});
}
function renderGrid() {
grid.innerHTML = "";
const list = filtered();
list.forEach((item) => {
const card = document.createElement("article");
card.className = "card";
card.style.viewTransitionName = `card-${item.id}`;
card.innerHTML = `
<div class="thumb" style="background:${item.color};view-transition-name:thumb-${item.id}"></div>
<p class="tag">${item.category}</p>
<h2>${item.title}</h2>
<p>${item.blurb}</p>
`;
grid.appendChild(card);
});
meta.textContent = `${list.length} item(s) visible in "${state.filter}".`;
}
function renderSupport() {
if (document.startViewTransition && !reduced) {
support.textContent = "View transitions enabled: shared cards animate between filters.";
support.classList.add("ok");
} else {
support.textContent = "Fallback mode: filters still work, but updates are instant.";
support.classList.add("warn");
}
}
function render() {
renderControls();
renderGrid();
}
renderSupport();
render();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Demo 17 - Grid Filter Identity</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header class="topbar">
<a href="../">Back to demos</a>
<p id="support" class="support"></p>
</header>
<main>
<section class="heading">
<p class="eyebrow">Demo 17</p>
<h1>Grid Filter With Preserved Identity</h1>
<p>Cards keep identity while filters reorder and remove items.</p>
</section>
<section class="controls" id="controls" aria-label="Category filters"></section>
<p class="meta" id="meta"></p>
<section class="grid" id="grid"></section>
</main>
<script src="script.js"></script>
</body>
</html>View Transition Grid Filter Identity
Filterable grid that preserves card identity while items reorder.
Source
- Repository:
libs-gen - Original demo id:
17-view-transition-grid-filter
Notes
Filterable grid that preserves card identity while items reorder.