UI Components Medium
Allergy & Diet Filter
Multi-select allergen filter with avoid/prefer chips, live count of safe dishes, and an inline result list that explains why each dish was hidden.
Open in Lab
MCP
html css vanilla-js
Targets: JS HTML
Code
:root {
--cream: #f5f0e8;
--cream-2: #ece4d4;
--bone: #faf7f1;
--terracotta: #c1714a;
--terracotta-d: #a05a38;
--forest: #2d4a3e;
--forest-d: #1e3329;
--gold: #c9a84c;
--gold-light: #e6c97a;
--ink: #2c1a0e;
--ink-2: #4a3828;
--warm-gray: #7a6a58;
--success: #4f7a3a;
--danger: #b3432a;
--warning: #d99020;
--font-display: "Playfair Display", Georgia, serif;
--font-body: "Inter", system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-body);
background: var(--cream);
color: var(--ink);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
.page {
max-width: 760px;
margin: 0 auto;
padding: 40px 24px 64px;
}
.head {
text-align: center;
margin-bottom: 28px;
}
.kicker {
font-size: 0.72rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--terracotta);
font-weight: 700;
margin-bottom: 8px;
}
.head h1 {
font-family: var(--font-display);
font-weight: 800;
font-size: clamp(1.8rem, 4.5vw, 2.5rem);
letter-spacing: -0.015em;
}
.sub {
margin-top: 10px;
font-size: 0.96rem;
color: var(--warm-gray);
max-width: 540px;
margin-left: auto;
margin-right: auto;
}
/* Filter */
.filter {
background: var(--bone);
border: 1px solid rgba(44, 26, 14, 0.08);
border-radius: 14px;
padding: 20px 22px;
display: flex;
flex-direction: column;
gap: 14px;
margin-bottom: 22px;
}
.bank {
border: none;
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.bank legend {
font-size: 0.7rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink-2);
font-weight: 700;
margin-bottom: 4px;
flex-basis: 100%;
}
.chip {
display: inline-flex;
align-items: center;
background: var(--cream);
border: 1px solid rgba(44, 26, 14, 0.12);
padding: 8px 14px;
border-radius: 999px;
font-size: 0.88rem;
font-weight: 600;
cursor: pointer;
user-select: none;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.chip input {
display: none;
}
.chip:has(input:checked) {
background: var(--forest);
color: var(--bone);
border-color: var(--forest-d);
}
.chip:hover {
border-color: var(--terracotta);
}
.chip-avoid:has(input:checked) {
background: var(--terracotta);
color: var(--bone);
border-color: var(--terracotta-d);
}
.counter {
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px dashed rgba(44, 26, 14, 0.18);
padding-top: 14px;
font-size: 0.92rem;
}
.counter b {
font-family: var(--font-mono);
font-weight: 800;
color: var(--ink);
}
.counter .ok {
color: var(--success);
font-weight: 700;
}
.reset {
background: transparent;
border: 1px solid rgba(44, 26, 14, 0.16);
color: var(--ink-2);
border-radius: 999px;
padding: 7px 14px;
font-family: inherit;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
}
.reset:hover {
border-color: var(--terracotta);
color: var(--terracotta-d);
}
/* List */
.list {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
@media (max-width: 640px) {
.list {
grid-template-columns: 1fr;
}
}
.dish {
background: var(--bone);
border: 1px solid rgba(44, 26, 14, 0.08);
border-radius: 12px;
padding: 16px 18px;
display: flex;
flex-direction: column;
gap: 6px;
transition: opacity 0.18s, background 0.18s, border-color 0.18s;
}
.dish.is-hidden {
background: var(--cream-2);
border-color: rgba(44, 26, 14, 0.06);
opacity: 0.55;
}
.d-top {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
}
.d-name {
font-family: var(--font-display);
font-weight: 700;
font-size: 1.1rem;
}
.d-price {
font-family: var(--font-mono);
font-weight: 700;
color: var(--terracotta-d);
}
.dish.is-hidden .d-price {
color: var(--warm-gray);
}
.d-desc {
font-size: 0.86rem;
color: var(--ink-2);
}
.d-tags {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 4px;
}
.d-tag {
font-size: 0.62rem;
letter-spacing: 0.08em;
text-transform: uppercase;
background: var(--cream-2);
color: var(--ink-2);
padding: 3px 8px;
border-radius: 999px;
font-weight: 700;
}
.d-tag[data-tone="veg"] {
background: rgba(79, 122, 58, 0.16);
color: var(--success);
}
.d-tag[data-tone="vegan"] {
background: rgba(79, 122, 58, 0.24);
color: var(--forest-d);
}
.d-tag[data-tone="gf"] {
background: rgba(201, 168, 76, 0.2);
color: #8a7325;
}
.d-tag[data-tone="signature"] {
background: var(--gold);
color: var(--ink);
}
.d-reason {
margin-top: 4px;
font-size: 0.78rem;
color: var(--danger);
font-weight: 600;
font-style: italic;
}
.dish:not(.is-hidden) .d-reason {
display: none;
}// Dishes labelled with what they CONTAIN and what diets they are SUITABLE for.
// contains: any of dairy, nuts, shellfish, eggs, pork, gluten, spicy
// diet: vegan / vegetarian / gluten-free (it's safe for that diet)
const DISHES = [
{
name: "Pan masa madre",
price: 8,
desc: "Sourdough, smoked oil, sea salt.",
contains: ["gluten"],
diet: ["vegan", "vegetarian"],
tags: [{ label: "Vegan", tone: "vegan" }],
},
{
name: "Burrata huerta",
price: 16,
desc: "Heirloom tomato, garden basil, focaccia.",
contains: ["dairy", "gluten"],
diet: ["vegetarian"],
tags: [{ label: "Veg", tone: "veg" }],
},
{
name: "Pulpo a la brasa",
price: 19,
desc: "Charred octopus, paprika potato.",
contains: ["shellfish"],
diet: ["gluten-free"],
tags: [{ label: "GF", tone: "gf" }],
},
{
name: "Croquetas de jamón",
price: 14,
desc: "Iberico ham croquettes, lemon aioli.",
contains: ["pork", "dairy", "gluten", "eggs"],
diet: [],
tags: [],
},
{
name: "Ensalada huerta",
price: 13,
desc: "Garden lettuces, radish, lemon vinaigrette.",
contains: [],
diet: ["vegan", "vegetarian", "gluten-free"],
tags: [
{ label: "Vegan", tone: "vegan" },
{ label: "GF", tone: "gf" },
],
},
{
name: "Pappardelle al ragú",
price: 24,
desc: "Hand-cut pasta, slow lamb shoulder.",
contains: ["gluten", "dairy", "eggs"],
diet: [],
tags: [],
},
{
name: "Risotto de hongos",
price: 26,
desc: "Carnaroli rice, wild mushrooms, parmesan.",
contains: ["dairy"],
diet: ["vegetarian", "gluten-free"],
tags: [
{ label: "Veg", tone: "veg" },
{ label: "GF", tone: "gf" },
],
},
{
name: "Spaghetti alle vongole",
price: 28,
desc: "Clams, white wine, parsley.",
contains: ["shellfish", "gluten"],
diet: [],
tags: [],
},
{
name: "Ribeye 14oz",
price: 48,
desc: "Dry-aged 28 days, marrow butter, chimichurri.",
contains: ["dairy"],
diet: ["gluten-free"],
tags: [
{ label: "GF", tone: "gf" },
{ label: "Signature", tone: "signature" },
],
},
{
name: "Branzino entero",
price: 38,
desc: "Whole sea bass, fennel, preserved lemon.",
contains: [],
diet: ["gluten-free"],
tags: [{ label: "GF", tone: "gf" }],
},
{
name: "Pollo al carbón",
price: 28,
desc: "Half free-range chicken, garlic confit.",
contains: [],
diet: ["gluten-free"],
tags: [{ label: "GF", tone: "gf" }],
},
{
name: "Costilla de cordero",
price: 42,
desc: "Lamb ribs, honey-mint, charred onion.",
contains: [],
diet: ["gluten-free"],
tags: [{ label: "GF", tone: "gf" }],
},
{
name: "Plato del huerto",
price: 22,
desc: "Seasonal vegetables, chilli oil.",
contains: ["spicy"],
diet: ["vegan", "vegetarian", "gluten-free"],
tags: [
{ label: "Vegan", tone: "vegan" },
{ label: "GF", tone: "gf" },
],
},
{
name: "Tarta de queso quemada",
price: 11,
desc: "Basque burnt cheesecake.",
contains: ["dairy", "eggs", "gluten"],
diet: ["vegetarian"],
tags: [
{ label: "Veg", tone: "veg" },
{ label: "Signature", tone: "signature" },
],
},
{
name: "Olive oil cake",
price: 10,
desc: "Citrus, crème fraîche.",
contains: ["dairy", "eggs", "gluten"],
diet: ["vegetarian"],
tags: [{ label: "Veg", tone: "veg" }],
},
{
name: "Chocolate ganache",
price: 12,
desc: "Dark chocolate, hazelnut praline.",
contains: ["dairy", "nuts"],
diet: ["vegetarian", "gluten-free"],
tags: [
{ label: "Veg", tone: "veg" },
{ label: "GF", tone: "gf" },
],
},
];
const list = document.getElementById("list");
const okEl = document.getElementById("ok");
const hiddenEl = document.getElementById("hidden");
const AVOID_LABEL = {
dairy: "Contains dairy",
nuts: "Contains nuts",
shellfish: "Contains shellfish",
eggs: "Contains eggs",
pork: "Contains pork",
spicy: "Spicy",
gluten: "Contains gluten",
};
const DIET_LABEL = {
vegan: "not vegan",
vegetarian: "not vegetarian",
"gluten-free": "not gluten-free",
};
function evaluate() {
const wants = [...document.querySelectorAll('input[name="diet"]:checked')].map((c) => c.value);
const avoids = [...document.querySelectorAll('input[name="avoid"]:checked')].map((c) => c.value);
let ok = 0;
let hidden = 0;
DISHES.forEach((d, i) => {
const reasons = [];
// Diet check: if user wants vegan, the dish must be vegan.
wants.forEach((w) => {
if (!d.diet.includes(w)) reasons.push(DIET_LABEL[w]);
});
// Avoid check
avoids.forEach((a) => {
if (d.contains.includes(a)) reasons.push(AVOID_LABEL[a]);
});
// GF avoid = also avoid gluten as an avoid
if (wants.includes("gluten-free") && d.contains.includes("gluten")) {
// already accounted for via diet; do nothing
}
const el = document.querySelector(`[data-i="${i}"]`);
if (!el) return;
const r = el.querySelector(".d-reason");
if (reasons.length === 0) {
el.classList.remove("is-hidden");
if (r) r.textContent = "";
ok += 1;
} else {
el.classList.add("is-hidden");
if (r) r.textContent = `Why hidden · ${reasons.slice(0, 2).join(" · ")}`;
hidden += 1;
}
});
okEl.textContent = ok;
hiddenEl.textContent = hidden;
}
function render() {
list.innerHTML = DISHES.map(
(d, i) => `<article class="dish" data-i="${i}">
<div class="d-top">
<span class="d-name">${d.name}</span>
<span class="d-price">$${d.price.toFixed(2)}</span>
</div>
<p class="d-desc">${d.desc}</p>
${
d.tags.length
? `<div class="d-tags">${d.tags.map((t) => `<span class="d-tag" data-tone="${t.tone}">${t.label}</span>`).join("")}</div>`
: ""
}
<p class="d-reason"></p>
</article>`
).join("");
}
document
.querySelectorAll('input[type="checkbox"]')
.forEach((cb) => cb.addEventListener("change", evaluate));
document.getElementById("reset").addEventListener("click", () => {
document.querySelectorAll('input[type="checkbox"]').forEach((cb) => (cb.checked = false));
evaluate();
});
render();
evaluate();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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=Playfair+Display:wght@700;800&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap"
/>
<link rel="stylesheet" href="style.css" />
<title>Allergy filter · Casa Olivar</title>
</head>
<body>
<main class="page">
<header class="head">
<p class="kicker">Tonight's carta · 16 dishes</p>
<h1>Tell us what you avoid.</h1>
<p class="sub">
The kitchen can adapt most dishes — pick what you'd rather skip and
we'll show you only what's safe.
</p>
</header>
<section class="filter">
<fieldset class="bank">
<legend>I want</legend>
<label class="chip" data-kind="diet">
<input type="checkbox" name="diet" value="vegetarian" />
<span>🥬 Vegetarian</span>
</label>
<label class="chip" data-kind="diet">
<input type="checkbox" name="diet" value="vegan" />
<span>🌱 Vegan</span>
</label>
<label class="chip" data-kind="diet">
<input type="checkbox" name="diet" value="gluten-free" />
<span>🌾 Gluten-free</span>
</label>
</fieldset>
<fieldset class="bank">
<legend>I avoid</legend>
<label class="chip chip-avoid" data-kind="avoid">
<input type="checkbox" name="avoid" value="dairy" />
<span>🥛 Dairy</span>
</label>
<label class="chip chip-avoid" data-kind="avoid">
<input type="checkbox" name="avoid" value="nuts" />
<span>🥜 Nuts</span>
</label>
<label class="chip chip-avoid" data-kind="avoid">
<input type="checkbox" name="avoid" value="shellfish" />
<span>🦐 Shellfish</span>
</label>
<label class="chip chip-avoid" data-kind="avoid">
<input type="checkbox" name="avoid" value="eggs" />
<span>🥚 Eggs</span>
</label>
<label class="chip chip-avoid" data-kind="avoid">
<input type="checkbox" name="avoid" value="pork" />
<span>🐖 Pork</span>
</label>
<label class="chip chip-avoid" data-kind="avoid">
<input type="checkbox" name="avoid" value="spicy" />
<span>🌶 Spicy</span>
</label>
</fieldset>
<div class="counter">
<p>
<b id="ok">16</b> of 16 dishes are <span class="ok">safe</span> · <b id="hidden">0</b> hidden
</p>
<button class="reset" type="button" id="reset">Reset filters</button>
</div>
</section>
<section class="list" id="list"></section>
</main>
<script src="script.js"></script>
</body>
</html>Allergy & Diet Filter
The widget a diner taps before browsing the menu. Two chip rows: “I want” (Vegetarian · Vegan · Gluten-free) and “I avoid” (Dairy · Nuts · Shellfish · Eggs · Pork · Spicy). Live counter shows how many of the 16 menu items match. Result list shows safe dishes; hidden ones get a muted card with a one-line “Why hidden” explanation. Adapts the carta in place — no full re-render.