Shop — Faceted Filter Sidebar
A reusable product-listing filter rail with collapsible facet groups: category checkboxes with live counts, a true dual-handle price range slider, a color swatch grid, size chips that disable when empty, a star-rating filter, a searchable brand list with match highlighting, and an in-stock toggle. Every control updates a live result count and a removable active-filters chip row, with clear-all reset and a slide-in mobile drawer. Built with vanilla JS and no libraries.
MCP
Code
:root {
--bg: #ffffff;
--surface: #f7f8fb;
--surface-2: #f2f4f9;
--ink: #16181d;
--ink-soft: #2a2e38;
--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 18px 50px rgba(16, 18, 29, .16);
--radius: 14px;
--radius-sm: 10px;
--ring: 0 0 0 3px rgba(52, 87, 255, .35);
}
* { 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:
radial-gradient(1100px 520px at 12% -8%, #eef1ff 0%, rgba(238, 241, 255, 0) 58%),
var(--surface);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
h1, h2, p, ul { margin: 0; }
ul { list-style: none; padding: 0; }
button { font: inherit; cursor: pointer; }
a { color: inherit; }
.skip-link {
position: absolute;
left: 12px;
top: -52px;
z-index: 80;
background: var(--ink);
color: #fff;
padding: 10px 16px;
border-radius: 10px;
font-weight: 600;
text-decoration: none;
transition: top .18s ease;
}
.skip-link:focus { top: 12px; }
:focus-visible {
outline: none;
box-shadow: var(--ring);
border-radius: 8px;
}
.shell {
max-width: 1180px;
margin: 0 auto;
padding: 28px 20px 64px;
}
/* ---------- Page head ---------- */
.pagehead { margin-bottom: 22px; }
.pagehead__row {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
}
.eyebrow {
font-size: .72rem;
font-weight: 700;
letter-spacing: .09em;
text-transform: uppercase;
color: var(--brand);
margin-bottom: 6px;
}
.pagehead__title {
font-size: clamp(1.5rem, 3.4vw, 2.1rem);
font-weight: 800;
letter-spacing: -.02em;
}
.drawer-toggle {
display: none;
align-items: center;
gap: 8px;
background: var(--bg);
border: 1px solid var(--line);
color: var(--ink);
padding: 11px 16px;
border-radius: 12px;
font-weight: 600;
box-shadow: var(--shadow);
}
.drawer-toggle:hover { border-color: var(--brand); }
.drawer-toggle__count {
display: inline-grid;
place-items: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 999px;
background: var(--brand);
color: #fff;
font-size: .72rem;
font-weight: 700;
}
/* ---------- Layout ---------- */
.layout {
display: grid;
grid-template-columns: 300px 1fr;
gap: 28px;
align-items: start;
}
/* ---------- Rail ---------- */
.rail { position: sticky; top: 18px; }
.rail__overlay { display: none; }
.rail__panel {
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
overflow: hidden;
max-height: calc(100vh - 36px);
}
.rail__top {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 8px 12px;
padding: 16px 18px 12px;
border-bottom: 1px solid var(--line-2);
}
.rail__heading {
display: flex;
align-items: center;
gap: 10px;
}
.rail__title { font-size: 1.05rem; font-weight: 800; letter-spacing: -.01em; }
.applied-badge {
font-size: .72rem;
font-weight: 700;
color: var(--brand);
background: var(--brand-soft);
padding: 3px 9px;
border-radius: 999px;
}
.clear-all {
justify-self: end;
background: none;
border: none;
color: var(--brand);
font-weight: 600;
font-size: .85rem;
padding: 4px 2px;
border-radius: 8px;
}
.clear-all:hover:not(:disabled) { color: var(--brand-d); text-decoration: underline; }
.clear-all:disabled { color: var(--muted); opacity: .55; cursor: default; }
.rail__close {
display: none;
background: none;
border: none;
color: var(--muted);
padding: 4px;
border-radius: 8px;
}
.rail__close:hover { color: var(--ink); }
/* Active chips */
.active-filters {
padding: 12px 18px 4px;
border-bottom: 1px solid var(--line-2);
}
.chips { display: flex; flex-wrap: wrap; gap: 7px; }
.chip {
display: inline-flex;
align-items: center;
gap: 5px;
background: var(--surface-2);
border: 1px solid var(--line-2);
color: var(--ink-soft);
font-size: .78rem;
font-weight: 600;
padding: 5px 6px 5px 10px;
border-radius: 999px;
}
.chip__x {
display: inline-grid;
place-items: center;
width: 18px;
height: 18px;
border: none;
border-radius: 999px;
background: rgba(16, 18, 29, .08);
color: var(--ink-soft);
font-size: 14px;
line-height: 1;
padding: 0;
}
.chip__x:hover { background: var(--sale); color: #fff; }
.chip__swatch {
width: 12px;
height: 12px;
border-radius: 999px;
border: 1px solid var(--line);
}
/* Scroll body */
.rail__scroll {
overflow-y: auto;
padding: 4px 0 8px;
flex: 1;
}
.rail__scroll::-webkit-scrollbar { width: 9px; }
.rail__scroll::-webkit-scrollbar-thumb {
background: rgba(16, 18, 29, .16);
border-radius: 999px;
border: 3px solid var(--bg);
}
/* ---------- Facet ---------- */
.facet { border-bottom: 1px solid var(--line-2); }
.facet:last-child { border-bottom: none; }
.facet__head {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
background: none;
border: none;
color: var(--ink);
padding: 14px 18px;
font-weight: 700;
font-size: .92rem;
}
.facet__head:hover .facet__name { color: var(--brand); }
.facet__chev { color: var(--muted); transition: transform .22s ease; }
.facet__head[aria-expanded="false"] .facet__chev { transform: rotate(-90deg); }
.facet__body {
padding: 0 18px 16px;
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows .24s ease, opacity .2s ease;
}
.facet__head[aria-expanded="false"] + .facet__body {
grid-template-rows: 0fr;
padding-bottom: 0;
opacity: 0;
}
.facet__body > * { min-height: 0; overflow: hidden; }
/* In-stock toggle facet */
.facet--toggle {
padding: 14px 18px;
border-bottom: 1px solid var(--line-2);
}
.switch {
display: flex;
align-items: center;
gap: 11px;
cursor: pointer;
font-weight: 600;
font-size: .92rem;
}
.switch input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.switch__track {
width: 42px;
height: 24px;
border-radius: 999px;
background: rgba(16, 18, 29, .18);
position: relative;
transition: background .2s ease;
flex-shrink: 0;
}
.switch__thumb {
position: absolute;
top: 3px;
left: 3px;
width: 18px;
height: 18px;
border-radius: 999px;
background: #fff;
box-shadow: 0 1px 3px rgba(16, 18, 29, .35);
transition: transform .2s ease;
}
.switch input:checked + .switch__track { background: var(--ok); }
.switch input:checked + .switch__track .switch__thumb { transform: translateX(18px); }
.switch input:focus-visible + .switch__track { box-shadow: var(--ring); }
/* Checklist (category + brand) */
.checklist { display: grid; gap: 2px; }
.checklist--scroll { max-height: 178px; overflow-y: auto; padding-right: 2px; }
.check {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 8px;
border-radius: 9px;
cursor: pointer;
transition: background .14s ease;
}
.check:hover { background: var(--surface); }
.check input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.check__box {
width: 19px;
height: 19px;
border: 2px solid var(--line);
border-radius: 6px;
display: grid;
place-items: center;
flex-shrink: 0;
transition: background .14s ease, border-color .14s ease;
}
.check__box svg { opacity: 0; transform: scale(.6); transition: all .14s ease; color: #fff; }
.check input:checked + .check__box { background: var(--brand); border-color: var(--brand); }
.check input:checked + .check__box svg { opacity: 1; transform: scale(1); }
.check input:focus-visible + .check__box { box-shadow: var(--ring); }
.check__name { font-size: .9rem; font-weight: 500; flex: 1; }
.check__count {
font-size: .78rem;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.check input:checked ~ .check__name { font-weight: 600; }
/* Price dual range */
.price__readout {
display: flex;
align-items: center;
gap: 8px;
font-weight: 700;
font-size: .95rem;
font-variant-numeric: tabular-nums;
margin-bottom: 16px;
}
.price__dash { color: var(--muted); }
.dualrange {
position: relative;
height: 24px;
margin: 4px 2px 2px;
}
.dualrange__track {
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 5px;
transform: translateY(-50%);
background: var(--surface-2);
border-radius: 999px;
}
.dualrange__fill {
position: absolute;
top: 0;
height: 100%;
background: var(--brand);
border-radius: 999px;
}
.dualrange input[type="range"] {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 24px;
margin: 0;
background: none;
pointer-events: none;
-webkit-appearance: none;
appearance: none;
}
.dualrange input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
pointer-events: auto;
width: 20px;
height: 20px;
border-radius: 999px;
background: #fff;
border: 2px solid var(--brand);
box-shadow: 0 1px 4px rgba(16, 18, 29, .28);
cursor: grab;
}
.dualrange input[type="range"]::-webkit-slider-thumb:active { cursor: grabbing; }
.dualrange input[type="range"]::-moz-range-thumb {
pointer-events: auto;
width: 20px;
height: 20px;
border-radius: 999px;
background: #fff;
border: 2px solid var(--brand);
box-shadow: 0 1px 4px rgba(16, 18, 29, .28);
cursor: grab;
}
.dualrange input[type="range"]:focus-visible::-webkit-slider-thumb { box-shadow: var(--ring); }
.dualrange input[type="range"]:focus-visible::-moz-range-thumb { box-shadow: var(--ring); }
/* Color swatches */
.swatches {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(46px, 1fr));
gap: 10px;
}
.swatch {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
}
.swatch__btn {
width: 38px;
height: 38px;
border-radius: 999px;
border: 2px solid var(--line);
padding: 0;
position: relative;
transition: transform .12s ease, box-shadow .12s ease;
}
.swatch__btn:hover { transform: scale(1.06); }
.swatch__btn[aria-pressed="true"] {
box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ink);
}
.swatch__btn[aria-pressed="true"]::after {
content: "✓";
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: #fff;
font-weight: 800;
font-size: .85rem;
text-shadow: 0 1px 2px rgba(0, 0, 0, .5);
}
.swatch__btn--light[aria-pressed="true"]::after { color: var(--ink); text-shadow: none; }
.swatch__name { font-size: .68rem; color: var(--muted); }
/* Size chips */
.sizechips {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(48px, 1fr));
gap: 8px;
}
.sizechip {
padding: 9px 4px;
border: 1.5px solid var(--line);
border-radius: 10px;
background: var(--bg);
font-weight: 600;
font-size: .85rem;
color: var(--ink-soft);
font-variant-numeric: tabular-nums;
transition: all .12s ease;
}
.sizechip:hover:not(:disabled) { border-color: var(--brand); color: var(--brand); }
.sizechip[aria-pressed="true"] {
background: var(--ink);
border-color: var(--ink);
color: #fff;
}
.sizechip:disabled {
opacity: .4;
cursor: not-allowed;
text-decoration: line-through;
}
/* Rating list */
.ratinglist { display: grid; gap: 2px; }
.rating-opt {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
background: none;
border: none;
padding: 8px;
border-radius: 9px;
text-align: left;
}
.rating-opt:hover { background: var(--surface); }
.rating-opt[aria-checked="true"] { background: var(--brand-soft); }
.rating-opt__stars { display: inline-flex; gap: 1px; color: var(--star); }
.rating-opt__stars .s-off { color: rgba(16, 18, 29, .16); }
.rating-opt__label { font-size: .85rem; color: var(--muted); font-weight: 500; }
.rating-opt[aria-checked="true"] .rating-opt__label { color: var(--brand-d); font-weight: 600; }
/* Brand search */
.brandsearch {
display: flex;
align-items: center;
gap: 8px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 10px;
padding: 0 10px;
margin-bottom: 10px;
color: var(--muted);
}
.brandsearch:focus-within { border-color: var(--brand); box-shadow: var(--ring); }
.brandsearch input {
flex: 1;
border: none;
background: none;
padding: 9px 0;
font: inherit;
color: var(--ink);
min-width: 0;
}
.brandsearch input:focus { outline: none; }
.brandsearch__empty {
font-size: .82rem;
color: var(--muted);
padding: 6px 8px;
}
mark.hl { background: rgba(245, 166, 35, .35); color: inherit; border-radius: 3px; }
/* Apply footer */
.rail__apply {
display: none;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
border-top: 1px solid var(--line);
background: var(--bg);
}
.apply-btn {
width: 100%;
background: var(--brand);
color: #fff;
border: none;
border-radius: 12px;
padding: 14px;
font-weight: 700;
font-size: 1rem;
box-shadow: 0 8px 20px rgba(52, 87, 255, .3);
}
.apply-btn:hover { background: var(--brand-d); }
/* ---------- Results ---------- */
.results__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 18px;
}
.results__count { font-size: .95rem; color: var(--muted); }
.results__count strong {
color: var(--ink);
font-variant-numeric: tabular-nums;
}
.sort { display: inline-flex; align-items: center; gap: 8px; }
.sort__label { font-size: .85rem; color: var(--muted); font-weight: 600; }
.sort select {
font: inherit;
font-weight: 600;
color: var(--ink);
background: var(--bg);
border: 1px solid var(--line);
border-radius: 10px;
padding: 9px 12px;
box-shadow: var(--shadow);
}
.sort select:hover { border-color: var(--brand); }
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18px;
}
.card {
background: var(--bg);
border: 1px solid var(--line-2);
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
animation: pop .26s ease both;
}
@keyframes pop {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
.card__media {
aspect-ratio: 4 / 3;
position: relative;
display: grid;
place-items: center;
}
.card__shoe { width: 78%; height: 78%; }
.card__badge {
position: absolute;
top: 10px;
left: 10px;
font-size: .68rem;
font-weight: 800;
letter-spacing: .04em;
text-transform: uppercase;
padding: 4px 9px;
border-radius: 999px;
color: #fff;
}
.card__badge--sale { background: var(--sale); }
.card__badge--new { background: var(--ok); }
.card__oos {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: rgba(247, 248, 251, .72);
backdrop-filter: blur(1px);
font-weight: 700;
font-size: .82rem;
letter-spacing: .03em;
text-transform: uppercase;
color: var(--muted);
}
.card__body { padding: 13px 14px 15px; display: flex; flex-direction: column; gap: 5px; }
.card__brand {
font-size: .7rem;
font-weight: 700;
letter-spacing: .05em;
text-transform: uppercase;
color: var(--muted);
}
.card__name { font-size: .94rem; font-weight: 600; letter-spacing: -.01em; }
.card__rate {
display: flex;
align-items: center;
gap: 6px;
font-size: .78rem;
color: var(--muted);
}
.card__stars { color: var(--star); letter-spacing: 1px; }
.card__price {
display: flex;
align-items: baseline;
gap: 8px;
margin-top: 3px;
font-variant-numeric: tabular-nums;
}
.card__now { font-weight: 800; font-size: 1.02rem; }
.card__was { color: var(--muted); text-decoration: line-through; font-size: .82rem; }
.card__sale { color: var(--sale); }
/* Empty */
.empty {
text-align: center;
padding: 60px 20px;
border: 1px dashed var(--line);
border-radius: var(--radius);
background: var(--bg);
}
.empty__art { font-size: 2.6rem; margin-bottom: 10px; }
.empty__title { font-weight: 700; margin-bottom: 16px; }
.ghost-btn {
background: var(--bg);
border: 1px solid var(--line);
color: var(--brand);
font-weight: 600;
padding: 10px 18px;
border-radius: 10px;
}
.ghost-btn:hover { border-color: var(--brand); background: var(--brand-soft); }
/* Toast */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 30px);
background: var(--ink);
color: #fff;
padding: 11px 18px;
border-radius: 12px;
font-weight: 600;
font-size: .9rem;
box-shadow: var(--shadow-lg);
opacity: 0;
pointer-events: none;
transition: opacity .22s ease, transform .22s ease;
z-index: 90;
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }
/* ---------- Responsive ---------- */
@media (max-width: 980px) {
.grid { grid-template-columns: repeat(2, 1fr); }
.layout { grid-template-columns: 270px 1fr; gap: 22px; }
}
@media (max-width: 820px) {
.drawer-toggle { display: inline-flex; }
.layout { grid-template-columns: 1fr; }
.rail {
position: fixed;
inset: 0;
z-index: 70;
top: 0;
pointer-events: none;
}
.rail.open { pointer-events: auto; }
.rail__overlay {
display: block;
position: absolute;
inset: 0;
background: rgba(16, 18, 29, .42);
opacity: 0;
transition: opacity .26s ease;
}
.rail.open .rail__overlay { opacity: 1; }
.rail__panel {
position: absolute;
top: 0;
left: 0;
width: min(360px, 88vw);
height: 100%;
max-height: none;
border-radius: 0;
border: none;
transform: translateX(-104%);
transition: transform .3s cubic-bezier(.4, 0, .2, 1);
box-shadow: var(--shadow-lg);
}
.rail.open .rail__panel { transform: none; }
.rail__close { display: inline-flex; }
.rail__apply { display: block; }
}
@media (max-width: 460px) {
.shell { padding: 20px 14px 56px; }
.grid { grid-template-columns: 1fr; gap: 14px; }
.card__media { aspect-ratio: 16 / 10; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .001ms !important; transition-duration: .001ms !important; }
}(() => {
"use strict";
/* ----------------------------------------------------------------
* Fictional catalog. Each product carries the attributes the rail
* facets against. Prices in whole dollars; rating 1 decimal.
* ---------------------------------------------------------------- */
const COLORS = {
black: { name: "Black", hex: "#1c1f26" },
white: { name: "White", hex: "#f3f4f6", light: true },
blue: { name: "Blue", hex: "#3457ff" },
red: { name: "Red", hex: "#e0245e" },
green: { name: "Green", hex: "#1f9d55" },
grey: { name: "Grey", hex: "#9aa1ad" },
orange: { name: "Orange", hex: "#f5811e" },
purple: { name: "Purple", hex: "#7c4dff" }
};
const CATEGORIES = ["Road", "Trail", "Track", "Walking", "Recovery"];
const BRANDS = ["Strideworks", "Apex Run", "Tempo", "Northpeak", "Velo Athletics",
"Cadence", "Trailhead Co.", "Pace Lab", "Halcyon", "Meridian"];
const SIZES = [6, 7, 8, 9, 10, 11, 12, 13];
const NAMES = ["Velocity", "Glide", "Terra", "Summit", "Pulse", "Drift", "Cloudstep",
"Trailblaze", "Momentum", "Cinder", "Horizon", "Quartz", "Vector", "Aero", "Granite",
"Stratus", "Ridgeline", "Kestrel", "Nimbus", "Ember", "Solstice", "Comet", "Pioneer",
"Tundra", "Zephyr", "Boulder", "Marathon", "Sprint"];
function seeded(i) {
// tiny deterministic generator so the demo is stable across reloads
const x = Math.sin(i * 99.71) * 43758.5453;
return x - Math.floor(x);
}
const PRODUCTS = NAMES.map((nm, i) => {
const r = (n) => seeded(i + n);
const colorKeys = Object.keys(COLORS);
const cols = [];
const cCount = 2 + Math.floor(r(1) * 3);
while (cols.length < cCount) {
const c = colorKeys[Math.floor(r(cols.length + 2) * colorKeys.length)];
if (!cols.includes(c)) cols.push(c);
}
const sizes = SIZES.filter((_, si) => r(si + 20) > 0.22);
const base = 70 + Math.round(r(7) * 46) * 5; // 70..300, step 5
const onSale = r(8) > 0.66;
const price = onSale ? Math.round(base * (0.78 - r(9) * 0.12)) : base;
const inStock = r(10) > 0.12;
return {
id: i + 1,
name: nm,
brand: BRANDS[Math.floor(r(11) * BRANDS.length)],
category: CATEGORIES[Math.floor(r(12) * CATEGORIES.length)],
colors: cols,
sizes: sizes.length ? sizes : [9, 10],
price,
was: onSale ? base : null,
rating: Math.round((3.4 + r(13) * 1.6) * 10) / 10,
reviews: 8 + Math.floor(r(14) * 920),
isNew: !onSale && r(15) > 0.78,
inStock
};
});
const PRICE_MAX = 300;
/* ---------------- State ---------------- */
const state = {
categories: new Set(),
brands: new Set(),
colors: new Set(),
sizes: new Set(),
rating: 0,
inStock: false,
priceMin: 0,
priceMax: PRICE_MAX,
sort: "featured"
};
/* ---------------- DOM ---------------- */
const $ = (s, r = document) => r.querySelector(s);
const grid = $("#grid");
const empty = $("#empty");
const resultCount = $("#resultCount");
const applyCount = $("#applyCount");
const appliedBadge = $("#appliedBadge");
const toggleCount = $("#toggleCount");
const clearAllBtn = $("#clearAll");
const chipRow = $("#chipRow");
const activeFilters = $("#activeFilters");
/* ---------------- Helpers ---------------- */
const money = (n) => "$" + n.toLocaleString("en-US");
const esc = (s) => String(s).replace(/[&<>"']/g, (c) =>
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
let toastTimer;
function toast(msg) {
const t = $("#toast");
t.textContent = msg;
t.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => t.classList.remove("show"), 1900);
}
function stars(rating) {
const full = Math.round(rating);
return "★★★★★".slice(0, full) + "☆☆☆☆☆".slice(0, 5 - full);
}
function svgStars(min) {
let out = "";
for (let i = 1; i <= 5; i++) {
out += `<span class="${i <= min ? "" : "s-off"}">★</span>`;
}
return out;
}
/* ---------------- Counts (computed against current filters,
* excluding the facet being counted, so each option shows how many
* products it WOULD add). For simplicity we count against base set
* + the other active facets. ---------------- */
function matches(p, skip) {
if (skip !== "category" && state.categories.size && !state.categories.has(p.category)) return false;
if (skip !== "brand" && state.brands.size && !state.brands.has(p.brand)) return false;
if (skip !== "color" && state.colors.size && !p.colors.some((c) => state.colors.has(c))) return false;
if (skip !== "size" && state.sizes.size && !p.sizes.some((s) => state.sizes.has(s))) return false;
if (skip !== "rating" && state.rating && p.rating < state.rating) return false;
if (skip !== "stock" && state.inStock && !p.inStock) return false;
if (skip !== "price" && (p.price < state.priceMin || p.price > state.priceMax)) return false;
return true;
}
const filtered = () => PRODUCTS.filter((p) => matches(p, null));
function countFor(facet, predicate) {
return PRODUCTS.filter((p) => matches(p, facet) && predicate(p)).length;
}
/* ---------------- Build facet controls ---------------- */
function buildCategories() {
$("#catList").innerHTML = CATEGORIES.map((c) => `
<li>
<label class="check">
<input type="checkbox" data-facet="category" value="${esc(c)}" />
<span class="check__box" aria-hidden="true">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none"><path d="M5 12l5 5L20 6" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
<span class="check__name">${esc(c)}</span>
<span class="check__count" data-count="category:${esc(c)}">0</span>
</label>
</li>`).join("");
}
function buildBrands(filter = "") {
const f = filter.trim().toLowerCase();
const list = BRANDS.filter((b) => b.toLowerCase().includes(f));
$("#brandEmpty").hidden = list.length > 0;
$("#brandList").innerHTML = list.map((b) => {
const label = f
? esc(b).replace(new RegExp(`(${f.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "ig"),
"<mark class=\"hl\">$1</mark>")
: esc(b);
return `
<li>
<label class="check">
<input type="checkbox" data-facet="brand" value="${esc(b)}" ${state.brands.has(b) ? "checked" : ""} />
<span class="check__box" aria-hidden="true">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none"><path d="M5 12l5 5L20 6" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
<span class="check__name">${label}</span>
<span class="check__count" data-count="brand:${esc(b)}">0</span>
</label>
</li>`;
}).join("");
}
function buildColors() {
$("#colorList").innerHTML = Object.entries(COLORS).map(([key, c]) => `
<li class="swatch">
<button class="swatch__btn${c.light ? " swatch__btn--light" : ""}" type="button"
data-facet="color" value="${key}" aria-pressed="false"
aria-label="${esc(c.name)}" style="background:${c.hex}"></button>
<span class="swatch__name">${esc(c.name)}</span>
</li>`).join("");
}
function buildSizes() {
$("#sizeList").innerHTML = SIZES.map((s) => `
<li>
<button class="sizechip" type="button" data-facet="size" value="${s}"
aria-pressed="false" aria-label="US ${s}">${s}</button>
</li>`).join("");
}
function buildRatings() {
$("#ratingList").innerHTML = [4, 3, 2].map((r) => `
<li>
<button class="rating-opt" type="button" role="radio" aria-checked="false"
data-facet="rating" value="${r}">
<span class="rating-opt__stars" aria-hidden="true">${svgStars(r)}</span>
<span class="rating-opt__label">${r} & up</span>
</button>
</li>`).join("");
}
/* ---------------- Sync counts + disabled states on controls ---------------- */
function refreshCounts() {
// category counts
CATEGORIES.forEach((c) => {
const el = $(`[data-count="category:${CSS.escape(c)}"]`);
if (el) el.textContent = countFor("category", (p) => p.category === c);
});
// brand counts
BRANDS.forEach((b) => {
const el = $(`[data-count="brand:${CSS.escape(b)}"]`);
if (el) el.textContent = countFor("brand", (p) => p.brand === b);
});
// size availability — disable sizes that yield 0 results
document.querySelectorAll('[data-facet="size"]').forEach((btn) => {
const s = Number(btn.value);
const n = countFor("size", (p) => p.sizes.includes(s));
const active = state.sizes.has(s);
btn.disabled = n === 0 && !active;
btn.title = active ? "" : `${n} result${n === 1 ? "" : "s"}`;
});
}
/* ---------------- Active filter chips ---------------- */
function renderChips() {
const chips = [];
state.categories.forEach((v) => chips.push({ facet: "category", val: v, label: v }));
state.colors.forEach((v) => chips.push({
facet: "color", val: v, label: COLORS[v].name, swatch: COLORS[v].hex
}));
state.sizes.forEach((v) => chips.push({ facet: "size", val: String(v), label: "US " + v }));
state.brands.forEach((v) => chips.push({ facet: "brand", val: v, label: v }));
if (state.rating) chips.push({ facet: "rating", val: String(state.rating), label: state.rating + "★ & up" });
if (state.inStock) chips.push({ facet: "stock", val: "1", label: "In stock" });
if (state.priceMin > 0 || state.priceMax < PRICE_MAX) {
chips.push({ facet: "price", val: "1", label: `${money(state.priceMin)} – ${money(state.priceMax)}` });
}
const count = chips.length;
activeFilters.hidden = count === 0;
chipRow.innerHTML = chips.map((c) => `
<li>
<span class="chip">
${c.swatch ? `<span class="chip__swatch" style="background:${c.swatch}"></span>` : ""}
${esc(c.label)}
<button class="chip__x" type="button" data-remove="${c.facet}" data-val="${esc(c.val)}"
aria-label="Remove ${esc(c.label)} filter">×</button>
</span>
</li>`).join("");
// applied count surfaces
appliedBadge.hidden = count === 0;
appliedBadge.textContent = count + " applied";
toggleCount.hidden = count === 0;
toggleCount.textContent = count;
clearAllBtn.disabled = count === 0;
return count;
}
function removeChip(facet, val) {
switch (facet) {
case "category": state.categories.delete(val); syncCheckbox("category", val, false); break;
case "brand": state.brands.delete(val); syncCheckbox("brand", val, false); break;
case "color": state.colors.delete(val); syncPressed("color", val, false); break;
case "size": state.sizes.delete(Number(val)); syncPressed("size", val, false); break;
case "rating": state.rating = 0; syncRating(0); break;
case "stock": state.inStock = false; $("#inStock").checked = false; break;
case "price":
state.priceMin = 0; state.priceMax = PRICE_MAX;
$("#priceMin").value = 0; $("#priceMax").value = PRICE_MAX; updatePriceUI();
break;
}
render();
}
function syncCheckbox(facet, val, on) {
const el = document.querySelector(`input[data-facet="${facet}"][value="${CSS.escape(val)}"]`);
if (el) el.checked = on;
}
function syncPressed(facet, val, on) {
const el = document.querySelector(`button[data-facet="${facet}"][value="${CSS.escape(val)}"]`);
if (el) el.setAttribute("aria-pressed", on ? "true" : "false");
}
function syncRating(val) {
document.querySelectorAll('[data-facet="rating"]').forEach((b) =>
b.setAttribute("aria-checked", Number(b.value) === val ? "true" : "false"));
}
/* ---------------- Price dual range ---------------- */
const minIn = $("#priceMin");
const maxIn = $("#priceMax");
function updatePriceUI() {
let lo = Number(minIn.value);
let hi = Number(maxIn.value);
if (lo > hi - 5) { // keep a 5-unit gap; nudge the one being dragged
if (document.activeElement === minIn) { lo = hi - 5; minIn.value = lo; }
else { hi = lo + 5; maxIn.value = hi; }
}
state.priceMin = lo;
state.priceMax = hi;
$("#priceMinOut").textContent = money(lo);
$("#priceMaxOut").textContent = money(hi);
const pctLo = (lo / PRICE_MAX) * 100;
const pctHi = (hi / PRICE_MAX) * 100;
const fill = $("#rangeFill");
fill.style.left = pctLo + "%";
fill.style.right = (100 - pctHi) + "%";
}
/* ---------------- Render results ---------------- */
function shoeSVG(p) {
const c1 = COLORS[p.colors[0]].hex;
const c2 = COLORS[p.colors[1] || p.colors[0]].hex;
const id = "g" + p.id;
return `
<div class="card__media" style="background:linear-gradient(135deg,${c1}1a,${c2}33)">
<svg class="card__shoe" viewBox="0 0 120 90" role="img" aria-label="${esc(p.name)} shoe">
<defs><linearGradient id="${id}" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="${c1}"/><stop offset="1" stop-color="${c2}"/>
</linearGradient></defs>
<path d="M10 58 C16 40 30 36 44 40 C54 43 60 50 74 50 C90 50 102 54 110 62 L110 68 C110 72 107 74 102 74 L18 74 C12 74 8 70 8 64 Z"
fill="url(#${id})"/>
<path d="M8 64 L112 64 L112 70 C112 73 110 74 106 74 L16 74 C11 74 8 70 8 64 Z" fill="rgba(0,0,0,.18)"/>
<path d="M44 41 C50 36 58 35 66 38" stroke="rgba(255,255,255,.7)" stroke-width="2.5" fill="none" stroke-linecap="round"/>
<circle cx="52" cy="44" r="2" fill="rgba(255,255,255,.85)"/>
<circle cx="60" cy="46" r="2" fill="rgba(255,255,255,.85)"/>
</svg>
${p.was ? '<span class="card__badge card__badge--sale">Sale</span>' :
p.isNew ? '<span class="card__badge card__badge--new">New</span>' : ""}
${!p.inStock ? '<div class="card__oos">Sold out</div>' : ""}
</div>`;
}
function cardHTML(p) {
const off = p.was ? Math.round((1 - p.price / p.was) * 100) : 0;
return `
<li class="card">
${shoeSVG(p)}
<div class="card__body">
<span class="card__brand">${esc(p.brand)}</span>
<h3 class="card__name">${esc(p.name)} ${esc(p.category)}</h3>
<div class="card__rate">
<span class="card__stars" aria-hidden="true">${stars(p.rating)}</span>
<span>${p.rating.toFixed(1)} · ${p.reviews.toLocaleString("en-US")}</span>
</div>
<div class="card__price">
<span class="card__now${p.was ? " card__sale" : ""}">${money(p.price)}</span>
${p.was ? `<span class="card__was">${money(p.was)}</span><span class="card__sale">−${off}%</span>` : ""}
</div>
</div>
</li>`;
}
function sortList(list) {
const arr = list.slice();
switch (state.sort) {
case "price-asc": arr.sort((a, b) => a.price - b.price); break;
case "price-desc": arr.sort((a, b) => b.price - a.price); break;
case "rating": arr.sort((a, b) => b.rating - a.rating || b.reviews - a.reviews); break;
default: arr.sort((a, b) => (b.isNew - a.isNew) || (b.rating - a.rating)); break;
}
return arr;
}
function render() {
const list = sortList(filtered());
resultCount.textContent = list.length;
applyCount.textContent = list.length;
empty.hidden = list.length !== 0;
grid.hidden = list.length === 0;
grid.innerHTML = list.map(cardHTML).join("");
renderChips();
refreshCounts();
}
/* ---------------- Event wiring ---------------- */
function onCheckbox(e) {
const el = e.target.closest('input[type="checkbox"][data-facet]');
if (!el) return;
const facet = el.dataset.facet;
const setName = facet === "category" ? "categories" : "brands";
if (el.checked) state[setName].add(el.value);
else state[setName].delete(el.value);
render();
}
document.querySelector("[data-rail-form]").addEventListener("change", (e) => {
if (e.target.matches('input[type="checkbox"][data-facet]')) onCheckbox(e);
if (e.target.id === "inStock") { state.inStock = e.target.checked; render(); }
});
// color + size + rating (button toggles via click delegation)
document.querySelector("[data-rail-form]").addEventListener("click", (e) => {
const color = e.target.closest('[data-facet="color"]');
if (color) {
const on = color.getAttribute("aria-pressed") !== "true";
color.setAttribute("aria-pressed", on ? "true" : "false");
on ? state.colors.add(color.value) : state.colors.delete(color.value);
render(); return;
}
const size = e.target.closest('[data-facet="size"]');
if (size && !size.disabled) {
const on = size.getAttribute("aria-pressed") !== "true";
size.setAttribute("aria-pressed", on ? "true" : "false");
const v = Number(size.value);
on ? state.sizes.add(v) : state.sizes.delete(v);
render(); return;
}
const rate = e.target.closest('[data-facet="rating"]');
if (rate) {
const v = Number(rate.value);
state.rating = state.rating === v ? 0 : v; // toggle off if same
syncRating(state.rating);
render(); return;
}
});
// price slider
[minIn, maxIn].forEach((el) =>
el.addEventListener("input", () => { updatePriceUI(); render(); }));
// brand search
let brandTimer;
$("#brandSearch").addEventListener("input", (e) => {
clearTimeout(brandTimer);
const v = e.target.value;
brandTimer = setTimeout(() => { buildBrands(v); refreshCounts(); }, 90);
});
// chip removal
chipRow.addEventListener("click", (e) => {
const x = e.target.closest("[data-remove]");
if (x) removeChip(x.dataset.remove, x.dataset.val);
});
// collapse / expand groups
document.querySelectorAll(".facet__head").forEach((head) => {
head.addEventListener("click", () => {
const open = head.getAttribute("aria-expanded") === "true";
head.setAttribute("aria-expanded", open ? "false" : "true");
});
});
// clear all
function clearAll() {
state.categories.clear();
state.brands.clear();
state.colors.clear();
state.sizes.clear();
state.rating = 0;
state.inStock = false;
state.priceMin = 0;
state.priceMax = PRICE_MAX;
minIn.value = 0; maxIn.value = PRICE_MAX; updatePriceUI();
$("#inStock").checked = false;
document.querySelectorAll('input[data-facet]').forEach((i) => (i.checked = false));
document.querySelectorAll('[data-facet="color"],[data-facet="size"]')
.forEach((b) => b.setAttribute("aria-pressed", "false"));
syncRating(0);
buildBrands($("#brandSearch").value);
render();
toast("Filters cleared");
}
clearAllBtn.addEventListener("click", clearAll);
$("#emptyClear").addEventListener("click", clearAll);
// sort
$("#sortBy").addEventListener("change", (e) => { state.sort = e.target.value; render(); });
/* ---------------- Mobile drawer ---------------- */
const rail = $("#rail");
const drawerToggle = $("#drawerToggle");
function openDrawer() {
rail.classList.add("open");
drawerToggle.setAttribute("aria-expanded", "true");
document.body.style.overflow = "hidden";
const first = rail.querySelector(".rail__close");
if (first) first.focus();
}
function closeDrawer() {
rail.classList.remove("open");
drawerToggle.setAttribute("aria-expanded", "false");
document.body.style.overflow = "";
drawerToggle.focus();
}
drawerToggle.addEventListener("click", openDrawer);
$("#railClose").addEventListener("click", closeDrawer);
$("#railOverlay").addEventListener("click", closeDrawer);
$("#applyBtn").addEventListener("click", () => {
closeDrawer();
toast(`${filtered().length} results shown`);
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && rail.classList.contains("open")) closeDrawer();
});
/* ---------------- Init ---------------- */
buildCategories();
buildBrands();
buildColors();
buildSizes();
buildRatings();
updatePriceUI();
render();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Shop — Faceted Filter Sidebar</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="#rail">Skip to filters</a>
<div class="shell">
<header class="pagehead" role="banner">
<div class="pagehead__row">
<div>
<p class="eyebrow">New Arrivals · Footwear</p>
<h1 class="pagehead__title">Running & Trail Shoes</h1>
</div>
<button class="drawer-toggle" type="button" id="drawerToggle" aria-expanded="false" aria-controls="rail">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
<path d="M3 6h18M6 12h12M10 18h4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
Filters
<span class="drawer-toggle__count" id="toggleCount" hidden>0</span>
</button>
</div>
</header>
<div class="layout">
<!-- ============ THE REUSABLE FILTER RAIL ============ -->
<aside class="rail" id="rail" aria-label="Product filters">
<div class="rail__overlay" id="railOverlay" hidden></div>
<div class="rail__panel" role="region" aria-label="Filter facets">
<div class="rail__top">
<div class="rail__heading">
<h2 class="rail__title">Filters</h2>
<span class="applied-badge" id="appliedBadge" hidden>0 applied</span>
</div>
<button class="clear-all" type="button" id="clearAll" disabled>Clear all</button>
<button class="rail__close" type="button" id="railClose" aria-label="Close filters">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</div>
<!-- Active filter chips -->
<div class="active-filters" id="activeFilters" hidden aria-live="polite">
<ul class="chips" id="chipRow"></ul>
</div>
<div class="rail__scroll" data-rail-form>
<!-- In-stock toggle -->
<section class="facet facet--toggle">
<label class="switch">
<input type="checkbox" id="inStock" data-facet="stock" />
<span class="switch__track" aria-hidden="true"><span class="switch__thumb"></span></span>
<span class="switch__label">In stock only</span>
</label>
</section>
<!-- Category -->
<section class="facet" data-group="category">
<button class="facet__head" type="button" aria-expanded="true">
<span class="facet__name">Category</span>
<svg class="facet__chev" viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
<path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div class="facet__body">
<ul class="checklist" id="catList"></ul>
</div>
</section>
<!-- Price -->
<section class="facet" data-group="price">
<button class="facet__head" type="button" aria-expanded="true">
<span class="facet__name">Price</span>
<svg class="facet__chev" viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
<path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div class="facet__body">
<div class="price">
<div class="price__readout">
<span id="priceMinOut">$0</span>
<span class="price__dash" aria-hidden="true">–</span>
<span id="priceMaxOut">$300</span>
</div>
<div class="dualrange" id="dualRange">
<div class="dualrange__track" aria-hidden="true">
<div class="dualrange__fill" id="rangeFill"></div>
</div>
<input type="range" id="priceMin" min="0" max="300" step="5" value="0"
aria-label="Minimum price" />
<input type="range" id="priceMax" min="0" max="300" step="5" value="300"
aria-label="Maximum price" />
</div>
</div>
</div>
</section>
<!-- Color -->
<section class="facet" data-group="color">
<button class="facet__head" type="button" aria-expanded="true">
<span class="facet__name">Color</span>
<svg class="facet__chev" viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
<path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div class="facet__body">
<ul class="swatches" id="colorList" role="group" aria-label="Color"></ul>
</div>
</section>
<!-- Size -->
<section class="facet" data-group="size">
<button class="facet__head" type="button" aria-expanded="true">
<span class="facet__name">Size (US)</span>
<svg class="facet__chev" viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
<path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div class="facet__body">
<ul class="sizechips" id="sizeList" role="group" aria-label="Size"></ul>
</div>
</section>
<!-- Rating -->
<section class="facet" data-group="rating">
<button class="facet__head" type="button" aria-expanded="true">
<span class="facet__name">Rating</span>
<svg class="facet__chev" viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
<path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div class="facet__body">
<ul class="ratinglist" id="ratingList" role="radiogroup" aria-label="Minimum rating"></ul>
</div>
</section>
<!-- Brand -->
<section class="facet" data-group="brand">
<button class="facet__head" type="button" aria-expanded="true">
<span class="facet__name">Brand</span>
<svg class="facet__chev" viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
<path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div class="facet__body">
<div class="brandsearch">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" aria-hidden="true">
<circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2"/>
<path d="M21 21l-4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<input type="search" id="brandSearch" placeholder="Search brands" aria-label="Search brands" autocomplete="off" />
</div>
<ul class="checklist checklist--scroll" id="brandList"></ul>
<p class="brandsearch__empty" id="brandEmpty" hidden>No brands match.</p>
</div>
</section>
</div>
<div class="rail__apply">
<button class="apply-btn" type="button" id="applyBtn">
Show <span id="applyCount">0</span> results
</button>
</div>
</div>
</aside>
<!-- ============ RESULTS PREVIEW (driven by the rail) ============ -->
<main class="results" id="results" aria-live="polite">
<div class="results__bar">
<p class="results__count"><strong id="resultCount">0</strong> results</p>
<label class="sort">
<span class="sort__label">Sort</span>
<select id="sortBy" 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>
</select>
</label>
</div>
<ul class="grid" id="grid"></ul>
<div class="empty" id="empty" hidden>
<div class="empty__art" aria-hidden="true">🔍</div>
<p class="empty__title">No products match your filters</p>
<button class="ghost-btn" type="button" id="emptyClear">Clear all filters</button>
</div>
</main>
</div>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Faceted Filter Sidebar
The reusable filter rail every product-listing page needs. Collapsible facet groups stack down a sticky sidebar: category checkboxes that show how many products each option would add, a real dual-handle price slider with a clamped minimum gap, a tappable color swatch grid, US size chips that disable themselves when nothing’s in stock, a star-rating radio group, a searchable brand list that highlights matches as you type, and a green in-stock toggle. A live result count and a paired product grid (inline-SVG “shoe photography” on soft tinted tiles) react instantly to confirm every facet works.
Each control updates an active-filters chip row at the top of the rail — every chip carries its own × to remove just that filter, color chips even mirror the swatch. The applied count surfaces in three places (a badge by the title, the chip total, and the mobile Filters button), and Clear all resets the entire state in one tap. Counts recompute against the other active facets, so the numbers always reflect what you’d actually get. Sorting (Featured, price low/high, top rated) reorders the preview grid without touching the filters.
On narrow screens the rail collapses into a slide-in drawer behind a Filters button, closable by the overlay, the close icon, or Escape, with a sticky “Show N results” apply bar at the bottom. Facet headers collapse and expand, the grid steps from three columns to one, and everything stays keyboard-operable with visible focus rings, ARIA roles on the custom swatch, chip, rating, and toggle controls, and an empty state when no products match.
Illustrative storefront UI only — fictional products, prices, and reviews. No real checkout.