Portfolio — Work Grid + Filter
A filterable portfolio work grid with category chips (All, Product, Web, Branding, Motion), a Recent/A–Z sort, and live result counts. Tiles use CSS-gradient thumbnails, titles, years, and craft tags, and reflow with a FLIP-style animation as you filter and sort. Each tile opens an accessible quick-look dialog with project detail, and an empty state appears when a category has no matches. Vanilla JS, no images, no dependencies.
MCP
Code
:root {
--bg: #f7f7f5;
--surface: #ffffff;
--ink: #16161a;
--ink-2: #5b5b66;
--ink-3: #8a8a96;
--line: #e7e7e2;
--line-2: #d9d9d2;
--accent: #4f46e5;
--accent-soft: #eceaff;
--accent-ink: #3a32c4;
--focus: #4f46e5;
--radius: 16px;
--radius-sm: 10px;
--shadow-sm: 0 1px 2px rgba(22, 22, 26, 0.05);
--shadow: 0 12px 30px -14px rgba(22, 22, 26, 0.28);
--shadow-lg: 0 30px 70px -24px rgba(22, 22, 26, 0.42);
--ease: cubic-bezier(0.22, 0.61, 0.36, 1);
--font: "Inter", system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
--display: "Inter Tight", "Inter", system-ui, sans-serif;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
font-family: var(--font);
background: var(--bg);
color: var(--ink);
line-height: 1.55;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
*:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 3px;
border-radius: 6px;
}
.skip-link {
position: absolute;
left: 12px;
top: -50px;
z-index: 50;
background: var(--ink);
color: #fff;
padding: 0.55rem 0.9rem;
border-radius: 8px;
font-weight: 600;
font-size: 0.85rem;
text-decoration: none;
transition: top 0.2s var(--ease);
}
.skip-link:focus {
top: 12px;
}
/* ---------- Intro ---------- */
.intro {
padding: clamp(2.6rem, 6vw, 4.5rem) 1.25rem 1.4rem;
}
.intro__inner {
max-width: 1080px;
margin: 0 auto;
}
.intro__mark {
display: inline-block;
font-size: 1.5rem;
color: var(--accent);
margin-bottom: 0.6rem;
transform: translateY(2px);
}
.intro__eyebrow {
margin: 0 0 0.5rem;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-3);
}
.intro__title {
margin: 0 0 0.6rem;
font-family: var(--display);
font-size: clamp(2.4rem, 7vw, 4rem);
font-weight: 800;
letter-spacing: -0.03em;
line-height: 1.02;
}
.intro__lede {
margin: 0;
max-width: 46ch;
font-size: clamp(1rem, 2.4vw, 1.12rem);
color: var(--ink-2);
}
/* ---------- Work shell ---------- */
.work {
max-width: 1080px;
margin: 0 auto;
padding: 0 1.25rem 4rem;
}
.work__bar {
position: sticky;
top: 0;
z-index: 20;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.9rem 1.2rem;
padding: 0.9rem 0;
margin-bottom: 0.4rem;
background: linear-gradient(var(--bg) 70%, rgba(247, 247, 245, 0));
}
/* ---------- Chips ---------- */
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-family: inherit;
font-size: 0.9rem;
font-weight: 600;
color: var(--ink-2);
background: var(--surface);
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.5rem 0.9rem;
cursor: pointer;
box-shadow: var(--shadow-sm);
transition:
color 0.18s var(--ease),
border-color 0.18s var(--ease),
background 0.18s var(--ease),
transform 0.12s var(--ease);
}
.chip:hover {
color: var(--ink);
border-color: var(--line-2);
transform: translateY(-1px);
}
.chip:active {
transform: translateY(0);
}
.chip.is-active {
color: #fff;
background: var(--ink);
border-color: var(--ink);
}
.chip__count {
font-size: 0.72rem;
font-weight: 700;
min-width: 1.35em;
padding: 0.05rem 0.35rem;
text-align: center;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent-ink);
}
.chip.is-active .chip__count {
background: rgba(255, 255, 255, 0.18);
color: #fff;
}
/* ---------- Sort ---------- */
.sort {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.sort__label {
font-size: 0.85rem;
font-weight: 600;
color: var(--ink-3);
}
.sort__field {
position: relative;
display: inline-flex;
align-items: center;
}
.sort__select {
appearance: none;
-webkit-appearance: none;
font-family: inherit;
font-size: 0.9rem;
font-weight: 600;
color: var(--ink);
background: var(--surface);
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.5rem 2.1rem 0.5rem 0.95rem;
cursor: pointer;
box-shadow: var(--shadow-sm);
transition: border-color 0.18s var(--ease);
}
.sort__select:hover {
border-color: var(--line-2);
}
.sort__chevron {
position: absolute;
right: 0.85rem;
font-size: 0.7rem;
color: var(--ink-3);
pointer-events: none;
}
/* ---------- Result line ---------- */
.result {
margin: 0.2rem 0 1.4rem;
font-size: 0.9rem;
color: var(--ink-3);
}
.result strong {
color: var(--ink);
font-weight: 700;
}
/* ---------- Grid ---------- */
.grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.1rem;
}
.tile {
position: relative;
display: flex;
flex-direction: column;
text-align: left;
width: 100%;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
cursor: pointer;
font-family: inherit;
color: inherit;
box-shadow: var(--shadow-sm);
transition:
transform 0.32s var(--ease),
opacity 0.32s var(--ease),
box-shadow 0.28s var(--ease),
border-color 0.28s var(--ease);
}
.tile:hover {
transform: translateY(-4px);
box-shadow: var(--shadow);
border-color: var(--line-2);
}
.tile:hover .tile__thumb {
transform: scale(1.04);
}
.tile.is-hidden {
display: none;
}
/* FLIP transition states */
.tile.flip-enter {
opacity: 0;
transform: translateY(14px) scale(0.97);
}
.tile__media {
position: relative;
aspect-ratio: 16 / 10;
overflow: hidden;
border-bottom: 1px solid var(--line);
}
.tile__thumb {
position: absolute;
inset: 0;
transition: transform 0.5s var(--ease);
}
.tile__glyph {
position: absolute;
right: 0.9rem;
bottom: 0.7rem;
font-family: var(--display);
font-size: 2.1rem;
font-weight: 800;
color: rgba(255, 255, 255, 0.85);
text-shadow: 0 2px 14px rgba(0, 0, 0, 0.25);
}
.tile__cat {
position: absolute;
left: 0.85rem;
top: 0.8rem;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ink);
background: rgba(255, 255, 255, 0.92);
padding: 0.25rem 0.55rem;
border-radius: 999px;
backdrop-filter: blur(4px);
}
.tile__body {
padding: 0.95rem 1rem 1.05rem;
display: flex;
flex-direction: column;
gap: 0.55rem;
flex: 1;
}
.tile__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.6rem;
}
.tile__title {
margin: 0;
font-family: var(--display);
font-size: 1.12rem;
font-weight: 700;
letter-spacing: -0.01em;
}
.tile__year {
font-size: 0.78rem;
font-weight: 600;
color: var(--ink-3);
flex-shrink: 0;
}
.tile__tags {
list-style: none;
margin: auto 0 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.tile__tags li {
font-size: 0.74rem;
font-weight: 600;
color: var(--ink-2);
background: var(--bg);
border: 1px solid var(--line);
border-radius: 6px;
padding: 0.15rem 0.45rem;
}
/* ---------- Empty state ---------- */
.empty {
text-align: center;
padding: 3.5rem 1rem;
border: 1px dashed var(--line-2);
border-radius: var(--radius);
background: var(--surface);
}
.empty__mark {
display: inline-block;
font-size: 2rem;
color: var(--ink-3);
margin-bottom: 0.4rem;
}
.empty__title {
margin: 0 0 0.3rem;
font-family: var(--display);
font-size: 1.25rem;
font-weight: 700;
}
.empty__sub {
margin: 0 0 1.2rem;
color: var(--ink-2);
}
.empty__reset {
font-family: inherit;
font-size: 0.9rem;
font-weight: 600;
color: #fff;
background: var(--ink);
border: none;
border-radius: 999px;
padding: 0.6rem 1.2rem;
cursor: pointer;
transition: transform 0.12s var(--ease), background 0.18s var(--ease);
}
.empty__reset:hover {
background: var(--accent);
transform: translateY(-1px);
}
/* ---------- Quick look ---------- */
.quicklook {
position: fixed;
inset: 0;
z-index: 60;
display: grid;
place-items: center;
padding: 1.25rem;
}
.quicklook[hidden] {
display: none;
}
.quicklook__scrim {
position: absolute;
inset: 0;
background: rgba(16, 16, 22, 0.5);
backdrop-filter: blur(3px);
animation: fade 0.25s var(--ease);
}
.quicklook__panel {
position: relative;
z-index: 1;
width: min(640px, 100%);
max-height: 90vh;
overflow: auto;
background: var(--surface);
border-radius: 20px;
box-shadow: var(--shadow-lg);
animation: pop 0.32s var(--ease);
}
.quicklook__close {
position: absolute;
right: 0.8rem;
top: 0.8rem;
z-index: 2;
width: 34px;
height: 34px;
display: grid;
place-items: center;
font-size: 0.85rem;
color: var(--ink);
background: rgba(255, 255, 255, 0.9);
border: 1px solid var(--line);
border-radius: 50%;
cursor: pointer;
transition: background 0.18s var(--ease), transform 0.12s var(--ease);
}
.quicklook__close:hover {
background: #fff;
transform: rotate(90deg);
}
.quicklook__thumb {
height: 200px;
}
.quicklook__body {
padding: 1.5rem 1.6rem 1.7rem;
}
.quicklook__cat {
margin: 0 0 0.4rem;
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent-ink);
}
.quicklook__title {
margin: 0 0 0.3rem;
font-family: var(--display);
font-size: clamp(1.5rem, 4vw, 2rem);
font-weight: 800;
letter-spacing: -0.02em;
}
.quicklook__year {
margin: 0 0 1rem;
font-size: 0.88rem;
font-weight: 600;
color: var(--ink-3);
}
.quicklook__desc {
margin: 0 0 1.1rem;
color: var(--ink-2);
}
.quicklook__tags {
list-style: none;
margin: 0 0 1.4rem;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.quicklook__tags li {
font-size: 0.78rem;
font-weight: 600;
color: var(--ink-2);
background: var(--bg);
border: 1px solid var(--line);
border-radius: 7px;
padding: 0.2rem 0.55rem;
}
.quicklook__link {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.92rem;
font-weight: 700;
color: #fff;
background: var(--accent);
text-decoration: none;
border-radius: 999px;
padding: 0.65rem 1.25rem;
transition: background 0.18s var(--ease), transform 0.12s var(--ease);
}
.quicklook__link:hover {
background: var(--accent-ink);
transform: translateY(-1px);
}
/* ---------- Toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 1.5rem;
transform: translate(-50%, 1.4rem);
z-index: 80;
max-width: calc(100% - 2rem);
padding: 0.7rem 1.1rem;
font-size: 0.9rem;
font-weight: 600;
color: #fff;
background: var(--ink);
border-radius: 999px;
box-shadow: var(--shadow);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s var(--ease), transform 0.25s var(--ease);
}
.toast.is-on {
opacity: 1;
transform: translate(-50%, 0);
}
@keyframes fade {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes pop {
from { opacity: 0; transform: translateY(16px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* ---------- Responsive ---------- */
@media (max-width: 640px) {
.work__bar {
flex-direction: column;
align-items: stretch;
}
.sort {
justify-content: space-between;
}
.sort__field,
.sort__select {
flex: 1;
}
.grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 380px) {
.chip {
font-size: 0.82rem;
padding: 0.45rem 0.7rem;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
}(function () {
"use strict";
/* ---------- Data (fictional but believable) ---------- */
var PROJECTS = [
{
title: "Meridian Banking App",
cat: "product",
year: 2026,
role: "Lead product designer",
grad: "linear-gradient(135deg,#4f46e5,#7c75f5 55%,#a8a2ff)",
glyph: "M",
tags: ["iOS", "Design system", "Onboarding"],
desc: "A complete rebuild of Meridian's mobile banking flow — a calmer dashboard, predictive transfers, and an onboarding that lifted activation by 23%.",
},
{
title: "Harvest Logistics",
cat: "product",
year: 2025,
role: "Senior designer",
grad: "linear-gradient(135deg,#0f9d6b,#34c38f 60%,#a7f3d0)",
glyph: "H",
tags: ["B2B", "Dashboards", "Maps"],
desc: "Routing and dispatch tooling for a regional produce network. Replaced spreadsheets with a live map and shaved 40 minutes off the average daily plan.",
},
{
title: "Studio Lumen Site",
cat: "web",
year: 2026,
role: "Designer & front-end",
grad: "linear-gradient(135deg,#f97316,#fb923c 55%,#fed7aa)",
glyph: "L",
tags: ["Marketing", "CMS", "Motion"],
desc: "A warm, editorial marketing site for an architecture studio. Built on a flexible CMS so the team can publish new projects without a developer.",
},
{
title: "Northwind Docs",
cat: "web",
year: 2024,
role: "Product designer",
grad: "linear-gradient(135deg,#0ea5e9,#38bdf8 60%,#bae6fd)",
glyph: "N",
tags: ["Docs", "Search", "DX"],
desc: "Developer documentation with instant search and runnable examples. Support tickets about setup dropped by a third in the first quarter.",
},
{
title: "Bloom Coffee Identity",
cat: "branding",
year: 2025,
role: "Brand designer",
grad: "linear-gradient(135deg,#db2777,#ec4899 55%,#fbcfe8)",
glyph: "B",
tags: ["Logo", "Packaging", "Type"],
desc: "A full identity for a neighbourhood roastery — wordmark, bag system, and a flexible type pairing that scales from labels to storefront.",
},
{
title: "Atlas Conference",
cat: "branding",
year: 2023,
role: "Art director",
grad: "linear-gradient(135deg,#7c3aed,#a855f7 55%,#e9d5ff)",
glyph: "A",
tags: ["Event", "System", "Signage"],
desc: "Visual identity for a 2,000-person design conference: badges, stage graphics, and a generative pattern that made every track feel distinct.",
},
{
title: "Pulse Onboarding Reel",
cat: "motion",
year: 2026,
role: "Motion designer",
grad: "linear-gradient(135deg,#0891b2,#06b6d4 55%,#a5f3fc)",
glyph: "P",
tags: ["Animation", "Product", "UI"],
desc: "A 40-second product walkthrough built from real UI, animated to make a dense feature set feel friendly. Used across web, sales, and app stores.",
},
{
title: "Drift Loading States",
cat: "motion",
year: 2024,
role: "Interaction designer",
grad: "linear-gradient(135deg,#e11d48,#f43f5e 55%,#fecdd3)",
glyph: "D",
tags: ["Micro-interaction", "Prototype"],
desc: "A library of playful loading and transition states for a music app — each one prototyped, timed, and handed off as production-ready specs.",
},
{
title: "Field Notes CRM",
cat: "product",
year: 2023,
role: "Product designer",
grad: "linear-gradient(135deg,#475569,#64748b 55%,#cbd5e1)",
glyph: "F",
tags: ["CRM", "Tables", "Filters"],
desc: "A lightweight CRM for field sales teams. Designed an offline-first record view and a fast filter model that survives a flaky signal.",
},
{
title: "Verde Reports",
cat: "web",
year: 2025,
role: "Designer",
grad: "linear-gradient(135deg,#65a30d,#84cc16 55%,#d9f99d)",
glyph: "V",
tags: ["Data viz", "Print", "Web"],
desc: "Annual sustainability reports that read well on screen and in print. A modular chart kit kept twelve markets visually consistent.",
},
{
title: "Cargo Brandbook",
cat: "branding",
year: 2026,
role: "Brand & systems",
grad: "linear-gradient(135deg,#ca8a04,#eab308 55%,#fef08a)",
glyph: "C",
tags: ["Guidelines", "Tokens"],
desc: "A living brand system delivered as a website and design tokens, so engineering and design pull from the same source of truth.",
},
{
title: "Echo Title Sequence",
cat: "motion",
year: 2025,
role: "Motion lead",
grad: "linear-gradient(135deg,#1e293b,#334155 55%,#94a3b8)",
glyph: "E",
tags: ["Title", "Type", "Sound"],
desc: "An opening title sequence for a documentary series — kinetic typography cut to an original score, balancing tension and clarity.",
},
];
var CAT_LABEL = {
all: "All",
product: "Product",
web: "Web",
branding: "Branding",
motion: "Motion",
};
/* ---------- DOM refs ---------- */
var grid = document.getElementById("grid");
var chips = Array.prototype.slice.call(document.querySelectorAll(".chip"));
var sortSel = document.getElementById("sort");
var resultCount = document.getElementById("resultCount");
var emptyEl = document.getElementById("empty");
var resetEmpty = document.getElementById("resetEmpty");
var toastEl = document.getElementById("toast");
var ql = document.getElementById("quicklook");
var qlPanel = ql.querySelector(".quicklook__panel");
var qlThumb = document.getElementById("qlThumb");
var qlCat = document.getElementById("qlCat");
var qlTitle = document.getElementById("qlTitle");
var qlYear = document.getElementById("qlYear");
var qlDesc = document.getElementById("qlDesc");
var qlTags = document.getElementById("qlTags");
var qlLink = document.getElementById("qlLink");
var state = { filter: "all", sort: "recent" };
var lastFocused = null;
var reduceMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
/* ---------- Toast helper ---------- */
var toastTimer;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("is-on");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("is-on");
}, 2200);
}
/* ---------- Build a tile ---------- */
function buildTile(p, index) {
var li = document.createElement("li");
var btn = document.createElement("button");
btn.className = "tile";
btn.type = "button";
btn.dataset.cat = p.cat;
btn.dataset.index = String(index);
btn.setAttribute(
"aria-label",
p.title + " — " + CAT_LABEL[p.cat] + ", " + p.year + ". Open quick look."
);
var media = document.createElement("div");
media.className = "tile__media";
var thumb = document.createElement("div");
thumb.className = "tile__thumb";
thumb.style.background = p.grad;
var cat = document.createElement("span");
cat.className = "tile__cat";
cat.textContent = CAT_LABEL[p.cat];
var glyph = document.createElement("span");
glyph.className = "tile__glyph";
glyph.textContent = p.glyph;
media.appendChild(thumb);
media.appendChild(cat);
media.appendChild(glyph);
var body = document.createElement("div");
body.className = "tile__body";
var head = document.createElement("div");
head.className = "tile__head";
var title = document.createElement("h3");
title.className = "tile__title";
title.textContent = p.title;
var year = document.createElement("span");
year.className = "tile__year";
year.textContent = p.year;
head.appendChild(title);
head.appendChild(year);
var tags = document.createElement("ul");
tags.className = "tile__tags";
p.tags.forEach(function (t) {
var tli = document.createElement("li");
tli.textContent = t;
tags.appendChild(tli);
});
body.appendChild(head);
body.appendChild(tags);
btn.appendChild(media);
btn.appendChild(body);
li.appendChild(btn);
btn.addEventListener("click", function () {
openQuickLook(p, btn);
});
return li;
}
/* ---------- Sorting ---------- */
function sortedIndices() {
var idx = PROJECTS.map(function (_, i) {
return i;
});
if (state.sort === "az") {
idx.sort(function (a, b) {
return PROJECTS[a].title.localeCompare(PROJECTS[b].title);
});
} else {
idx.sort(function (a, b) {
if (PROJECTS[b].year !== PROJECTS[a].year) {
return PROJECTS[b].year - PROJECTS[a].year;
}
return PROJECTS[a].title.localeCompare(PROJECTS[b].title);
});
}
return idx;
}
/* ---------- FLIP-style render ---------- */
function render() {
// First: record current positions of existing tiles.
var existing = Array.prototype.slice.call(grid.children);
var firstRects = {};
existing.forEach(function (li) {
var key = li.firstChild.dataset.index;
firstRects[key] = li.getBoundingClientRect();
});
// Determine the ordered, filtered list.
var order = sortedIndices().filter(function (i) {
return state.filter === "all" || PROJECTS[i].cat === state.filter;
});
// Build a lookup of already-rendered <li> by index.
var byIndex = {};
existing.forEach(function (li) {
byIndex[li.firstChild.dataset.index] = li;
});
var fragment = document.createDocumentFragment();
var entering = [];
order.forEach(function (i) {
var li = byIndex[String(i)];
if (!li) {
li = buildTile(PROJECTS[i], i);
entering.push(li);
}
fragment.appendChild(li);
});
// Tiles that exist now but are not in the new order = leaving.
existing.forEach(function (li) {
if (order.indexOf(Number(li.firstChild.dataset.index)) === -1) {
li.remove();
}
});
grid.appendChild(fragment);
// Last + Invert + Play for moved tiles.
if (!reduceMotion) {
Array.prototype.slice.call(grid.children).forEach(function (li) {
var key = li.firstChild.dataset.index;
var prev = firstRects[key];
if (!prev) return;
var now = li.getBoundingClientRect();
var dx = prev.left - now.left;
var dy = prev.top - now.top;
if (dx === 0 && dy === 0) return;
li.firstChild.style.transition = "none";
li.firstChild.style.transform =
"translate(" + dx + "px," + dy + "px)";
requestAnimationFrame(function () {
li.firstChild.style.transition = "";
li.firstChild.style.transform = "";
});
});
// Animate newly entering tiles.
entering.forEach(function (li, n) {
var t = li.firstChild;
t.classList.add("flip-enter");
requestAnimationFrame(function () {
setTimeout(function () {
t.classList.remove("flip-enter");
}, n * 35);
});
});
}
// Result count + empty state.
var n = order.length;
resultCount.textContent = String(n);
document.querySelector(".result").style.display = n ? "" : "none";
if (n === 0) {
grid.hidden = true;
emptyEl.hidden = false;
} else {
grid.hidden = false;
emptyEl.hidden = true;
}
}
/* ---------- Counts per category ---------- */
function paintCounts() {
var counts = { all: PROJECTS.length };
PROJECTS.forEach(function (p) {
counts[p.cat] = (counts[p.cat] || 0) + 1;
});
document.querySelectorAll(".chip__count").forEach(function (el) {
var key = el.getAttribute("data-count");
el.textContent = String(counts[key] || 0);
});
}
/* ---------- Filter chips ---------- */
function setFilter(value) {
state.filter = value;
chips.forEach(function (c) {
var on = c.dataset.filter === value;
c.classList.toggle("is-active", on);
c.setAttribute("aria-pressed", on ? "true" : "false");
});
render();
}
chips.forEach(function (c) {
c.addEventListener("click", function () {
setFilter(c.dataset.filter);
});
});
sortSel.addEventListener("change", function () {
state.sort = sortSel.value;
render();
});
resetEmpty.addEventListener("click", function () {
setFilter("all");
toast("Showing all projects");
});
/* ---------- Quick look ---------- */
function openQuickLook(p, sourceBtn) {
lastFocused = sourceBtn || document.activeElement;
qlThumb.style.background = p.grad;
qlCat.textContent = CAT_LABEL[p.cat];
qlTitle.textContent = p.title;
qlYear.textContent = p.year + " · " + p.role;
qlDesc.textContent = p.desc;
qlTags.innerHTML = "";
p.tags.forEach(function (t) {
var li = document.createElement("li");
li.textContent = t;
qlTags.appendChild(li);
});
ql.hidden = false;
document.body.style.overflow = "hidden";
requestAnimationFrame(function () {
qlPanel.focus();
});
}
function closeQuickLook() {
if (ql.hidden) return;
ql.hidden = true;
document.body.style.overflow = "";
if (lastFocused && lastFocused.focus) lastFocused.focus();
}
ql.addEventListener("click", function (e) {
if (e.target.hasAttribute("data-close")) closeQuickLook();
});
qlLink.addEventListener("click", function (e) {
e.preventDefault();
toast("Case study is illustrative in this demo");
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape") closeQuickLook();
// Simple focus trap within the open dialog.
if (e.key === "Tab" && !ql.hidden) {
var focusables = ql.querySelectorAll(
'button, [href], select, [tabindex]:not([tabindex="-1"])'
);
if (!focusables.length) return;
var first = focusables[0];
var last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
});
/* ---------- Init ---------- */
paintCounts();
render();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Portfolio — Work Grid + Filter</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Inter+Tight:wght@600;700;800;900&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<a class="skip-link" href="#work">Skip to work</a>
<header class="intro" role="banner">
<div class="intro__inner">
<span class="intro__mark" aria-hidden="true">◗</span>
<p class="intro__eyebrow">Selected work · 2021–2026</p>
<h1 class="intro__title">Maya Okafor</h1>
<p class="intro__lede">
Product designer crafting calm, useful interfaces for tools people
rely on every day. Filter the grid below by craft, or sort to find
something specific.
</p>
</div>
</header>
<main id="work" class="work" role="main">
<div class="work__bar" role="region" aria-label="Filter and sort projects">
<div
class="chips"
role="group"
aria-label="Filter projects by category"
>
<button class="chip is-active" type="button" data-filter="all" aria-pressed="true">
All <span class="chip__count" data-count="all">12</span>
</button>
<button class="chip" type="button" data-filter="product" aria-pressed="false">
Product <span class="chip__count" data-count="product">0</span>
</button>
<button class="chip" type="button" data-filter="web" aria-pressed="false">
Web <span class="chip__count" data-count="web">0</span>
</button>
<button class="chip" type="button" data-filter="branding" aria-pressed="false">
Branding <span class="chip__count" data-count="branding">0</span>
</button>
<button class="chip" type="button" data-filter="motion" aria-pressed="false">
Motion <span class="chip__count" data-count="motion">0</span>
</button>
</div>
<div class="sort">
<label class="sort__label" for="sort">Sort</label>
<div class="sort__field">
<select id="sort" class="sort__select">
<option value="recent">Most recent</option>
<option value="az">Title A–Z</option>
</select>
<span class="sort__chevron" aria-hidden="true">▾</span>
</div>
</div>
</div>
<p class="result" aria-live="polite">
Showing <strong id="resultCount">12</strong> projects
</p>
<ul class="grid" id="grid" aria-label="Project grid"></ul>
<div class="empty" id="empty" hidden>
<span class="empty__mark" aria-hidden="true">⌕</span>
<p class="empty__title">No projects in this category</p>
<p class="empty__sub">Try another filter to see more of the work.</p>
<button class="empty__reset" type="button" id="resetEmpty">
Show all projects
</button>
</div>
</main>
<!-- Quick-look dialog -->
<div class="quicklook" id="quicklook" hidden>
<div class="quicklook__scrim" data-close></div>
<div
class="quicklook__panel"
role="dialog"
aria-modal="true"
aria-labelledby="qlTitle"
tabindex="-1"
>
<button class="quicklook__close" type="button" data-close aria-label="Close quick look">
✕
</button>
<div class="quicklook__thumb" id="qlThumb" aria-hidden="true"></div>
<div class="quicklook__body">
<p class="quicklook__cat" id="qlCat">Product</p>
<h2 class="quicklook__title" id="qlTitle">Project</h2>
<p class="quicklook__year" id="qlYear">2026 · Lead designer</p>
<p class="quicklook__desc" id="qlDesc"></p>
<ul class="quicklook__tags" id="qlTags" aria-label="Project tags"></ul>
<a class="quicklook__link" id="qlLink" href="#" data-toast>
View case study →
</a>
</div>
</div>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Work Grid + Filter
A drop-in projects section for a single-person portfolio. Category chips (All, Product, Web, Branding, Motion) filter a responsive tile grid, each chip carrying its own live count. A sort control re-orders the visible work by Most recent or Title A–Z, and a result line announces how many projects are showing.
Filtering and sorting animate the tiles with a FLIP-style reflow: existing tiles glide to their new positions while entering tiles fade and rise into place, so the grid never jumps. Tiles render from a small data array with CSS-gradient thumbnails, a craft badge, title, year, and tags — no external images. Clicking a tile opens a focus-trapped quick-look dialog (Escape, scrim click, or close button to dismiss) with a longer description and tags. When a category is empty, a tidy empty state offers a one-click reset back to all projects.
Everything is keyboard-usable with visible focus rings, aria-pressed chips, a
live result region, and reduced-motion support. Pure HTML, CSS, and vanilla
JavaScript — paste it in and swap the project data for your own.
Illustrative portfolio — fictional person and projects.