Shop — Variant Selector
An e-commerce variant selector primitive with named color swatches that cross out sold-out shades, a material picker that adjusts the price, and size chips whose availability is computed per color and material so disabled combinations are clearly shown. Choosing a color or material filters the available sizes, while a live summary, per-variant price, compare-at savings, and stock chip stay in sync. Includes a size-guide dialog, keyboard-roving radio groups, and validation before add to cart.
MCP
Code
:root {
--bg: #ffffff;
--ink: #16181d;
--muted: #6b7280;
--brand: #3457ff;
--brand-d: #2742d6;
--sale: #e0245e;
--ok: #1f9d55;
--warn: #c2700a;
--line: rgba(16, 18, 29, .1);
--line-2: rgba(16, 18, 29, .16);
--soft: #f6f7fb;
--tile-a: #1b1d24;
--tile-b: #3a3f4d;
--shadow: 0 1px 2px rgba(16, 18, 29, .06), 0 16px 40px -24px rgba(16, 18, 29, .35);
--radius: 18px;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background:
radial-gradient(1200px 600px at 80% -10%, rgba(52, 87, 255, .08), transparent 60%),
radial-gradient(900px 500px at -10% 110%, rgba(224, 36, 94, .06), transparent 55%),
var(--bg);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100vh;
}
.skip-link {
position: absolute;
left: -999px;
top: 8px;
background: var(--ink);
color: #fff;
padding: 10px 16px;
border-radius: 10px;
z-index: 50;
font-weight: 600;
}
.skip-link:focus { left: 16px; }
.shell {
max-width: 980px;
margin: clamp(20px, 5vw, 64px) auto;
padding: 0 16px;
}
.card {
display: grid;
grid-template-columns: 1.05fr 1fr;
gap: clamp(20px, 3vw, 40px);
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: clamp(18px, 3vw, 32px);
box-shadow: var(--shadow);
}
/* ---------- Media ---------- */
.media { min-width: 0; }
.media-tile {
position: relative;
aspect-ratio: 4 / 3;
border-radius: 14px;
display: grid;
place-items: center;
overflow: hidden;
background: linear-gradient(150deg, var(--tile-a), var(--tile-b));
transition: background .35s ease;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .08);
}
.media-tile::after {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(420px 240px at 30% 20%, rgba(255, 255, 255, .22), transparent 60%);
pointer-events: none;
}
.media-badge {
position: absolute;
top: 12px;
left: 12px;
background: rgba(255, 255, 255, .92);
color: var(--ink);
font-size: 12px;
font-weight: 700;
letter-spacing: .02em;
padding: 5px 11px;
border-radius: 999px;
z-index: 2;
}
.shoe { width: 78%; height: auto; filter: drop-shadow(0 18px 26px rgba(0, 0, 0, .35)); }
.shoe-body { fill: rgba(255, 255, 255, .94); transition: fill .35s ease; }
.shoe-sole { stroke: none; }
.shoe-lace { stroke: rgba(20, 22, 30, .35); }
.shoe-eye { fill: rgba(20, 22, 30, .45); }
.thumbs {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-top: 12px;
}
.thumb {
font-size: 11px;
font-weight: 600;
color: var(--muted);
text-align: center;
padding: 14px 6px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--soft);
}
/* ---------- Buy box ---------- */
.buy { min-width: 0; }
.eyebrow {
margin: 0 0 6px;
font-size: 12px;
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--brand);
}
.title {
margin: 0 0 10px;
font-size: clamp(22px, 3.4vw, 30px);
font-weight: 800;
letter-spacing: -.02em;
line-height: 1.15;
}
.rating {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
margin-bottom: 14px;
}
.stars { color: #f5a623; letter-spacing: 1px; }
.stars .half {
background: linear-gradient(90deg, #f5a623 50%, rgba(245, 166, 35, .28) 50%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.muted { color: var(--muted); }
.price-row {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 20px;
}
.price { font-size: 26px; font-weight: 800; letter-spacing: -.02em; }
.compare { color: var(--muted); text-decoration: line-through; font-size: 16px; }
.save-chip {
font-size: 12px;
font-weight: 700;
color: var(--sale);
background: rgba(224, 36, 94, .1);
padding: 4px 9px;
border-radius: 999px;
}
/* ---------- Fieldsets ---------- */
.group {
border: 0;
padding: 0;
margin: 0 0 22px;
}
.group-head {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0;
margin-bottom: 10px;
}
.group-label { font-size: 14px; font-weight: 700; }
.group-value { font-size: 14px; color: var(--muted); font-weight: 500; }
/* color swatches */
.swatches { display: flex; flex-wrap: wrap; gap: 12px; }
.swatch {
position: relative;
width: 42px;
height: 42px;
border-radius: 50%;
border: 2px solid var(--line-2);
cursor: pointer;
padding: 0;
background: transparent;
display: grid;
place-items: center;
transition: transform .15s ease, box-shadow .15s ease, border-color .15s ease;
}
.swatch .dot-fill {
width: 30px;
height: 30px;
border-radius: 50%;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .12);
}
.swatch:hover { transform: translateY(-1px); }
.swatch[aria-checked="true"] {
border-color: var(--ink);
box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ink);
}
.swatch[aria-disabled="true"] {
cursor: not-allowed;
opacity: .55;
}
.swatch[aria-disabled="true"]::before {
content: "";
position: absolute;
inset: 50% 0 auto;
height: 2px;
background: var(--sale);
transform: rotate(-32deg);
}
.swatch:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--brand);
}
/* chips (material + size) */
.chips { display: flex; flex-wrap: wrap; gap: 10px; }
.chip {
min-width: 48px;
padding: 10px 16px;
border: 1px solid var(--line-2);
border-radius: 12px;
background: var(--bg);
font-size: 14px;
font-weight: 600;
color: var(--ink);
cursor: pointer;
text-align: center;
transition: border-color .15s ease, background .15s ease, color .15s ease, transform .12s ease;
}
.chip:hover:not([aria-disabled="true"]) { border-color: var(--ink); transform: translateY(-1px); }
.chip[aria-checked="true"] {
border-color: var(--brand);
background: var(--brand);
color: #fff;
box-shadow: 0 6px 16px -8px var(--brand);
}
.chip[aria-disabled="true"] {
cursor: not-allowed;
color: var(--muted);
background: var(--soft);
border-style: dashed;
border-color: var(--line);
position: relative;
overflow: hidden;
}
.chip[aria-disabled="true"]::after {
content: "";
position: absolute;
inset: 50% 0 auto;
height: 1px;
background: var(--line-2);
transform: rotate(-12deg);
}
.chip:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--brand);
}
.sizes .chip { min-width: 54px; }
.size-guide {
display: inline-flex;
align-items: center;
gap: 5px;
background: none;
border: 0;
color: var(--brand);
font-size: 13px;
font-weight: 600;
cursor: pointer;
padding: 4px 6px;
border-radius: 8px;
font-family: inherit;
}
.size-guide:hover { text-decoration: underline; }
.size-guide:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
.hint {
margin: 10px 0 0;
font-size: 13px;
min-height: 1em;
color: var(--warn);
}
.hint:empty { margin: 0; min-height: 0; }
/* ---------- Summary / stock / cta ---------- */
.summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background: var(--soft);
border: 1px solid var(--line);
border-radius: 12px;
padding: 12px 16px;
margin-bottom: 12px;
}
.summary-label {
font-size: 11px;
font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--muted);
}
.summary-value { font-size: 14px; font-weight: 700; text-align: right; }
.stock-line {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
margin-bottom: 16px;
}
.stock-line .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--ok);
box-shadow: 0 0 0 4px rgba(31, 157, 85, .15);
}
.stock-line[data-state="low"] { color: var(--warn); }
.stock-line[data-state="low"] .dot { background: var(--warn); box-shadow: 0 0 0 4px rgba(194, 112, 10, .15); }
.stock-line[data-state="out"] { color: var(--sale); }
.stock-line[data-state="out"] .dot { background: var(--sale); box-shadow: 0 0 0 4px rgba(224, 36, 94, .15); }
.stock-line[data-state="pending"] { color: var(--muted); }
.stock-line[data-state="pending"] .dot { background: var(--muted); box-shadow: 0 0 0 4px rgba(107, 114, 128, .15); }
.error {
background: rgba(224, 36, 94, .08);
border: 1px solid rgba(224, 36, 94, .3);
color: var(--sale);
font-size: 13px;
font-weight: 600;
padding: 10px 14px;
border-radius: 10px;
margin-bottom: 14px;
}
.cta-row { display: flex; gap: 12px; margin-bottom: 18px; }
.btn-add {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
background: var(--brand);
color: #fff;
border: 0;
border-radius: 14px;
padding: 15px 18px;
font-size: 15px;
font-weight: 700;
font-family: inherit;
cursor: pointer;
transition: background .15s ease, transform .12s ease, box-shadow .15s ease;
box-shadow: 0 10px 24px -12px var(--brand);
}
.btn-add:hover { background: var(--brand-d); }
.btn-add:active { transform: translateY(1px); }
.btn-add:disabled {
background: var(--line-2);
color: var(--muted);
cursor: not-allowed;
box-shadow: none;
}
.btn-add:focus-visible { outline: 2px solid var(--brand-d); outline-offset: 3px; }
.btn-wish {
width: 54px;
display: grid;
place-items: center;
border: 1px solid var(--line-2);
background: var(--bg);
border-radius: 14px;
cursor: pointer;
color: var(--ink);
transition: color .15s ease, border-color .15s ease, background .15s ease;
}
.btn-wish:hover { border-color: var(--sale); color: var(--sale); }
.btn-wish[aria-pressed="true"] { color: var(--sale); border-color: var(--sale); background: rgba(224, 36, 94, .07); }
.btn-wish[aria-pressed="true"] svg path { fill: var(--sale); }
.btn-wish:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
.trust {
display: flex;
flex-wrap: wrap;
gap: 8px 18px;
list-style: none;
margin: 0;
padding: 14px 0 0;
border-top: 1px solid var(--line);
font-size: 12.5px;
font-weight: 600;
color: var(--muted);
}
.trust li { display: inline-flex; align-items: center; gap: 6px; }
.trust svg { color: var(--ok); }
/* ---------- Size dialog ---------- */
.dialog-backdrop {
position: fixed;
inset: 0;
background: rgba(16, 18, 29, .45);
z-index: 60;
}
.size-dialog {
position: fixed;
inset: 0;
margin: auto;
z-index: 70;
width: min(420px, calc(100vw - 32px));
max-height: 80vh;
border: 1px solid var(--line);
border-radius: 16px;
padding: 22px;
box-shadow: 0 24px 60px -20px rgba(16, 18, 29, .5);
color: var(--ink);
background: var(--bg);
}
.size-dialog::backdrop { background: rgba(16, 18, 29, .45); }
.dialog-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.dialog-head h2 { margin: 0; font-size: 18px; font-weight: 800; }
.dialog-close {
border: 0;
background: var(--soft);
width: 34px;
height: 34px;
border-radius: 10px;
font-size: 22px;
line-height: 1;
cursor: pointer;
color: var(--ink);
}
.dialog-close:hover { background: var(--line); }
.dialog-close:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
.size-table {
width: 100%;
border-collapse: collapse;
margin-top: 14px;
font-size: 14px;
}
.size-table th, .size-table td {
padding: 9px 8px;
text-align: left;
border-bottom: 1px solid var(--line);
}
.size-table th { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
.size-table tr:last-child td { border-bottom: 0; }
/* ---------- Toasts ---------- */
.toast-region {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 10px;
z-index: 90;
width: min(360px, calc(100vw - 32px));
}
.toast {
display: flex;
align-items: center;
gap: 10px;
background: var(--ink);
color: #fff;
padding: 12px 16px;
border-radius: 12px;
font-size: 14px;
font-weight: 600;
box-shadow: 0 14px 34px -12px rgba(0, 0, 0, .5);
animation: toast-in .28s cubic-bezier(.2, .8, .2, 1);
}
.toast.is-out { animation: toast-out .25s ease forwards; }
.toast .toast-mark {
display: grid;
place-items: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--ok);
flex: none;
}
@keyframes toast-in { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
@keyframes toast-out { to { opacity: 0; transform: translateY(8px); } }
/* ---------- Responsive ---------- */
@media (max-width: 760px) {
.card { grid-template-columns: 1fr; }
}
@media (max-width: 420px) {
.shell { padding: 0 12px; }
.card { padding: 16px; border-radius: 14px; }
.summary { flex-direction: column; align-items: flex-start; gap: 4px; }
.summary-value { text-align: left; }
.swatch { width: 38px; height: 38px; }
.swatch .dot-fill { width: 26px; height: 26px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .001ms !important; transition-duration: .001ms !important; }
}(function () {
"use strict";
/* ---------------- Data model (fictional) ---------------- */
// Colors carry a display hex/gradient, a "tile" gradient for the media, and a base price.
const COLORS = [
{ id: "black", name: "Black", fill: "#1d2026", tileA: "#1b1d24", tileB: "#3a3f4d", shoe: "#f4f5f7", base: 129, available: true },
{ id: "sand", name: "Sand", fill: "#d8c3a0", tileA: "#cdb892", tileB: "#e6d8bd", shoe: "#5b4d33", base: 129, available: true },
{ id: "moss", name: "Moss", fill: "#5a6b4a", tileA: "#41513a", tileB: "#6f8262", shoe: "#eef2e8", base: 134, available: true },
{ id: "coral", name: "Coral", fill: "#e8745c", tileA: "#d65b44", tileB: "#f2937c", shoe: "#fff3ef", base: 134, available: true },
{ id: "indigo", name: "Indigo", fill: "#3457ff", tileA: "#2a3fb8", tileB: "#5a73ff", shoe: "#eef1ff", base: 139, available: false }, // sold out color
];
const MATERIALS = [
{ id: "knit", name: "Recycled knit", add: 0 },
{ id: "mesh", name: "Breathable mesh", add: 6 },
{ id: "suede", name: "Vegan suede", add: 14 },
];
// Master size list (US). Per-variant availability is computed below.
const SIZES = ["6", "7", "8", "9", "10", "11", "12"];
const SIZE_GUIDE = [
{ us: "6", eu: "38", cm: "24.1" },
{ us: "7", eu: "39.5", cm: "25.0" },
{ us: "8", eu: "41", cm: "25.7" },
{ us: "9", eu: "42.5", cm: "26.5" },
{ us: "10", eu: "44", cm: "27.3" },
{ us: "11", eu: "45", cm: "28.1" },
{ us: "12", eu: "46.5", cm: "29.0" },
];
// Inventory keyed by `${colorId}|${materialId}|${size}` -> stock count.
// Build deterministic, realistic-looking stock with some out-of-stock combos.
const INVENTORY = (function buildInventory() {
const inv = {};
let seed = 7;
const rnd = () => {
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
return seed / 0x7fffffff;
};
COLORS.forEach((c) => {
MATERIALS.forEach((m) => {
SIZES.forEach((s) => {
let stock;
if (!c.available) {
stock = 0; // whole color sold out
} else {
const r = rnd();
if (r < 0.16) stock = 0; // out of stock
else if (r < 0.32) stock = 1 + Math.floor(rnd() * 3); // low
else stock = 5 + Math.floor(rnd() * 30);
}
inv[`${c.id}|${m.id}|${s}`] = stock;
});
});
});
// Guarantee a couple of clearly-shown disabled combos on the default view.
inv["black|knit|6"] = 0;
inv["black|knit|12"] = 0;
return inv;
})();
/* ---------------- State ---------------- */
const state = {
color: "black",
material: "knit",
size: null,
wished: false,
};
/* ---------------- DOM refs ---------------- */
const $ = (id) => document.getElementById(id);
const swatchesEl = $("swatches");
const materialsEl = $("materials");
const sizesEl = $("sizes");
const colorValueEl = $("colorValue");
const materialValueEl = $("materialValue");
const summaryValueEl = $("summaryValue");
const stockLineEl = $("stockLine");
const stockTextEl = $("stockText");
const sizeHintEl = $("sizeHint");
const errorEl = $("errorMsg");
const priceEl = $("price");
const compareEl = $("compare");
const saveChipEl = $("saveChip");
const addBtn = $("addBtn");
const addLabel = $("addLabel");
const wishBtn = $("wishBtn");
const mediaTile = $("mediaTile");
const mediaBadge = $("mediaBadge");
const shoeBody = document.querySelector(".shoe-body");
const form = $("variantForm");
const money = (n) =>
n.toLocaleString("en-US", { style: "currency", currency: "USD", minimumFractionDigits: 2 });
const getColor = (id) => COLORS.find((c) => c.id === id);
const getMaterial = (id) => MATERIALS.find((m) => m.id === id);
const stockFor = (color, material, size) => INVENTORY[`${color}|${material}|${size}`] ?? 0;
// Current unit price = color base + material add-on.
function currentPrice() {
const c = getColor(state.color);
const m = getMaterial(state.material);
return c.base + m.add;
}
// Compare-at is a fixed % above price (illustrative discount).
function comparePrice(price) {
return Math.round(price * 1.24);
}
/* ---------------- Renderers ---------------- */
function renderSwatches() {
swatchesEl.innerHTML = "";
COLORS.forEach((c, i) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "swatch";
btn.setAttribute("role", "radio");
btn.dataset.color = c.id;
const checked = c.id === state.color;
btn.setAttribute("aria-checked", String(checked));
btn.setAttribute("aria-label", c.available ? c.name : `${c.name} (sold out)`);
// Roving tabindex
btn.tabIndex = checked ? 0 : -1;
if (!c.available) btn.setAttribute("aria-disabled", "true");
const fill = document.createElement("span");
fill.className = "dot-fill";
fill.style.background = c.fill;
btn.appendChild(fill);
btn.addEventListener("click", () => {
if (!c.available) {
toast(`${c.name} is sold out`, false);
return;
}
selectColor(c.id);
});
swatchesEl.appendChild(btn);
});
}
function renderMaterials() {
materialsEl.innerHTML = "";
MATERIALS.forEach((m) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "chip";
btn.setAttribute("role", "radio");
btn.dataset.material = m.id;
const checked = m.id === state.material;
btn.setAttribute("aria-checked", String(checked));
btn.tabIndex = checked ? 0 : -1;
btn.textContent = m.add ? `${m.name} +${money(m.add).replace(".00", "")}` : m.name;
btn.addEventListener("click", () => selectMaterial(m.id));
materialsEl.appendChild(btn);
});
}
function renderSizes() {
sizesEl.innerHTML = "";
let firstEnabled = null;
SIZES.forEach((s) => {
const stock = stockFor(state.color, state.material, s);
const disabled = stock <= 0;
const btn = document.createElement("button");
btn.type = "button";
btn.className = "chip";
btn.setAttribute("role", "radio");
btn.dataset.size = s;
btn.textContent = s;
const checked = s === state.size;
btn.setAttribute("aria-checked", String(checked));
if (disabled) {
btn.setAttribute("aria-disabled", "true");
btn.setAttribute("aria-label", `Size ${s} — unavailable in this combination`);
} else if (firstEnabled === null) {
firstEnabled = s;
}
btn.tabIndex = checked ? 0 : -1;
btn.addEventListener("click", () => {
if (disabled) {
toast(`Size ${s} isn't available in ${getColor(state.color).name} · ${getMaterial(state.material).name}`, false);
return;
}
selectSize(s);
});
sizesEl.appendChild(btn);
});
// Ensure at least one enabled size is tabbable when none selected.
if (!state.size && firstEnabled) {
const el = sizesEl.querySelector(`[data-size="${firstEnabled}"]`);
if (el) el.tabIndex = 0;
}
}
function updateMedia() {
const c = getColor(state.color);
mediaTile.style.background = `linear-gradient(150deg, ${c.tileA}, ${c.tileB})`;
mediaTile.dataset.color = c.id;
mediaBadge.textContent = c.name;
if (shoeBody) shoeBody.style.fill = c.shoe;
}
function updatePriceAndSummary() {
const price = currentPrice();
const compare = comparePrice(price);
priceEl.textContent = money(price);
compareEl.textContent = money(compare);
saveChipEl.textContent = `Save ${money(compare - price).replace(".00", "")}`;
const c = getColor(state.color);
const m = getMaterial(state.material);
summaryValueEl.textContent = `${c.name} · ${m.name} · ${state.size ? `US ${state.size}` : "choose a size"}`;
colorValueEl.textContent = c.name;
materialValueEl.textContent = m.name;
addLabel.textContent = `Add to cart · ${money(price)}`;
}
function updateStock() {
// If a size is chosen, show that exact combo's stock; otherwise show whether
// ANY size is available in the current color/material.
if (state.size) {
const stock = stockFor(state.color, state.material, state.size);
if (stock <= 0) {
setStock("out", "Out of stock for this size", true);
} else if (stock <= 4) {
setStock("low", `Only ${stock} left — order soon`, false);
} else {
setStock("in", "In stock — ready to ship", false);
}
} else {
const anyAvailable = SIZES.some((s) => stockFor(state.color, state.material, s) > 0);
if (anyAvailable) {
setStock("pending", "Select a size to check availability", false);
} else {
setStock("out", "Sold out in this combination", true);
}
}
}
function setStock(stateName, text, disableAdd) {
stockLineEl.dataset.state = stateName === "in" ? "" : stateName;
stockTextEl.textContent = text;
// Add button is disabled when the exact chosen combo is out of stock.
addBtn.disabled = !!disableAdd;
}
/* ---------------- Selection handlers ---------------- */
function selectColor(id) {
state.color = id;
// If current size is no longer available for new color/material, clear it.
if (state.size && stockFor(state.color, state.material, state.size) <= 0) {
state.size = null;
flashSizeHint("Heads up: your size sold out in this color — pick another.");
} else {
clearSizeHint();
}
clearError();
renderSwatches();
renderSizes();
updateMedia();
updatePriceAndSummary();
updateStock();
}
function selectMaterial(id) {
state.material = id;
if (state.size && stockFor(state.color, state.material, state.size) <= 0) {
state.size = null;
flashSizeHint("Heads up: your size sold out in this material — pick another.");
} else {
clearSizeHint();
}
clearError();
renderMaterials();
renderSizes();
updatePriceAndSummary();
updateStock();
}
function selectSize(s) {
state.size = s;
clearError();
clearSizeHint();
renderSizes();
updatePriceAndSummary();
updateStock();
}
function flashSizeHint(msg) {
sizeHintEl.textContent = msg;
}
function clearSizeHint() {
sizeHintEl.textContent = "";
}
function clearError() {
errorEl.hidden = true;
errorEl.textContent = "";
}
/* ---------------- Keyboard roving for radiogroups ---------------- */
function wireRoving(container, selector, onPick) {
container.addEventListener("keydown", (e) => {
const items = Array.from(container.querySelectorAll(selector));
const enabled = items.filter((el) => el.getAttribute("aria-disabled") !== "true");
if (!enabled.length) return;
const current = document.activeElement;
let idx = enabled.indexOf(current);
if (idx === -1) idx = 0;
let handled = true;
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
idx = (idx + 1) % enabled.length;
break;
case "ArrowLeft":
case "ArrowUp":
idx = (idx - 1 + enabled.length) % enabled.length;
break;
case "Home":
idx = 0;
break;
case "End":
idx = enabled.length - 1;
break;
case " ":
case "Enter":
current && current.click();
handled = true;
break;
default:
handled = false;
}
if (!handled) return;
e.preventDefault();
if (e.key === " " || e.key === "Enter") return;
const next = enabled[idx];
if (next) {
next.tabIndex = 0;
next.focus();
}
});
}
/* ---------------- Toast ---------------- */
let toastTimer;
function toast(msg, success = true) {
const region = $("toastRegion");
const el = document.createElement("div");
el.className = "toast";
if (success) {
const mark = document.createElement("span");
mark.className = "toast-mark";
mark.innerHTML =
'<svg width="13" height="13" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 13l4 4L19 7" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>';
el.appendChild(mark);
}
const txt = document.createElement("span");
txt.textContent = msg;
el.appendChild(txt);
region.appendChild(el);
setTimeout(() => {
el.classList.add("is-out");
setTimeout(() => el.remove(), 260);
}, 2600);
}
/* ---------------- Wishlist ---------------- */
wishBtn.addEventListener("click", () => {
state.wished = !state.wished;
wishBtn.setAttribute("aria-pressed", String(state.wished));
toast(state.wished ? "Saved to wishlist" : "Removed from wishlist", state.wished);
});
/* ---------------- Validation + add to cart ---------------- */
form.addEventListener("submit", (e) => {
e.preventDefault();
if (!state.size) {
errorEl.hidden = false;
errorEl.textContent = "Please choose a size before adding to cart.";
// Move focus to first enabled size for quick correction.
const firstSize = Array.from(sizesEl.querySelectorAll(".chip")).find(
(el) => el.getAttribute("aria-disabled") !== "true"
);
if (firstSize) firstSize.focus();
return;
}
const stock = stockFor(state.color, state.material, state.size);
if (stock <= 0) {
errorEl.hidden = false;
errorEl.textContent = "That combination just sold out — pick a different size.";
return;
}
clearError();
const c = getColor(state.color);
const m = getMaterial(state.material);
toast(`Added · ${c.name} / ${m.name} / US ${state.size} — ${money(currentPrice())}`, true);
});
/* ---------------- Size guide dialog ---------------- */
const dialog = $("sizeDialog");
const backdrop = $("dialogBackdrop");
const sizeGuideBtn = $("sizeGuideBtn");
const dialogClose = $("dialogClose");
let lastFocused = null;
function buildSizeTable() {
const body = $("sizeTableBody");
body.innerHTML = "";
SIZE_GUIDE.forEach((row) => {
const tr = document.createElement("tr");
tr.innerHTML = `<td>${row.us}</td><td>${row.eu}</td><td>${row.cm}</td>`;
body.appendChild(tr);
});
}
function openDialog() {
lastFocused = document.activeElement;
backdrop.hidden = false;
if (typeof dialog.showModal === "function") {
dialog.showModal();
} else {
dialog.setAttribute("open", "");
}
dialogClose.focus();
}
function closeDialog() {
backdrop.hidden = true;
if (typeof dialog.close === "function" && dialog.open) {
dialog.close();
} else {
dialog.removeAttribute("open");
}
if (lastFocused) lastFocused.focus();
}
sizeGuideBtn.addEventListener("click", openDialog);
dialogClose.addEventListener("click", closeDialog);
backdrop.addEventListener("click", closeDialog);
dialog.addEventListener("cancel", (e) => {
e.preventDefault();
closeDialog();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && (dialog.open || dialog.hasAttribute("open"))) closeDialog();
});
/* ---------------- Init ---------------- */
renderSwatches();
renderMaterials();
renderSizes();
updateMedia();
updatePriceAndSummary();
updateStock();
buildSizeTable();
wireRoving(swatchesEl, ".swatch", selectColor);
wireRoving(materialsEl, ".chip", selectMaterial);
wireRoving(sizesEl, ".chip", selectSize);
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Variant Selector — Trailhead Knit Runner</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<a class="skip-link" href="#picker">Skip to variant picker</a>
<main class="shell" id="main">
<article class="card" aria-labelledby="prod-title">
<!-- Media / product photography (CSS gradient + inline SVG silhouette) -->
<section class="media" aria-label="Product preview">
<div class="media-tile" id="mediaTile" data-color="black">
<span class="media-badge" id="mediaBadge">Black</span>
<svg class="shoe" viewBox="0 0 320 200" role="img" aria-label="Knit runner sneaker silhouette" focusable="false">
<defs>
<linearGradient id="sole" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="rgba(255,255,255,.9)"/>
<stop offset="1" stop-color="rgba(255,255,255,.55)"/>
</linearGradient>
</defs>
<path class="shoe-body" d="M28 120 C44 92 86 78 120 80 C150 82 168 96 196 96 C232 96 256 84 286 96 C302 102 306 118 300 132 C292 150 264 152 232 152 L60 152 C40 152 22 142 28 120 Z"/>
<path class="shoe-sole" d="M24 150 C24 150 96 168 160 168 C224 168 300 150 300 150 L302 162 C302 170 296 176 286 176 L40 176 C30 176 22 170 22 162 Z" fill="url(#sole)"/>
<path class="shoe-lace" d="M120 92 L150 100 M132 84 L160 94 M146 80 L174 92" stroke-width="4" stroke-linecap="round" fill="none"/>
<circle class="shoe-eye" cx="118" cy="98" r="4"/>
</svg>
</div>
<div class="thumbs" role="list" aria-label="Selected color">
<div class="thumb" role="listitem" aria-hidden="true">Knit upper</div>
<div class="thumb" role="listitem" aria-hidden="true">Side profile</div>
<div class="thumb" role="listitem" aria-hidden="true">Outsole</div>
</div>
</section>
<!-- Buy box / variant picker -->
<section class="buy" id="picker" aria-label="Buy box">
<p class="eyebrow">Trailhead · Everyday runner</p>
<h1 class="title" id="prod-title">Knit Runner — Variant Selector</h1>
<div class="rating" aria-label="Rated 4.7 out of 5 from 1,284 reviews">
<span class="stars" aria-hidden="true">★★★★<span class="half">★</span></span>
<strong>4.7</strong>
<span class="muted">· 1,284 reviews</span>
</div>
<div class="price-row">
<span class="price" id="price">$129.00</span>
<span class="compare" id="compare">$160.00</span>
<span class="save-chip" id="saveChip">Save $31</span>
</div>
<form id="variantForm" novalidate>
<!-- COLOR -->
<fieldset class="group" id="colorGroup">
<legend class="group-head">
<span class="group-label">Color</span>
<span class="group-value" id="colorValue">Black</span>
</legend>
<div class="swatches" role="radiogroup" aria-label="Color" id="swatches"></div>
</fieldset>
<!-- MATERIAL / STYLE -->
<fieldset class="group" id="materialGroup">
<legend class="group-head">
<span class="group-label">Material</span>
<span class="group-value" id="materialValue">Recycled knit</span>
</legend>
<div class="chips" role="radiogroup" aria-label="Material" id="materials"></div>
</fieldset>
<!-- SIZE -->
<fieldset class="group" id="sizeGroup">
<legend class="group-head">
<span class="group-label">Size <span class="muted">(US)</span></span>
<button type="button" class="size-guide" id="sizeGuideBtn" aria-haspopup="dialog">
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 6h18M3 12h18M3 18h18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
Size guide
</button>
</legend>
<div class="chips sizes" role="radiogroup" aria-label="Size" id="sizes"></div>
<p class="hint" id="sizeHint" role="status" aria-live="polite"></p>
</fieldset>
<!-- LIVE SUMMARY -->
<div class="summary" id="summary" aria-live="polite">
<span class="summary-label">Selected</span>
<span class="summary-value" id="summaryValue">Black · Recycled knit · choose a size</span>
</div>
<div class="stock-line" id="stockLine" aria-live="polite">
<span class="dot" aria-hidden="true"></span>
<span id="stockText">In stock — ready to ship</span>
</div>
<div class="error" id="errorMsg" role="alert" hidden></div>
<div class="cta-row">
<button type="submit" class="btn-add" id="addBtn">
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6h15l-1.5 9h-12L6 6Zm0 0L5 3H2m5 18a1 1 0 100-2 1 1 0 000 2Zm11 0a1 1 0 100-2 1 1 0 000 2Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span id="addLabel">Add to cart · $129.00</span>
</button>
<button type="button" class="btn-wish" id="wishBtn" aria-pressed="false" aria-label="Save to wishlist">
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 21s-7.5-4.6-10-9.3C.5 8 2.4 4.5 6 4.5c2.1 0 3.3 1.1 4 2.2.7-1.1 1.9-2.2 4-2.2 3.6 0 5.5 3.5 4 7.2C19.5 16.4 12 21 12 21Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>
</button>
</div>
<ul class="trust">
<li><svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2 4 5v6c0 5 3.4 8.7 8 11 4.6-2.3 8-6 8-11V5l-8-3Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>Secure checkout</li>
<li><svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 7h13v8H3zM16 10h3l2 2v3h-5z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>Free shipping over $75</li>
<li><svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12a9 9 0 1018 0 9 9 0 00-18 0Zm9-4v4l3 2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>30-day returns</li>
</ul>
</form>
</section>
</article>
</main>
<!-- Size guide dialog -->
<div class="dialog-backdrop" id="dialogBackdrop" hidden></div>
<dialog class="size-dialog" id="sizeDialog" aria-labelledby="sizeDialogTitle">
<div class="dialog-head">
<h2 id="sizeDialogTitle">Size guide</h2>
<button type="button" class="dialog-close" id="dialogClose" aria-label="Close size guide">×</button>
</div>
<p class="muted">Measure your foot from heel to toe. If you're between sizes, size up for the knit upper.</p>
<table class="size-table">
<thead><tr><th>US</th><th>EU</th><th>Heel–toe (cm)</th></tr></thead>
<tbody id="sizeTableBody"></tbody>
</table>
</dialog>
<!-- Toast region -->
<div class="toast-region" id="toastRegion" aria-live="polite" aria-atomic="true"></div>
<script src="script.js"></script>
</body>
</html>Variant Selector
A self-contained variant-picker primitive for a fictional knit-runner sneaker. The buy box pairs a gradient “product photography” tile and inline-SVG silhouette with three real radio groups: named color swatches, a material picker, and US size chips. Sold-out colors render crossed-out and disabled, and the size chips recompute their availability from a per-combination inventory map, so impossible color/material/size combinations are dimmed, dashed, and unselectable.
Every selection drives state. Choosing a color repaints the media tile and shoe upper, picking a material adds its surcharge to the price, and any change re-filters the sizes — clearing a now-unavailable size with a friendly hint. A live “Selected: Black / Recycled knit / US 9” summary, the per-variant price with compare-at savings, and a color-coded stock chip (in stock / only N left / out of stock) all update together. Submitting validates that a size is chosen and still in stock before firing the add-to-cart toast, and a size-guide dialog opens with US/EU/heel-to-toe measurements.
The radio groups use roving tabindex with arrow, Home, and End keys, expose aria-checked and aria-disabled state, and keep crisp focus-visible rings. The layout collapses to a single column on tablets, reflows at phone widths, and honors reduced-motion preferences.
Illustrative storefront UI only — fictional products, prices, and reviews. No real checkout.