Shop — Category / PLP
A flagship e-commerce category and product-listing page with a faceted filter rail (category, price range, color swatches, fit, rating, in-stock), a sort dropdown, removable active-filter chips, and a responsive product grid. Every facet filters the grid live and instantly, updating the result count and chip row, while sort reorders and clear-all resets state. Includes working add-to-cart, wishlist, price slider, and a mobile filter drawer. Vanilla JS, no libraries.
MCP
Code
:root {
--bg: #ffffff;
--surface: #f7f8fb;
--ink: #16181d;
--muted: #6b7280;
--brand: #3457ff;
--brand-d: #2742d6;
--brand-soft: #eef1ff;
--sale: #e0245e;
--ok: #1f9d55;
--star: #f5a623;
--line: rgba(16, 18, 29, .1);
--line-2: rgba(16, 18, 29, .06);
--shadow: 0 1px 2px rgba(16, 18, 29, .05), 0 8px 24px rgba(16, 18, 29, .06);
--shadow-lg: 0 12px 40px rgba(16, 18, 29, .14);
--radius: 14px;
--radius-sm: 10px;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
color: var(--ink);
background: var(--bg);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, h3, p { margin: 0; }
a { color: inherit; }
button { font-family: inherit; }
:focus-visible {
outline: 2.5px solid var(--brand);
outline-offset: 2px;
border-radius: 6px;
}
.skip-link {
position: absolute;
left: 12px;
top: -48px;
background: var(--ink);
color: #fff;
padding: 8px 14px;
border-radius: 8px;
z-index: 60;
transition: top .15s;
}
.skip-link:focus { top: 12px; }
/* ===== Topbar ===== */
.topbar {
position: sticky;
top: 0;
z-index: 40;
background: rgba(255, 255, 255, .9);
backdrop-filter: saturate(160%) blur(10px);
border-bottom: 1px solid var(--line);
}
.topbar__inner {
max-width: 1240px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 18px;
padding: 12px 20px;
}
.brand {
display: inline-flex;
align-items: center;
gap: 8px;
font-weight: 800;
text-decoration: none;
letter-spacing: -.02em;
font-size: 19px;
}
.brand__mark { color: var(--brand); display: inline-flex; }
.brand__name { color: var(--ink); }
.search {
position: relative;
flex: 1;
max-width: 460px;
}
.search__icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--muted);
}
.search input {
width: 100%;
border: 1px solid var(--line);
background: var(--surface);
border-radius: 999px;
padding: 10px 14px 10px 38px;
font-size: 14px;
color: var(--ink);
}
.search input::placeholder { color: var(--muted); }
.search input:focus-visible { background: #fff; outline-offset: 0; }
.topbar__actions {
display: flex;
align-items: center;
gap: 14px;
margin-left: auto;
}
.ship-note {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12.5px;
font-weight: 600;
color: var(--ok);
}
.icon-btn {
position: relative;
border: 1px solid var(--line);
background: #fff;
width: 40px;
height: 40px;
border-radius: 11px;
display: grid;
place-items: center;
cursor: pointer;
color: var(--ink);
transition: border-color .15s, transform .1s;
}
.icon-btn:hover { border-color: var(--ink); }
.icon-btn:active { transform: scale(.95); }
.cart-count {
position: absolute;
top: -6px;
right: -6px;
min-width: 18px;
height: 18px;
padding: 0 4px;
border-radius: 999px;
background: var(--brand);
color: #fff;
font-size: 11px;
font-weight: 700;
display: grid;
place-items: center;
transition: transform .18s cubic-bezier(.34, 1.56, .64, 1);
}
.cart-count.bump { transform: scale(1.4); }
/* ===== Layout ===== */
.wrap {
max-width: 1240px;
margin: 0 auto;
padding: 22px 20px 64px;
}
.crumbs {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--muted);
margin-bottom: 14px;
}
.crumbs a { text-decoration: none; }
.crumbs a:hover { color: var(--brand); }
.crumbs [aria-current] { color: var(--ink); font-weight: 600; }
.page-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin-bottom: 22px;
}
.page-head h1 {
font-size: clamp(26px, 4vw, 34px);
font-weight: 800;
letter-spacing: -.025em;
}
.page-head__sub {
color: var(--muted);
font-size: 14.5px;
margin-top: 4px;
}
.trust { color: var(--ink); font-weight: 600; white-space: nowrap; }
.filter-toggle {
display: none;
align-items: center;
gap: 8px;
border: 1px solid var(--line);
background: #fff;
border-radius: 11px;
padding: 10px 14px;
font-weight: 600;
font-size: 14px;
cursor: pointer;
}
.layout {
display: grid;
grid-template-columns: 256px 1fr;
gap: 28px;
align-items: start;
}
/* ===== Facets ===== */
.facets {
position: sticky;
top: 84px;
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 18px;
box-shadow: var(--shadow);
}
.facets__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.facets__head h2 { font-size: 16px; font-weight: 700; }
.link-btn {
border: 0;
background: none;
color: var(--brand);
font-weight: 600;
font-size: 13px;
cursor: pointer;
padding: 4px 2px;
}
.link-btn:hover { color: var(--brand-d); text-decoration: underline; }
.facet {
padding: 16px 0;
border-top: 1px solid var(--line-2);
}
.facet:first-of-type { padding-top: 10px; border-top: 0; }
.facet__title {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .06em;
color: var(--muted);
margin-bottom: 12px;
}
/* checkbox option list */
.opt-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; }
.opt {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
cursor: pointer;
padding: 5px 6px;
border-radius: 8px;
}
.opt:hover { background: var(--surface); }
.opt input { position: absolute; opacity: 0; width: 0; height: 0; }
.opt__box {
width: 18px;
height: 18px;
border-radius: 5px;
border: 1.5px solid var(--line);
display: grid;
place-items: center;
flex: none;
transition: background .12s, border-color .12s;
}
.opt__box svg { opacity: 0; transform: scale(.6); transition: .12s; }
.opt input:checked + .opt__box {
background: var(--brand);
border-color: var(--brand);
}
.opt input:checked + .opt__box svg { opacity: 1; transform: scale(1); color: #fff; }
.opt input:focus-visible + .opt__box { outline: 2.5px solid var(--brand); outline-offset: 2px; }
.opt__count { margin-left: auto; color: var(--muted); font-size: 12.5px; }
/* price */
.price-row { display: flex; align-items: flex-end; gap: 10px; }
.price-field { flex: 1; display: grid; gap: 4px; }
.price-field span { font-size: 11px; color: var(--muted); font-weight: 600; }
.price-field input {
width: 100%;
border: 1px solid var(--line);
border-radius: 9px;
padding: 8px 10px;
font-size: 14px;
color: var(--ink);
}
.price-dash { padding-bottom: 8px; color: var(--muted); }
.range {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 5px;
border-radius: 999px;
background: var(--line);
margin: 14px 0 6px;
cursor: pointer;
}
.range::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--brand);
border: 3px solid #fff;
box-shadow: 0 1px 4px rgba(52, 87, 255, .5);
}
.range::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--brand);
border: 3px solid #fff;
}
.price-readout { font-size: 13px; font-weight: 600; color: var(--ink); }
/* swatches */
.swatches { display: flex; flex-wrap: wrap; gap: 10px; }
.swatch {
width: 30px;
height: 30px;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 0 0 1px var(--line);
cursor: pointer;
position: relative;
transition: transform .1s;
}
.swatch:hover { transform: scale(1.08); }
.swatch[aria-pressed="true"] { box-shadow: 0 0 0 2px var(--brand); }
.swatch[aria-pressed="true"]::after {
content: "";
position: absolute;
inset: 0;
margin: auto;
width: 9px;
height: 9px;
border-radius: 50%;
background: #fff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, .15);
}
/* chips (size/fit) */
.chips { display: flex; flex-wrap: wrap; gap: 8px; }
.chip-btn {
border: 1px solid var(--line);
background: #fff;
border-radius: 9px;
padding: 7px 12px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
color: var(--ink);
transition: .12s;
}
.chip-btn:hover { border-color: var(--ink); }
.chip-btn[aria-pressed="true"] {
background: var(--brand-soft);
border-color: var(--brand);
color: var(--brand-d);
}
/* ratings */
.ratings { display: grid; gap: 2px; }
.rating-opt {
display: flex;
align-items: center;
gap: 8px;
border: 0;
background: none;
padding: 6px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
color: var(--muted);
text-align: left;
}
.rating-opt:hover { background: var(--surface); }
.rating-opt[aria-checked="true"] { color: var(--ink); font-weight: 600; }
.rating-opt[aria-checked="true"] .stars { filter: none; }
.stars { color: var(--star); letter-spacing: 1px; font-size: 14px; }
.stars .off { color: var(--line); }
/* toggle */
.toggle { display: flex; align-items: center; gap: 10px; cursor: pointer; user-select: none; }
.toggle input { position: absolute; opacity: 0; width: 0; height: 0; }
.toggle__track {
width: 40px;
height: 23px;
border-radius: 999px;
background: var(--line);
position: relative;
flex: none;
transition: background .15s;
}
.toggle__dot {
position: absolute;
top: 2.5px;
left: 2.5px;
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, .25);
transition: transform .16s cubic-bezier(.34, 1.56, .64, 1);
}
.toggle input:checked + .toggle__track { background: var(--ok); }
.toggle input:checked + .toggle__track .toggle__dot { transform: translateX(17px); }
.toggle input:focus-visible + .toggle__track { outline: 2.5px solid var(--brand); outline-offset: 2px; }
.toggle__label { font-size: 14px; font-weight: 500; }
/* ===== Results ===== */
.results__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
margin-bottom: 12px;
}
.count { font-size: 14px; color: var(--muted); }
.count strong { color: var(--ink); font-weight: 700; }
.sort { display: inline-flex; align-items: center; gap: 8px; }
.sort__label { font-size: 13px; color: var(--muted); font-weight: 600; }
.sort select {
border: 1px solid var(--line);
background: #fff;
border-radius: 10px;
padding: 9px 32px 9px 12px;
font-size: 14px;
font-weight: 600;
color: var(--ink);
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' fill='none'%3E%3Cpath d='M3 5l4 4 4-4' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 11px center;
}
/* active chips */
.active-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }
.active-chips:empty { display: none; }
.fchip {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--brand-soft);
color: var(--brand-d);
border: 1px solid rgba(52, 87, 255, .25);
border-radius: 999px;
padding: 5px 6px 5px 12px;
font-size: 13px;
font-weight: 600;
}
.fchip button {
border: 0;
background: rgba(52, 87, 255, .14);
color: var(--brand-d);
width: 18px;
height: 18px;
border-radius: 50%;
cursor: pointer;
display: grid;
place-items: center;
font-size: 13px;
line-height: 1;
}
.fchip button:hover { background: var(--brand); color: #fff; }
.fchip--clear {
background: #fff;
color: var(--muted);
border-color: var(--line);
cursor: pointer;
padding: 6px 12px;
}
.fchip--clear:hover { border-color: var(--ink); color: var(--ink); }
/* grid */
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18px;
}
.card {
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
overflow: hidden;
display: flex;
flex-direction: column;
transition: transform .16s, box-shadow .16s, border-color .16s;
animation: pop .28s ease both;
}
.card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
border-color: transparent;
}
@keyframes pop {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.card__media {
position: relative;
aspect-ratio: 4 / 3;
display: grid;
place-items: center;
overflow: hidden;
}
.card__media svg { width: 56%; height: 56%; filter: drop-shadow(0 10px 18px rgba(16, 18, 29, .18)); }
.badge {
position: absolute;
top: 10px;
left: 10px;
font-size: 11px;
font-weight: 700;
letter-spacing: .03em;
padding: 4px 9px;
border-radius: 999px;
text-transform: uppercase;
}
.badge--sale { background: var(--sale); color: #fff; }
.badge--new { background: var(--ink); color: #fff; }
.badge--out { background: rgba(22, 24, 29, .7); color: #fff; }
.wish {
position: absolute;
top: 8px;
right: 8px;
width: 32px;
height: 32px;
border-radius: 50%;
border: 0;
background: rgba(255, 255, 255, .85);
backdrop-filter: blur(4px);
display: grid;
place-items: center;
cursor: pointer;
color: var(--muted);
transition: transform .1s, color .12s;
}
.wish:hover { transform: scale(1.1); color: var(--sale); }
.wish[aria-pressed="true"] { color: var(--sale); }
.wish[aria-pressed="true"] svg { fill: currentColor; }
.card__body { padding: 14px 15px 16px; display: flex; flex-direction: column; gap: 7px; flex: 1; }
.card__brand { font-size: 11.5px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); }
.card__name { font-size: 15px; font-weight: 600; letter-spacing: -.01em; }
.card__rate { display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--muted); }
.card__rate .stars { font-size: 13px; }
.card__swatches { display: flex; gap: 5px; margin-top: 2px; }
.card__swatches span {
width: 14px;
height: 14px;
border-radius: 50%;
box-shadow: 0 0 0 1px var(--line);
}
.card__foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: auto; padding-top: 6px; }
.price { display: flex; align-items: baseline; gap: 7px; }
.price__now { font-size: 18px; font-weight: 800; letter-spacing: -.02em; }
.price__was { font-size: 13px; color: var(--muted); text-decoration: line-through; }
.price--sale .price__now { color: var(--sale); }
.add {
border: 0;
background: var(--ink);
color: #fff;
font-weight: 700;
font-size: 13px;
padding: 9px 14px;
border-radius: 10px;
cursor: pointer;
transition: background .14s, transform .1s;
white-space: nowrap;
}
.add:hover { background: var(--brand); }
.add:active { transform: scale(.96); }
.add:disabled { background: var(--surface); color: var(--muted); cursor: not-allowed; }
.add.added { background: var(--ok); }
.stock { font-size: 12px; font-weight: 600; }
.stock--low { color: var(--sale); }
/* empty */
.empty { text-align: center; padding: 60px 20px; color: var(--muted); }
.empty__art { font-size: 42px; margin-bottom: 8px; }
.empty h3 { color: var(--ink); font-size: 18px; margin-bottom: 6px; }
.empty p { margin-bottom: 16px; }
.more-row { display: flex; justify-content: center; margin-top: 28px; }
.more-row[hidden] { display: none; }
.btn {
border: 0;
cursor: pointer;
border-radius: 11px;
padding: 11px 22px;
font-weight: 700;
font-size: 14px;
}
.btn--ghost {
background: #fff;
border: 1px solid var(--line);
color: var(--ink);
}
.btn--ghost:hover { border-color: var(--ink); background: var(--surface); }
/* toast */
.toast {
position: fixed;
left: 50%;
bottom: 24px;
transform: translate(-50%, 18px);
background: var(--ink);
color: #fff;
padding: 12px 18px;
border-radius: 12px;
font-size: 14px;
font-weight: 600;
box-shadow: var(--shadow-lg);
opacity: 0;
pointer-events: none;
transition: opacity .2s, transform .2s;
z-index: 80;
max-width: 90vw;
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }
/* ===== Responsive ===== */
@media (max-width: 980px) {
.grid { grid-template-columns: repeat(2, 1fr); }
.filter-toggle { display: inline-flex; }
.layout { grid-template-columns: 1fr; }
.facets {
position: fixed;
inset: 0 0 0 auto;
width: min(340px, 88vw);
border-radius: 0;
z-index: 70;
overflow-y: auto;
transform: translateX(100%);
transition: transform .25s ease;
box-shadow: var(--shadow-lg);
}
.facets.open { transform: translateX(0); }
body.facets-open::after {
content: "";
position: fixed;
inset: 0;
background: rgba(16, 18, 29, .4);
z-index: 65;
}
}
@media (max-width: 620px) {
.ship-note { display: none; }
.page-head { flex-direction: column; align-items: stretch; }
.filter-toggle { justify-content: center; }
}
@media (max-width: 460px) {
.grid { grid-template-columns: 1fr; }
.topbar__inner { flex-wrap: wrap; }
.search { order: 3; flex-basis: 100%; max-width: none; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .001ms !important; transition-duration: .001ms !important; }
}/* Lumen — Category / PLP. Vanilla JS faceted filtering engine. */
(function () {
"use strict";
/* ---------- Data ---------- */
const COLORS = {
midnight: "#1d2433",
sand: "#d8c4a6",
rose: "#e09aad",
sky: "#7fb3e8",
sage: "#9bb89a",
coral: "#ef7d5a",
};
const PRODUCTS = [
{ id: "p1", name: "Aurora Over-Ear", brand: "Lumen", cat: "Headphones", price: 249, was: 299, rating: 4.8, reviews: 1284, colors: ["midnight", "sand"], fit: "Over-ear", stock: 14, new: false, sale: true, day: 28 },
{ id: "p2", name: "Pulse Mini Buds", brand: "Sonara", cat: "Earbuds", price: 89, was: null, rating: 4.5, reviews: 642, colors: ["rose", "sky"], fit: "In-ear", stock: 40, new: true, sale: false, day: 41 },
{ id: "p3", name: "Echo Stage 360", brand: "Voltic", cat: "Speakers", price: 199, was: null, rating: 4.7, reviews: 503, colors: ["midnight", "sage"], fit: "Portable", stock: 7, new: false, sale: false, day: 18 },
{ id: "p4", name: "Drift Wireless", brand: "Lumen", cat: "Headphones", price: 159, was: 189, rating: 4.3, reviews: 388, colors: ["sky", "sand", "coral"], fit: "On-ear", stock: 22, new: false, sale: true, day: 12 },
{ id: "p5", name: "Tide Pro ANC", brand: "Sonara", cat: "Earbuds", price: 179, was: null, rating: 4.9, reviews: 2140, colors: ["midnight", "rose"], fit: "In-ear", stock: 0, new: false, sale: false, day: 35 },
{ id: "p6", name: "Halo Desk Speaker", brand: "Voltic", cat: "Speakers", price: 119, was: 139, rating: 4.2, reviews: 219, colors: ["sand", "sage"], fit: "Desktop", stock: 31, new: false, sale: true, day: 9 },
{ id: "p7", name: "Nova Studio XL", brand: "Lumen", cat: "Headphones", price: 379, was: null, rating: 4.9, reviews: 876, colors: ["midnight"], fit: "Over-ear", stock: 5, new: true, sale: false, day: 44 },
{ id: "p8", name: "Spark Sport Buds", brand: "Kinetic", cat: "Earbuds", price: 69, was: 99, rating: 4.1, reviews: 311, colors: ["coral", "sky"], fit: "In-ear", stock: 60, new: false, sale: true, day: 5 },
{ id: "p9", name: "Bloom Bookshelf", brand: "Voltic", cat: "Speakers", price: 289, was: null, rating: 4.6, reviews: 158, colors: ["sand", "midnight"], fit: "Bookshelf", stock: 11, new: false, sale: false, day: 22 },
{ id: "p10", name: "Vibe On-Ear", brand: "Kinetic", cat: "Headphones", price: 99, was: null, rating: 3.9, reviews: 174, colors: ["rose", "sage", "sky"], fit: "On-ear", stock: 26, new: false, sale: false, day: 14 },
{ id: "p11", name: "Mist Open Buds", brand: "Sonara", cat: "Earbuds", price: 129, was: 149, rating: 4.4, reviews: 421, colors: ["sage", "sand"], fit: "Open-ear", stock: 18, new: true, sale: true, day: 39 },
{ id: "p12", name: "Quasar Floor 500", brand: "Voltic", cat: "Speakers", price: 549, was: null, rating: 4.8, reviews: 92, colors: ["midnight"], fit: "Floor", stock: 3, new: false, sale: false, day: 30 },
{ id: "p13", name: "Pebble Clip Buds", brand: "Kinetic", cat: "Earbuds", price: 49, was: 59, rating: 3.8, reviews: 540, colors: ["coral", "rose", "sky"], fit: "Clip-on", stock: 80, new: false, sale: true, day: 3 },
{ id: "p14", name: "Lyric Travel Pair", brand: "Lumen", cat: "Headphones", price: 219, was: null, rating: 4.6, reviews: 667, colors: ["sand", "midnight"], fit: "Over-ear", stock: 9, new: true, sale: false, day: 42 },
{ id: "p15", name: "Ripple Room Fill", brand: "Voltic", cat: "Speakers", price: 169, was: 199, rating: 4.5, reviews: 287, colors: ["sage", "sky"], fit: "Portable", stock: 0, new: false, sale: true, day: 16 },
];
/* ---------- State ---------- */
const state = {
category: new Set(),
color: new Set(),
size: new Set(),
rating: 0,
priceMin: 0,
priceMax: 600,
inStock: false,
sort: "featured",
query: "",
page: 1,
};
const PER_PAGE = 9;
/* ---------- Helpers ---------- */
const $ = (sel) => document.querySelector(sel);
const money = (n) => "$" + n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const titleize = (s) => s.charAt(0).toUpperCase() + s.slice(1);
let toastTimer;
function toast(msg) {
const el = $("#toast");
el.textContent = msg;
el.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove("show"), 2200);
}
function starsHTML(r) {
const full = Math.round(r);
let out = "";
for (let i = 1; i <= 5; i++) out += i <= full ? "★" : '<span class="off">★</span>';
return out;
}
function productSVG(p) {
const c = COLORS[p.colors[0]] || "#1d2433";
const tints = { Headphones: "#eef1ff", Earbuds: "#fff0f4", Speakers: "#eefaf2" };
const bg = tints[p.cat] || "#f1f3f8";
let shape;
if (p.cat === "Headphones") {
shape = `<path d="M30 58a40 40 0 0 1 80 0" fill="none" stroke="${c}" stroke-width="9" stroke-linecap="round"/>
<rect x="20" y="54" width="22" height="40" rx="11" fill="${c}"/>
<rect x="98" y="54" width="22" height="40" rx="11" fill="${c}"/>`;
} else if (p.cat === "Earbuds") {
shape = `<circle cx="50" cy="48" r="17" fill="${c}"/><rect x="44" y="58" width="12" height="42" rx="6" fill="${c}"/>
<circle cx="90" cy="48" r="17" fill="${c}" opacity=".82"/><rect x="84" y="58" width="12" height="42" rx="6" fill="${c}" opacity=".82"/>`;
} else {
shape = `<rect x="44" y="20" width="52" height="92" rx="14" fill="${c}"/>
<circle cx="70" cy="50" r="13" fill="${bg}"/><circle cx="70" cy="84" r="9" fill="${bg}" opacity=".7"/>`;
}
return `<svg viewBox="0 0 140 128" role="img" aria-label="${p.name}" style="background:${bg}">${shape}</svg>`;
}
/* ---------- Build facet controls ---------- */
function buildFacets() {
const cats = [...new Set(PRODUCTS.map((p) => p.cat))];
const catUl = $('[data-group="category"]');
catUl.innerHTML = cats
.map((c) => {
const n = PRODUCTS.filter((p) => p.cat === c).length;
return `<li><label class="opt"><input type="checkbox" value="${c}">
<span class="opt__box"><svg viewBox="0 0 16 16" width="12" height="12"><path d="M3.5 8.5l3 3 6-7" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
<span>${c}</span><span class="opt__count">${n}</span></label></li>`;
})
.join("");
const sw = $('[data-group="color"]');
sw.innerHTML = Object.entries(COLORS)
.map(([k, v]) => `<button class="swatch" type="button" data-color="${k}" style="background:${v}" aria-pressed="false" aria-label="${titleize(k)}" title="${titleize(k)}"></button>`)
.join("");
const fits = [...new Set(PRODUCTS.map((p) => p.fit))];
$('[data-group="size"]').innerHTML = fits
.map((f) => `<button class="chip-btn" type="button" data-size="${f}" aria-pressed="false">${f}</button>`)
.join("");
$('[data-group="rating"]').innerHTML = [4, 3, 2]
.map((r) => `<button class="rating-opt" type="button" data-rating="${r}" role="radio" aria-checked="false">
<span class="stars">${starsHTML(r)}</span><span>& up</span></button>`)
.join("");
}
/* ---------- Filter + sort ---------- */
function compute() {
let list = PRODUCTS.filter((p) => {
if (state.category.size && !state.category.has(p.cat)) return false;
if (state.color.size && !p.colors.some((c) => state.color.has(c))) return false;
if (state.size.size && !state.size.has(p.fit)) return false;
if (state.rating && p.rating < state.rating) return false;
if (p.price < state.priceMin || p.price > state.priceMax) return false;
if (state.inStock && p.stock === 0) return false;
if (state.query) {
const hay = (p.name + " " + p.brand + " " + p.cat + " " + p.fit).toLowerCase();
if (!hay.includes(state.query)) return false;
}
return true;
});
const sorters = {
"price-asc": (a, b) => a.price - b.price,
"price-desc": (a, b) => b.price - a.price,
rating: (a, b) => b.rating - a.rating || b.reviews - a.reviews,
newest: (a, b) => b.day - a.day,
featured: (a, b) => Number(b.new) - Number(a.new) || b.reviews - a.reviews,
};
list.sort(sorters[state.sort] || sorters.featured);
return list;
}
/* ---------- Render ---------- */
function render() {
const all = compute();
const grid = $("#grid");
const shown = all.slice(0, state.page * PER_PAGE);
if (all.length === 0) {
grid.innerHTML = "";
$("#empty").hidden = false;
$("#moreRow").hidden = true;
} else {
$("#empty").hidden = true;
grid.innerHTML = shown.map(cardHTML).join("");
$("#moreRow").hidden = shown.length >= all.length;
}
$("#count").innerHTML = `Showing <strong>${shown.length}</strong> of <strong>${all.length}</strong> products`;
renderChips();
}
function cardHTML(p) {
const out = p.stock === 0;
const low = p.stock > 0 && p.stock <= 7;
const badge = out
? '<span class="badge badge--out">Sold out</span>'
: p.sale
? '<span class="badge badge--sale">Sale</span>'
: p.new
? '<span class="badge badge--new">New</span>'
: "";
const swatches = p.colors.map((c) => `<span style="background:${COLORS[c]}"></span>`).join("");
const priceBlock = p.was
? `<div class="price price--sale"><span class="price__now">${money(p.price)}</span><span class="price__was">${money(p.was)}</span></div>`
: `<div class="price"><span class="price__now">${money(p.price)}</span></div>`;
const foot = out
? `<span class="stock stock--low">Out of stock</span><button class="add" data-id="${p.id}" disabled>Sold out</button>`
: `${priceBlock}<button class="add" data-id="${p.id}" data-name="${p.name}">Add</button>`;
const stockLine = low ? `<span class="stock stock--low">Only ${p.stock} left</span>` : "";
return `<article class="card">
<div class="card__media">
${productSVG(p)}
${badge}
<button class="wish" type="button" data-wish="${p.id}" aria-pressed="false" aria-label="Save ${p.name}">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none"><path d="M12 20s-7-4.4-9.2-8.4C1.3 8.7 2.6 5.4 5.8 5.4c2 0 3.2 1.3 4.2 2.6 1-1.3 2.2-2.6 4.2-2.6 3.2 0 4.5 3.3 3 6.2C19 15.6 12 20 12 20Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/></svg>
</button>
</div>
<div class="card__body">
<span class="card__brand">${p.brand}</span>
<h3 class="card__name">${p.name}</h3>
<div class="card__rate"><span class="stars">${starsHTML(p.rating)}</span> ${p.rating.toFixed(1)} <span>(${p.reviews.toLocaleString("en-US")})</span></div>
<div class="card__swatches" aria-hidden="true">${swatches}</div>
${stockLine}
<div class="card__foot">${foot}</div>
</div>
</article>`;
}
/* ---------- Active filter chips ---------- */
function renderChips() {
const wrap = $("#activeChips");
const chips = [];
state.category.forEach((c) => chips.push({ type: "category", val: c, label: c }));
state.color.forEach((c) => chips.push({ type: "color", val: c, label: titleize(c) }));
state.size.forEach((s) => chips.push({ type: "size", val: s, label: s }));
if (state.rating) chips.push({ type: "rating", val: state.rating, label: state.rating + "★ & up" });
if (state.priceMin > 0 || state.priceMax < 600)
chips.push({ type: "price", val: "", label: `${money(state.priceMin).replace(".00", "")} – ${money(state.priceMax).replace(".00", "")}` });
if (state.inStock) chips.push({ type: "inStock", val: "", label: "In stock" });
if (!chips.length) { wrap.innerHTML = ""; return; }
wrap.innerHTML =
chips
.map(
(c) =>
`<span class="fchip">${c.label}<button type="button" data-chip-type="${c.type}" data-chip-val="${c.val}" aria-label="Remove ${c.label} filter">×</button></span>`
)
.join("") + `<button class="fchip fchip--clear" type="button" id="chipClear">Clear all</button>`;
}
/* ---------- Mutations ---------- */
function changed() {
state.page = 1;
render();
}
function removeChip(type, val) {
if (type === "category") state.category.delete(val);
else if (type === "color") {
state.color.delete(val);
const b = document.querySelector(`.swatch[data-color="${val}"]`);
if (b) b.setAttribute("aria-pressed", "false");
} else if (type === "size") {
state.size.delete(val);
const b = document.querySelector(`.chip-btn[data-size="${val}"]`);
if (b) b.setAttribute("aria-pressed", "false");
} else if (type === "rating") {
state.rating = 0;
document.querySelectorAll(".rating-opt").forEach((r) => r.setAttribute("aria-checked", "false"));
} else if (type === "price") {
state.priceMin = 0;
state.priceMax = 600;
syncPriceInputs();
} else if (type === "inStock") {
state.inStock = false;
$("#inStock").checked = false;
}
// un-tick category checkbox
if (type === "category") {
const cb = document.querySelector(`[data-group="category"] input[value="${val}"]`);
if (cb) cb.checked = false;
}
changed();
}
function clearAll() {
state.category.clear();
state.color.clear();
state.size.clear();
state.rating = 0;
state.priceMin = 0;
state.priceMax = 600;
state.inStock = false;
document.querySelectorAll('[data-group="category"] input').forEach((i) => (i.checked = false));
document.querySelectorAll(".swatch").forEach((s) => s.setAttribute("aria-pressed", "false"));
document.querySelectorAll(".chip-btn").forEach((c) => c.setAttribute("aria-pressed", "false"));
document.querySelectorAll(".rating-opt").forEach((r) => r.setAttribute("aria-checked", "false"));
$("#inStock").checked = false;
syncPriceInputs();
changed();
toast("Filters cleared");
}
function syncPriceInputs() {
$("#priceMin").value = state.priceMin;
$("#priceMax").value = state.priceMax;
$("#priceRange").value = state.priceMax;
$("#priceLabel").textContent = `${money(state.priceMin).replace(".00", "")} – ${money(state.priceMax).replace(".00", "")}`;
}
/* ---------- Cart ---------- */
let cartN = 0;
function addToCart(btn) {
cartN++;
const cc = $("#cartCount");
cc.textContent = cartN;
cc.classList.remove("bump");
void cc.offsetWidth;
cc.classList.add("bump");
btn.classList.add("added");
const orig = btn.textContent;
btn.textContent = "✓ Added";
setTimeout(() => {
btn.classList.remove("added");
btn.textContent = orig;
}, 1100);
toast(`Added “${btn.dataset.name}” to cart`);
}
/* ---------- Wire events ---------- */
function wire() {
// category checkboxes
$('[data-group="category"]').addEventListener("change", (e) => {
const cb = e.target.closest("input");
if (!cb) return;
cb.checked ? state.category.add(cb.value) : state.category.delete(cb.value);
changed();
});
// color swatches
$('[data-group="color"]').addEventListener("click", (e) => {
const b = e.target.closest(".swatch");
if (!b) return;
const on = b.getAttribute("aria-pressed") === "true";
b.setAttribute("aria-pressed", String(!on));
on ? state.color.delete(b.dataset.color) : state.color.add(b.dataset.color);
changed();
});
// size chips
$('[data-group="size"]').addEventListener("click", (e) => {
const b = e.target.closest(".chip-btn");
if (!b) return;
const on = b.getAttribute("aria-pressed") === "true";
b.setAttribute("aria-pressed", String(!on));
on ? state.size.delete(b.dataset.size) : state.size.add(b.dataset.size);
changed();
});
// rating radios (toggleable)
$('[data-group="rating"]').addEventListener("click", (e) => {
const b = e.target.closest(".rating-opt");
if (!b) return;
const val = Number(b.dataset.rating);
const already = state.rating === val;
document.querySelectorAll(".rating-opt").forEach((r) => r.setAttribute("aria-checked", "false"));
if (already) {
state.rating = 0;
} else {
state.rating = val;
b.setAttribute("aria-checked", "true");
}
changed();
});
// price inputs
function applyPrice() {
let mn = parseInt($("#priceMin").value, 10) || 0;
let mx = parseInt($("#priceMax").value, 10);
if (isNaN(mx)) mx = 600;
mn = Math.max(0, Math.min(mn, 600));
mx = Math.max(0, Math.min(mx, 600));
if (mn > mx) [mn, mx] = [mx, mn];
state.priceMin = mn;
state.priceMax = mx;
syncPriceInputs();
changed();
}
$("#priceMin").addEventListener("change", applyPrice);
$("#priceMax").addEventListener("change", applyPrice);
$("#priceRange").addEventListener("input", (e) => {
state.priceMax = parseInt(e.target.value, 10);
if (state.priceMin > state.priceMax) state.priceMin = state.priceMax;
$("#priceMin").value = state.priceMin;
$("#priceMax").value = state.priceMax;
$("#priceLabel").textContent = `${money(state.priceMin).replace(".00", "")} – ${money(state.priceMax).replace(".00", "")}`;
changed();
});
// in-stock toggle
$("#inStock").addEventListener("change", (e) => {
state.inStock = e.target.checked;
changed();
});
// sort
$("#sort").addEventListener("change", (e) => {
state.sort = e.target.value;
render();
});
// search
let qTimer;
$("#q").addEventListener("input", (e) => {
clearTimeout(qTimer);
qTimer = setTimeout(() => {
state.query = e.target.value.trim().toLowerCase();
changed();
}, 160);
});
// clear all (rail button)
$("#clearAll").addEventListener("click", clearAll);
$("#emptyReset").addEventListener("click", clearAll);
// active chips delegation
$("#activeChips").addEventListener("click", (e) => {
if (e.target.id === "chipClear") return clearAll();
const btn = e.target.closest("button[data-chip-type]");
if (btn) removeChip(btn.dataset.chipType, btn.dataset.chipVal);
});
// grid delegation: add to cart + wishlist
$("#grid").addEventListener("click", (e) => {
const add = e.target.closest(".add");
if (add && !add.disabled) return addToCart(add);
const wish = e.target.closest(".wish");
if (wish) {
const on = wish.getAttribute("aria-pressed") === "true";
wish.setAttribute("aria-pressed", String(!on));
toast(on ? "Removed from wishlist" : "Saved to wishlist ♥");
}
});
// load more
$("#loadMore").addEventListener("click", () => {
state.page++;
render();
});
// cart button
$("#cartBtn").addEventListener("click", () => toast(`Cart · ${cartN} item${cartN === 1 ? "" : "s"}`));
// mobile filter drawer
const toggle = $("#filterToggle");
const facets = $("#facets");
toggle.addEventListener("click", () => {
const open = facets.classList.toggle("open");
document.body.classList.toggle("facets-open", open);
toggle.setAttribute("aria-expanded", String(open));
});
document.body.addEventListener("click", (e) => {
if (
facets.classList.contains("open") &&
!facets.contains(e.target) &&
!toggle.contains(e.target) &&
window.innerWidth <= 980
) {
facets.classList.remove("open");
document.body.classList.remove("facets-open");
toggle.setAttribute("aria-expanded", "false");
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && facets.classList.contains("open")) {
facets.classList.remove("open");
document.body.classList.remove("facets-open");
toggle.setAttribute("aria-expanded", "false");
}
});
}
/* ---------- Init ---------- */
buildFacets();
syncPriceInputs();
wire();
render();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Lumen — Audio & Sound · Shop</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&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<a class="skip-link" href="#results">Skip to products</a>
<header class="topbar" role="banner">
<div class="topbar__inner">
<a class="brand" href="#" aria-label="Lumen home">
<span class="brand__mark" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none">
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2"/>
<circle cx="12" cy="12" r="3.4" fill="currentColor"/>
</svg>
</span>
<span class="brand__name">Lumen</span>
</a>
<form class="search" role="search" onsubmit="return false">
<svg class="search__icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M16.5 16.5 21 21" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<input id="q" type="search" placeholder="Search headphones, speakers…" aria-label="Search products" />
</form>
<div class="topbar__actions">
<span class="ship-note" title="Free shipping over $75">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M3 7h11v8H3zM14 10h4l3 3v2h-7z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><circle cx="7" cy="17" r="1.6" fill="currentColor"/><circle cx="17" cy="17" r="1.6" fill="currentColor"/></svg>
Free shipping $75+
</span>
<button class="icon-btn" id="cartBtn" aria-label="Cart">
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M5 7h14l-1.4 9.3a2 2 0 0 1-2 1.7H8.4a2 2 0 0 1-2-1.7L5 7Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="M9 7a3 3 0 0 1 6 0" fill="none" stroke="currentColor" stroke-width="2"/></svg>
<span class="cart-count" id="cartCount" aria-live="polite">0</span>
</button>
</div>
</div>
</header>
<main class="wrap">
<nav class="crumbs" aria-label="Breadcrumb">
<a href="#">Home</a><span aria-hidden="true">/</span>
<a href="#">Electronics</a><span aria-hidden="true">/</span>
<span aria-current="page">Audio & Sound</span>
</nav>
<div class="page-head">
<div>
<h1>Audio & Sound</h1>
<p class="page-head__sub">Headphones, earbuds, and speakers engineered for clarity. <span class="trust">★ 4.8 avg · 12,400+ reviews</span></p>
</div>
<button class="filter-toggle" id="filterToggle" aria-expanded="false" aria-controls="facets">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M4 6h16M7 12h10M10 18h4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
Filters
</button>
</div>
<div class="layout">
<!-- ===== FILTER RAIL ===== -->
<aside class="facets" id="facets" aria-label="Product filters">
<div class="facets__head">
<h2>Filters</h2>
<button class="link-btn" id="clearAll" type="button">Clear all</button>
</div>
<section class="facet" aria-labelledby="f-cat">
<h3 id="f-cat" class="facet__title">Category</h3>
<ul class="opt-list" data-group="category" role="group" aria-labelledby="f-cat"></ul>
</section>
<section class="facet" aria-labelledby="f-price">
<h3 id="f-price" class="facet__title">Price</h3>
<div class="price-row">
<label class="price-field">
<span>Min</span>
<input type="number" id="priceMin" min="0" max="600" step="10" value="0" inputmode="numeric" />
</label>
<span class="price-dash" aria-hidden="true">–</span>
<label class="price-field">
<span>Max</span>
<input type="number" id="priceMax" min="0" max="600" step="10" value="600" inputmode="numeric" />
</label>
</div>
<input type="range" id="priceRange" class="range" min="0" max="600" step="10" value="600" aria-label="Maximum price" />
<div class="price-readout"><span id="priceLabel">$0 – $600</span></div>
</section>
<section class="facet" aria-labelledby="f-color">
<h3 id="f-color" class="facet__title">Color</h3>
<div class="swatches" data-group="color" role="group" aria-labelledby="f-color"></div>
</section>
<section class="facet" aria-labelledby="f-size">
<h3 id="f-size" class="facet__title">Fit</h3>
<div class="chips" data-group="size" role="group" aria-labelledby="f-size"></div>
</section>
<section class="facet" aria-labelledby="f-rating">
<h3 id="f-rating" class="facet__title">Rating</h3>
<div class="ratings" data-group="rating" role="radiogroup" aria-labelledby="f-rating"></div>
</section>
<section class="facet">
<label class="toggle">
<input type="checkbox" id="inStock" />
<span class="toggle__track" aria-hidden="true"><span class="toggle__dot"></span></span>
<span class="toggle__label">In stock only</span>
</label>
</section>
</aside>
<!-- ===== RESULTS ===== -->
<section class="results" id="results" aria-label="Products">
<div class="results__bar">
<p class="count" id="count" aria-live="polite"></p>
<label class="sort">
<span class="sort__label">Sort</span>
<select id="sort" aria-label="Sort products">
<option value="featured">Featured</option>
<option value="price-asc">Price: Low to High</option>
<option value="price-desc">Price: High to Low</option>
<option value="rating">Top rated</option>
<option value="newest">Newest</option>
</select>
</label>
</div>
<div class="active-chips" id="activeChips" aria-label="Active filters"></div>
<div class="grid" id="grid"></div>
<div class="empty" id="empty" hidden>
<div class="empty__art" aria-hidden="true">🔍</div>
<h3>No products match those filters</h3>
<p>Try widening your price range or clearing a filter.</p>
<button class="btn btn--ghost" id="emptyReset" type="button">Clear all filters</button>
</div>
<div class="more-row" id="moreRow">
<button class="btn btn--ghost" id="loadMore" type="button">Load more</button>
</div>
</section>
</div>
</main>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Category / PLP
A complete storefront category page built around fast, real faceted filtering. A sticky left rail exposes the facets shoppers expect — category checkboxes with counts, a dual price range (number inputs plus a slider), tappable color swatches, fit chips, a star-rating filter, and an in-stock toggle. The results area pairs a live result count with a sort dropdown (Featured, Price low/high, Top rated, Newest) and a row of removable active-filter chips, above a responsive grid of product cards rendered with inline-SVG “product photography” on soft tinted tiles.
Every interaction actually works and feels instant. Toggling any facet re-filters the grid in place, updates the “Showing X of Y” count, and adds or removes the matching chip — and each chip’s × removes just that filter, while Clear all resets everything. Cards carry brand, star rating with review count, color dots, sale/new/sold-out badges, low-stock warnings, a working add-to-cart with a count badge that bumps, and a wishlist heart. Load more pages through the catalog, and an empty state appears when nothing matches.
On narrow screens the rail collapses into a slide-in drawer behind a Filters button (closable via overlay tap or Escape), the grid steps down from three columns to two to one, and the layout stays usable down to about 360px. Built with semantic landmarks, ARIA on the custom controls, visible focus rings, and keyboard-operable facets.
Illustrative storefront UI only — fictional products, prices, and reviews. No real checkout.