LMS — Course Catalog
A friendly, focused e-learning course catalog with a sticky filter rail for level, category, duration and price, plus an instant search that narrows the grid as you type. Sortable course cards show thumbnails, instructors, ratings, level pills and live progress bars for enrolled classes. Active filters appear as removable chips, a sort dropdown reorders results, and enrolling fires a calm confirmation toast. Built with semantic, accessible, responsive vanilla HTML, CSS and JavaScript.
MCP
Code
:root {
--brand: #5b5bd6;
--brand-d: #4444c2;
--brand-50: #eeeefc;
--accent: #13b981;
--amber: #f59e0b;
--ink: #1a1a2e;
--ink-2: #44465f;
--muted: #6b6e87;
--bg: #f7f7fb;
--surface: #ffffff;
--line: rgba(26, 26, 46, 0.1);
--ok: #13b981;
--danger: #e05656;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--shadow-sm: 0 1px 2px rgba(26, 26, 46, 0.06), 0 1px 3px rgba(26, 26, 46, 0.04);
--shadow-md: 0 6px 20px rgba(26, 26, 46, 0.08);
--shadow-lg: 0 14px 40px rgba(26, 26, 46, 0.12);
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
}
body {
font-family: "Inter", system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, h3 { margin: 0; line-height: 1.2; }
button { font-family: inherit; cursor: pointer; }
.skip-link {
position: absolute;
left: -999px;
top: 8px;
background: var(--ink);
color: #fff;
padding: 8px 14px;
border-radius: var(--r-sm);
z-index: 50;
}
.skip-link:focus { left: 12px; }
:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
}
/* ---------- Top bar ---------- */
.topbar {
position: sticky;
top: 0;
z-index: 20;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: saturate(1.2) blur(8px);
border-bottom: 1px solid var(--line);
}
.topbar__inner {
max-width: 1240px;
margin: 0 auto;
padding: 12px 22px;
display: flex;
align-items: center;
gap: 18px;
}
.brand {
display: flex;
align-items: center;
gap: 9px;
text-decoration: none;
color: var(--ink);
font-weight: 800;
flex-shrink: 0;
}
.brand__mark {
width: 34px;
height: 34px;
display: grid;
place-items: center;
font-size: 20px;
color: #fff;
background: linear-gradient(135deg, var(--brand), var(--brand-d));
border-radius: 10px;
box-shadow: var(--shadow-sm);
}
.brand__name { font-size: 1.06rem; letter-spacing: -0.02em; }
.brand__name span { color: var(--brand); }
.search {
flex: 1;
max-width: 460px;
position: relative;
display: flex;
align-items: center;
}
.search__icon {
position: absolute;
left: 13px;
width: 18px;
height: 18px;
color: var(--muted);
pointer-events: none;
}
.search input {
width: 100%;
border: 1px solid var(--line);
background: var(--surface);
border-radius: 999px;
padding: 11px 16px 11px 40px;
font-size: 0.92rem;
color: var(--ink);
transition: border-color 0.15s, box-shadow 0.15s;
}
.search input::placeholder { color: var(--muted); }
.search input:focus {
outline: none;
border-color: var(--brand);
box-shadow: 0 0 0 3px var(--brand-50);
}
.topbar__right {
display: flex;
align-items: center;
gap: 14px;
margin-left: auto;
flex-shrink: 0;
}
.streak {
font-size: 0.85rem;
color: var(--ink-2);
background: var(--brand-50);
padding: 6px 11px;
border-radius: 999px;
}
.streak strong { color: var(--brand-d); }
.avatar {
width: 36px;
height: 36px;
display: grid;
place-items: center;
border-radius: 50%;
background: linear-gradient(135deg, var(--amber), #f97316);
color: #fff;
font-weight: 700;
font-size: 0.78rem;
}
/* ---------- Layout ---------- */
.layout {
max-width: 1240px;
margin: 0 auto;
padding: 26px 22px 60px;
display: grid;
grid-template-columns: 248px 1fr;
gap: 26px;
align-items: start;
}
/* ---------- Filter rail ---------- */
.rail {
position: sticky;
top: 86px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 18px;
box-shadow: var(--shadow-sm);
}
.rail__head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 4px;
}
.rail__head h2 { font-size: 1rem; }
.link-btn {
background: none;
border: none;
color: var(--brand);
font-weight: 600;
font-size: 0.82rem;
padding: 0;
}
.link-btn:hover { color: var(--brand-d); text-decoration: underline; }
.fgroup {
border: none;
padding: 0;
margin: 16px 0 0;
border-top: 1px solid var(--line);
padding-top: 16px;
}
.fgroup legend {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--muted);
font-weight: 700;
padding: 0;
margin-bottom: 10px;
}
.pillset { display: flex; flex-wrap: wrap; gap: 7px; }
.pill {
border: 1px solid var(--line);
background: var(--surface);
color: var(--ink-2);
border-radius: 999px;
padding: 6px 13px;
font-size: 0.82rem;
font-weight: 600;
transition: all 0.15s;
}
.pill:hover { border-color: var(--brand); color: var(--brand); }
.pill.is-on {
background: var(--brand);
border-color: var(--brand);
color: #fff;
}
.checks { display: flex; flex-direction: column; gap: 9px; }
.checks label,
.radio {
display: flex;
align-items: center;
gap: 9px;
font-size: 0.88rem;
color: var(--ink-2);
cursor: pointer;
}
.checks .count {
margin-left: auto;
font-size: 0.74rem;
color: var(--muted);
background: var(--bg);
border-radius: 999px;
padding: 1px 8px;
}
input[type="checkbox"],
input[type="radio"] {
accent-color: var(--brand);
width: 16px;
height: 16px;
cursor: pointer;
}
.radioset { display: flex; flex-direction: column; gap: 9px; }
.enrolled-toggle {
display: flex;
align-items: center;
gap: 9px;
margin-top: 18px;
padding-top: 16px;
border-top: 1px solid var(--line);
font-size: 0.88rem;
font-weight: 600;
color: var(--ink-2);
cursor: pointer;
}
/* ---------- Catalog head ---------- */
.catalog__head {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.catalog__head h1 {
font-size: 1.6rem;
letter-spacing: -0.02em;
}
.catalog__sub {
margin: 4px 0 0;
color: var(--muted);
font-size: 0.9rem;
}
.sort {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
color: var(--muted);
}
.sort select {
font-family: inherit;
border: 1px solid var(--line);
background: var(--surface);
border-radius: var(--r-sm);
padding: 8px 12px;
font-size: 0.86rem;
font-weight: 600;
color: var(--ink);
}
.sort select:focus { outline: none; border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-50); }
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 14px 0 18px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--brand-50);
color: var(--brand-d);
border: none;
border-radius: 999px;
padding: 5px 10px 5px 12px;
font-size: 0.8rem;
font-weight: 600;
}
.chip button {
background: none;
border: none;
color: var(--brand-d);
font-size: 1rem;
line-height: 1;
padding: 0;
opacity: 0.7;
}
.chip button:hover { opacity: 1; }
/* ---------- Grid ---------- */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(258px, 1fr));
gap: 18px;
outline: none;
}
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: var(--shadow-sm);
transition: transform 0.16s, box-shadow 0.16s, border-color 0.16s;
}
.card:hover {
transform: translateY(-3px);
box-shadow: var(--shadow-lg);
border-color: rgba(91, 91, 214, 0.35);
}
.thumb {
height: 124px;
position: relative;
display: flex;
align-items: flex-end;
padding: 10px;
}
.thumb__emoji {
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-size: 2.6rem;
opacity: 0.92;
}
.level-pill {
position: relative;
z-index: 1;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
padding: 4px 9px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
color: var(--ink);
}
.level-pill[data-level="Beginner"] { color: #0f8a63; }
.level-pill[data-level="Intermediate"] { color: var(--brand-d); }
.level-pill[data-level="Advanced"] { color: #b4541f; }
.card__body {
padding: 13px 14px 15px;
display: flex;
flex-direction: column;
gap: 7px;
flex: 1;
}
.card__cat {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
}
.card__title {
font-size: 1rem;
font-weight: 700;
letter-spacing: -0.01em;
line-height: 1.3;
}
.card__instr {
font-size: 0.83rem;
color: var(--ink-2);
display: flex;
align-items: center;
gap: 6px;
}
.card__instr .dot {
width: 18px;
height: 18px;
border-radius: 50%;
background: linear-gradient(135deg, var(--brand), var(--accent));
color: #fff;
font-size: 0.6rem;
font-weight: 700;
display: grid;
place-items: center;
}
.card__meta {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.8rem;
color: var(--muted);
margin-top: 2px;
}
.rating { display: inline-flex; align-items: center; gap: 4px; color: var(--ink-2); font-weight: 600; }
.rating .star { color: var(--amber); }
.card__meta .sep { opacity: 0.5; }
.progress {
margin-top: 4px;
}
.progress__label {
display: flex;
justify-content: space-between;
font-size: 0.74rem;
color: var(--accent);
font-weight: 700;
margin-bottom: 4px;
}
.bar {
height: 6px;
border-radius: 999px;
background: var(--brand-50);
overflow: hidden;
}
.bar > i {
display: block;
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, var(--accent), #34d39e);
}
.card__foot {
margin-top: auto;
padding-top: 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.price { font-weight: 800; font-size: 0.98rem; color: var(--ink); }
.price.free { color: var(--accent); }
.btn {
border: 1px solid transparent;
border-radius: var(--r-sm);
padding: 8px 14px;
font-size: 0.83rem;
font-weight: 700;
transition: all 0.15s;
}
.btn--primary { background: var(--brand); color: #fff; }
.btn--primary:hover { background: var(--brand-d); }
.btn--primary:active { transform: scale(0.97); }
.btn--enrolled {
background: var(--brand-50);
color: var(--brand-d);
cursor: default;
}
.btn--ghost {
background: var(--surface);
border-color: var(--line);
color: var(--ink-2);
}
.btn--ghost:hover { border-color: var(--brand); color: var(--brand); }
/* ---------- Empty ---------- */
.empty {
text-align: center;
padding: 60px 20px;
color: var(--muted);
}
.empty__art { font-size: 3rem; margin-bottom: 8px; }
.empty h3 { color: var(--ink); margin-bottom: 6px; }
.empty p { margin: 0 0 18px; }
/* ---------- Toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 24px);
background: var(--ink);
color: #fff;
padding: 12px 18px;
border-radius: var(--r-md);
font-size: 0.88rem;
font-weight: 600;
box-shadow: var(--shadow-lg);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s, transform 0.25s;
z-index: 60;
max-width: calc(100vw - 32px);
}
.toast.is-on {
opacity: 1;
transform: translate(-50%, 0);
}
.toast strong { color: #8d8df0; }
/* ---------- Responsive ---------- */
@media (max-width: 920px) {
.layout { grid-template-columns: 1fr; }
.rail { position: static; }
}
@media (max-width: 520px) {
.topbar__inner { flex-wrap: wrap; padding: 10px 16px; }
.search { order: 3; max-width: none; flex-basis: 100%; }
.streak { display: none; }
.layout { padding: 18px 16px 50px; gap: 18px; }
.catalog__head h1 { font-size: 1.35rem; }
.grid { grid-template-columns: 1fr; }
.sort { width: 100%; }
.sort select { flex: 1; }
}/* Lumina Learn — Course Catalog (vanilla JS) */
(function () {
"use strict";
const COURSES = [
{ id: 1, title: "Foundations of UX Research", instructor: "Priya Nandakumar", cat: "Design", level: "Beginner", hours: 4.5, rating: 4.8, students: 12840, price: 0, emoji: "🔍", grad: "linear-gradient(135deg,#eef0ff,#dfe4ff)", added: 12, progress: 35 },
{ id: 2, title: "Modern JavaScript from Scratch", instructor: "Diego Salazar", cat: "Development", level: "Beginner", hours: 9, rating: 4.9, students: 31205, price: 49, emoji: "⚡", grad: "linear-gradient(135deg,#fff5e0,#ffe9bf)", added: 30, progress: 0 },
{ id: 3, title: "Data Visualization with D3", instructor: "Hana Okafor", cat: "Data", level: "Intermediate", hours: 7.5, rating: 4.6, students: 8410, price: 59, emoji: "📊", grad: "linear-gradient(135deg,#e3fbf0,#c6f3df)", added: 5, progress: 0 },
{ id: 4, title: "Architecting Scalable APIs", instructor: "Marcus Feld", cat: "Development", level: "Advanced", hours: 14, rating: 4.7, students: 6720, price: 89, emoji: "🛠️", grad: "linear-gradient(135deg,#ffe9ec,#ffd6db)", added: 18, progress: 0 },
{ id: 5, title: "Design Systems in Figma", instructor: "Lena Brandt", cat: "Design", level: "Intermediate", hours: 6, rating: 4.9, students: 15330, price: 39, emoji: "🎨", grad: "linear-gradient(135deg,#f0eaff,#e2d7ff)", added: 2, progress: 68 },
{ id: 6, title: "Intro to Machine Learning", instructor: "Wei Zhang", cat: "Data", level: "Intermediate", hours: 16, rating: 4.5, students: 22190, price: 0, emoji: "🤖", grad: "linear-gradient(135deg,#e6f1ff,#d2e6ff)", added: 22, progress: 0 },
{ id: 7, title: "Brand Storytelling Essentials", instructor: "Amara Cole", cat: "Marketing", level: "Beginner", hours: 3.5, rating: 4.4, students: 5980, price: 29, emoji: "📣", grad: "linear-gradient(135deg,#fff0e8,#ffe0cf)", added: 9, progress: 0 },
{ id: 8, title: "Advanced CSS & Motion", instructor: "Theo Marchetti", cat: "Development", level: "Advanced", hours: 8.5, rating: 4.8, students: 11250, price: 45, emoji: "🌀", grad: "linear-gradient(135deg,#ecfbff,#d6f3ff)", added: 1, progress: 0 },
{ id: 9, title: "Product Analytics Playbook", instructor: "Sofia Adeyemi", cat: "Data", level: "Beginner", hours: 4, rating: 4.3, students: 4310, price: 0, emoji: "📈", grad: "linear-gradient(135deg,#eafbef,#d2f3dc)", added: 14, progress: 12 },
{ id: 10, title: "Cloud Infrastructure with K8s", instructor: "Raj Mehrotra", cat: "Development", level: "Advanced", hours: 18, rating: 4.6, students: 9870, price: 99, emoji: "☁️", grad: "linear-gradient(135deg,#eef0ff,#dbe0ff)", added: 26, progress: 0 },
{ id: 11, title: "Color Theory for Screens", instructor: "Lena Brandt", cat: "Design", level: "Beginner", hours: 2.5, rating: 4.7, students: 7640, price: 0, emoji: "🌈", grad: "linear-gradient(135deg,#fff0f6,#ffd9ec)", added: 7, progress: 0 },
{ id: 12, title: "Growth Marketing Tactics", instructor: "Amara Cole", cat: "Marketing", level: "Intermediate", hours: 5.5, rating: 4.5, students: 6420, price: 55, emoji: "🚀", grad: "linear-gradient(135deg,#fff6e0,#ffe7b3)", added: 11, progress: 0 }
];
// ----- state -----
const state = {
q: "",
level: "all",
categories: new Set(),
duration: "all",
price: "all",
enrolledOnly: false,
sort: "popular"
};
// track enrolled ids (seed from any with progress > 0)
const enrolled = new Set(COURSES.filter(c => c.progress > 0).map(c => c.id));
// ----- el refs -----
const $ = (s, r = document) => r.querySelector(s);
const grid = $("#grid");
const empty = $("#empty");
const resultCount = $("#resultCount");
const activeChips = $("#activeChips");
const toastEl = $("#toast");
// ----- toast helper -----
let toastTimer;
function toast(msg) {
toastEl.innerHTML = msg;
toastEl.classList.add("is-on");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove("is-on"), 2600);
}
// ----- build category checkboxes -----
const cats = [...new Set(COURSES.map(c => c.cat))].sort();
const catBox = $("#categoryChecks");
cats.forEach(cat => {
const n = COURSES.filter(c => c.cat === cat).length;
const label = document.createElement("label");
label.innerHTML =
`<input type="checkbox" value="${cat}" /> <span>${cat}</span><span class="count">${n}</span>`;
label.querySelector("input").addEventListener("change", e => {
e.target.checked ? state.categories.add(cat) : state.categories.delete(cat);
render();
});
catBox.appendChild(label);
});
// ----- helpers -----
const fmtPrice = p => (p === 0 ? "Free" : "$" + p);
const fmtStudents = n => (n >= 1000 ? (n / 1000).toFixed(1).replace(/\.0$/, "") + "k" : "" + n);
const inDuration = h =>
state.duration === "all" ||
(state.duration === "short" && h < 5) ||
(state.duration === "mid" && h >= 5 && h <= 12) ||
(state.duration === "long" && h > 12);
function filtered() {
const q = state.q.trim().toLowerCase();
let list = COURSES.filter(c => {
if (q && !(c.title.toLowerCase().includes(q) || c.instructor.toLowerCase().includes(q) || c.cat.toLowerCase().includes(q))) return false;
if (state.level !== "all" && c.level !== state.level) return false;
if (state.categories.size && !state.categories.has(c.cat)) return false;
if (!inDuration(c.hours)) return false;
if (state.price === "free" && c.price !== 0) return false;
if (state.price === "paid" && c.price === 0) return false;
if (state.enrolledOnly && !enrolled.has(c.id)) return false;
return true;
});
list.sort((a, b) => {
switch (state.sort) {
case "rating": return b.rating - a.rating;
case "newest": return a.added - b.added;
case "duration": return a.hours - b.hours;
case "price": return a.price - b.price;
default: return b.students - a.students; // popular
}
});
return list;
}
function initials(name) {
return name.split(" ").map(w => w[0]).slice(0, 2).join("").toUpperCase();
}
function cardHTML(c) {
const isEnrolled = enrolled.has(c.id);
const prog = isEnrolled && c.progress > 0
? `<div class="progress">
<div class="progress__label"><span>In progress</span><span>${c.progress}%</span></div>
<div class="bar"><i style="width:${c.progress}%"></i></div>
</div>`
: "";
const cta = isEnrolled
? `<button class="btn btn--enrolled" disabled>Enrolled ✓</button>`
: `<button class="btn btn--primary" data-enroll="${c.id}">Enroll</button>`;
return `
<article class="card" data-id="${c.id}">
<div class="thumb" style="background:${c.grad}">
<span class="thumb__emoji" aria-hidden="true">${c.emoji}</span>
<span class="level-pill" data-level="${c.level}">${c.level}</span>
</div>
<div class="card__body">
<span class="card__cat">${c.cat}</span>
<h3 class="card__title">${c.title}</h3>
<p class="card__instr"><span class="dot" aria-hidden="true">${initials(c.instructor)}</span>${c.instructor}</p>
<div class="card__meta">
<span class="rating"><span class="star">★</span>${c.rating.toFixed(1)}</span>
<span class="sep">·</span>
<span>${fmtStudents(c.students)} learners</span>
<span class="sep">·</span>
<span>${c.hours} hrs</span>
</div>
${prog}
<div class="card__foot">
<span class="price ${c.price === 0 ? "free" : ""}">${fmtPrice(c.price)}</span>
${cta}
</div>
</div>
</article>`;
}
function chipsHTML() {
const chips = [];
if (state.q.trim()) chips.push(["q", `“${state.q.trim()}”`]);
if (state.level !== "all") chips.push(["level", state.level]);
state.categories.forEach(c => chips.push(["cat:" + c, c]));
if (state.duration !== "all") {
const map = { short: "Under 5 hrs", mid: "5–12 hrs", long: "12 hrs+" };
chips.push(["duration", map[state.duration]]);
}
if (state.price !== "all") chips.push(["price", state.price === "free" ? "Free" : "Paid"]);
if (state.enrolledOnly) chips.push(["enrolled", "Enrolled only"]);
activeChips.innerHTML = chips
.map(([key, label]) => `<span class="chip">${label}<button type="button" data-chip="${key}" aria-label="Remove ${label} filter">×</button></span>`)
.join("");
}
function clearChip(key) {
if (key === "q") { state.q = ""; $("#search").value = ""; }
else if (key === "level") { state.level = "all"; syncLevelPills(); }
else if (key.startsWith("cat:")) {
const cat = key.slice(4);
state.categories.delete(cat);
const cb = [...catBox.querySelectorAll("input")].find(i => i.value === cat);
if (cb) cb.checked = false;
}
else if (key === "duration") { state.duration = "all"; checkRadio("duration", "all"); }
else if (key === "price") { state.price = "all"; checkRadio("price", "all"); }
else if (key === "enrolled") { state.enrolledOnly = false; $("#enrolledOnly").checked = false; }
render();
}
function render() {
const list = filtered();
resultCount.textContent = list.length;
chipsHTML();
if (list.length === 0) {
grid.innerHTML = "";
empty.hidden = false;
} else {
empty.hidden = true;
grid.innerHTML = list.map(cardHTML).join("");
}
}
// ----- sync UI helpers -----
function syncLevelPills() {
document.querySelectorAll('[data-filter="level"] .pill').forEach(p => {
const on = p.dataset.value === state.level;
p.classList.toggle("is-on", on);
p.setAttribute("aria-pressed", String(on));
});
}
function checkRadio(name, value) {
const r = document.querySelector(`input[name="${name}"][value="${value}"]`);
if (r) r.checked = true;
}
// ----- events -----
let searchTimer;
$("#search").addEventListener("input", e => {
clearTimeout(searchTimer);
const v = e.target.value;
searchTimer = setTimeout(() => { state.q = v; render(); }, 120);
});
document.querySelectorAll('[data-filter="level"] .pill').forEach(p => {
p.addEventListener("click", () => { state.level = p.dataset.value; syncLevelPills(); render(); });
});
document.querySelectorAll('[data-filter="duration"] input').forEach(r =>
r.addEventListener("change", e => { state.duration = e.target.value; render(); }));
document.querySelectorAll('[data-filter="price"] input').forEach(r =>
r.addEventListener("change", e => { state.price = e.target.value; render(); }));
$("#enrolledOnly").addEventListener("change", e => { state.enrolledOnly = e.target.checked; render(); });
$("#sort").addEventListener("change", e => { state.sort = e.target.value; render(); });
function resetAll() {
state.q = ""; state.level = "all"; state.categories.clear();
state.duration = "all"; state.price = "all"; state.enrolledOnly = false;
$("#search").value = "";
syncLevelPills();
checkRadio("duration", "all");
checkRadio("price", "all");
$("#enrolledOnly").checked = false;
catBox.querySelectorAll("input").forEach(i => (i.checked = false));
render();
}
$("#clearFilters").addEventListener("click", resetAll);
$("#emptyReset").addEventListener("click", resetAll);
// delegated: enroll buttons + chip removal
grid.addEventListener("click", e => {
const btn = e.target.closest("[data-enroll]");
if (!btn) return;
const id = Number(btn.dataset.enroll);
const course = COURSES.find(c => c.id === id);
enrolled.add(id);
if (course.progress === 0) course.progress = 0; // freshly enrolled, not started
toast(`Enrolled in <strong>${course.title}</strong> — happy learning!`);
render();
});
activeChips.addEventListener("click", e => {
const btn = e.target.closest("[data-chip]");
if (btn) clearChip(btn.dataset.chip);
});
// ----- init -----
render();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Lumina Learn — Course Catalog</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="#grid">Skip to courses</a>
<header class="topbar">
<div class="topbar__inner">
<a class="brand" href="#" aria-label="Lumina Learn home">
<span class="brand__mark" aria-hidden="true">◐</span>
<span class="brand__name">Lumina<span>Learn</span></span>
</a>
<div class="search" role="search">
<svg class="search__icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M21 21l-4.3-4.3M11 18a7 7 0 1 1 0-14 7 7 0 0 1 0 14Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<input id="search" type="search" placeholder="Search courses, instructors…" aria-label="Search courses" autocomplete="off" />
</div>
<div class="topbar__right">
<span class="streak" title="7-day study streak">🔥 <strong>7</strong></span>
<span class="avatar" aria-hidden="true">AR</span>
</div>
</div>
</header>
<main class="layout">
<!-- Filter rail -->
<aside class="rail" aria-label="Filters">
<div class="rail__head">
<h2>Filters</h2>
<button id="clearFilters" class="link-btn" type="button">Clear all</button>
</div>
<fieldset class="fgroup">
<legend>Level</legend>
<div class="pillset" data-filter="level">
<button class="pill is-on" data-value="all" type="button" aria-pressed="true">All</button>
<button class="pill" data-value="Beginner" type="button" aria-pressed="false">Beginner</button>
<button class="pill" data-value="Intermediate" type="button" aria-pressed="false">Intermediate</button>
<button class="pill" data-value="Advanced" type="button" aria-pressed="false">Advanced</button>
</div>
</fieldset>
<fieldset class="fgroup">
<legend>Category</legend>
<div class="checks" id="categoryChecks"></div>
</fieldset>
<fieldset class="fgroup">
<legend>Duration</legend>
<div class="radioset" data-filter="duration">
<label class="radio"><input type="radio" name="duration" value="all" checked /> Any length</label>
<label class="radio"><input type="radio" name="duration" value="short" /> Under 5 hrs</label>
<label class="radio"><input type="radio" name="duration" value="mid" /> 5 – 12 hrs</label>
<label class="radio"><input type="radio" name="duration" value="long" /> 12 hrs +</label>
</div>
</fieldset>
<fieldset class="fgroup">
<legend>Price</legend>
<div class="radioset" data-filter="price">
<label class="radio"><input type="radio" name="price" value="all" checked /> Any price</label>
<label class="radio"><input type="radio" name="price" value="free" /> Free</label>
<label class="radio"><input type="radio" name="price" value="paid" /> Paid</label>
</div>
</fieldset>
<label class="enrolled-toggle">
<input type="checkbox" id="enrolledOnly" />
<span>My enrolled courses only</span>
</label>
</aside>
<!-- Catalog -->
<section class="catalog" aria-label="Course catalog">
<div class="catalog__head">
<div>
<h1>Course Catalog</h1>
<p class="catalog__sub"><span id="resultCount">0</span> courses · keep your streak alive 🔥</p>
</div>
<div class="sort">
<label for="sort">Sort</label>
<select id="sort">
<option value="popular">Most popular</option>
<option value="rating">Highest rated</option>
<option value="newest">Newest</option>
<option value="duration">Shortest first</option>
<option value="price">Price: low to high</option>
</select>
</div>
</div>
<div class="chips" id="activeChips" aria-live="polite"></div>
<div id="grid" class="grid" tabindex="-1"></div>
<div id="empty" class="empty" hidden>
<div class="empty__art" aria-hidden="true">🗂️</div>
<h3>No courses match those filters</h3>
<p>Try widening your level or price range.</p>
<button class="btn btn--ghost" id="emptyReset" type="button">Reset filters</button>
</div>
</section>
</main>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Course Catalog
A calm, light-themed course catalog for an online learning platform. A sticky left rail combines level pills, category checkboxes with counts, duration and price radios, and an “enrolled only” toggle, while a rounded search field in the top bar filters by course title, instructor or category as you type. The right column shows a responsive grid of course cards, each with a tinted thumbnail, level pill, rating, learner count, duration and price.
Every control updates the grid live. Active filters render as removable chips above the results, the sort dropdown reorders cards by popularity, rating, recency, length or price, and the result count stays in sync. Enrolled courses surface a green progress bar and an “Enrolled ✓” state; clicking Enroll on any other card marks it enrolled and fires a friendly confirmation toast.
The whole screen is built from semantic landmarks with aria-pressed pills,
labelled radios and a live region for the toast. It uses WCAG-AA contrast,
keyboard-focusable controls, and a layout that collapses the rail above the grid
and drops to a single column down to ~360px — no frameworks, just vanilla HTML,
CSS and JavaScript.
Illustrative UI only — fictional courses, not a real learning platform.