UI Components Medium
Wine Pairing Recommender
Pair-by-dish widget — pick a dish from the carta, get three sommelier-curated wine matches with a confidence pip, tasting notes, glass/bottle price and an 'add to order' CTA.
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;
--burgundy: #6f1f1f;
--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: 1080px;
margin: 0 auto;
padding: 36px 28px 56px;
}
.head {
text-align: center;
margin-bottom: 28px;
}
.kicker {
font-size: 0.72rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--terracotta);
font-weight: 700;
}
.head h1 {
margin-top: 6px;
font-family: var(--font-display);
font-weight: 800;
font-size: clamp(1.8rem, 4.5vw, 2.6rem);
letter-spacing: -0.015em;
}
.head h1 .ital {
font-style: italic;
color: var(--terracotta);
font-weight: 500;
}
.sub {
margin-top: 10px;
font-size: 0.94rem;
color: var(--warm-gray);
max-width: 580px;
margin-left: auto;
margin-right: auto;
}
/* Layout */
.layout {
display: grid;
grid-template-columns: 240px 1fr;
gap: 18px;
align-items: start;
}
@media (max-width: 760px) {
.layout {
grid-template-columns: 1fr;
}
}
/* Dish list */
.dishes {
background: var(--bone);
border: 1px solid rgba(44, 26, 14, 0.08);
border-radius: 14px;
padding: 16px 14px;
position: sticky;
top: 16px;
}
.block-kicker {
font-size: 0.7rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink-2);
font-weight: 700;
padding: 0 8px 8px;
margin-bottom: 6px;
border-bottom: 1px dashed rgba(44, 26, 14, 0.18);
}
.dishes ul {
list-style: none;
display: flex;
flex-direction: column;
gap: 2px;
}
.dishes li {
padding: 9px 10px;
border-radius: 8px;
cursor: pointer;
font-size: 0.92rem;
color: var(--ink-2);
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
}
.dishes li:hover {
background: var(--cream-2);
}
.dishes li.is-active {
background: var(--forest);
color: var(--bone);
font-weight: 700;
}
.dishes .d-section {
margin-top: 8px;
padding: 6px 10px 4px;
font-size: 0.66rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--terracotta);
font-weight: 700;
cursor: default;
background: transparent;
}
.dishes li.is-active .d-price {
color: var(--gold-light);
}
.d-price {
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--warm-gray);
font-weight: 700;
}
/* Panel */
.panel {
background: var(--bone);
border: 1px solid rgba(44, 26, 14, 0.08);
border-radius: 14px;
padding: 22px 24px 18px;
display: flex;
flex-direction: column;
gap: 14px;
}
.panel-head {
padding-bottom: 12px;
border-bottom: 1px solid rgba(44, 26, 14, 0.08);
}
.panel-kicker {
font-size: 0.7rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--terracotta);
font-weight: 700;
}
.panel-head h2 {
font-family: var(--font-display);
font-weight: 800;
font-size: 1.6rem;
margin-top: 4px;
letter-spacing: -0.01em;
}
.dish-meta {
margin-top: 4px;
font-size: 0.86rem;
color: var(--warm-gray);
font-style: italic;
}
/* Pairings */
.pairings {
list-style: none;
display: flex;
flex-direction: column;
gap: 10px;
}
.pair {
background: var(--cream);
border: 1px solid rgba(44, 26, 14, 0.08);
border-radius: 12px;
padding: 16px 18px;
display: grid;
grid-template-columns: 36px 1fr;
gap: 14px;
align-items: start;
}
.bottle {
width: 36px;
height: 60px;
position: relative;
margin-top: 6px;
}
.bottle::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(180deg, var(--burgundy) 0%, var(--ink) 100%);
border-radius: 6px 6px 4px 4px;
box-shadow: inset 1px 0 0 rgba(255, 255, 255, 0.08);
}
.bottle::after {
/* neck */
content: "";
position: absolute;
top: -14px;
left: 50%;
width: 14px;
height: 16px;
background: linear-gradient(180deg, var(--burgundy) 0%, var(--ink) 100%);
border-radius: 3px 3px 0 0;
transform: translateX(-50%);
}
.bottle.white::before,
.bottle.white::after {
background: linear-gradient(180deg, #5d6a3a 0%, #3a4423 100%);
}
.bottle.rose::before,
.bottle.rose::after {
background: linear-gradient(180deg, #c97a6a 0%, #8e3a2c 100%);
}
.bottle.spark::before,
.bottle.spark::after {
background: linear-gradient(180deg, #4d6f7a 0%, #1f3236 100%);
}
.bottle .label {
position: absolute;
left: 4px;
right: 4px;
top: 16px;
height: 28px;
background: var(--bone);
border-radius: 2px;
display: grid;
place-items: center;
font-family: var(--font-mono);
font-size: 6.5px;
color: var(--ink);
font-weight: 700;
letter-spacing: 0.04em;
z-index: 2;
text-align: center;
line-height: 1;
}
.p-body {
min-width: 0;
}
.p-top {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 14px;
flex-wrap: wrap;
}
.p-name {
font-family: var(--font-display);
font-weight: 700;
font-size: 1.15rem;
letter-spacing: -0.005em;
}
.p-region {
font-size: 0.78rem;
color: var(--warm-gray);
margin-top: 2px;
}
.p-region span {
margin: 0 4px;
}
.p-price {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.p-price .p-glass {
font-family: var(--font-mono);
font-weight: 700;
color: var(--terracotta-d);
font-size: 0.92rem;
}
.p-price small {
font-size: 0.7rem;
color: var(--warm-gray);
}
.p-conf {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.72rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--warm-gray);
font-weight: 700;
margin-top: 4px;
}
.p-pip {
display: inline-flex;
gap: 3px;
}
.p-pip span {
width: 8px;
height: 8px;
border-radius: 999px;
background: rgba(44, 26, 14, 0.18);
}
.p-pip span.on {
background: var(--terracotta);
}
.p-notes {
margin-top: 8px;
font-size: 0.9rem;
color: var(--ink-2);
line-height: 1.55;
}
.p-actions {
display: flex;
gap: 6px;
margin-top: 10px;
}
.p-actions button {
border-radius: 999px;
padding: 8px 14px;
font-family: inherit;
font-size: 0.8rem;
font-weight: 700;
cursor: pointer;
}
.p-add {
background: var(--forest);
color: var(--bone);
border: none;
}
.p-add:hover {
background: var(--forest-d);
}
.p-why {
background: transparent;
border: 1px solid rgba(44, 26, 14, 0.18);
color: var(--ink-2);
}
.p-why:hover {
border-color: var(--terracotta);
color: var(--terracotta-d);
}
.p-why-body {
margin-top: 8px;
background: var(--bone);
border-left: 3px solid var(--terracotta);
padding: 10px 14px;
border-radius: 0 8px 8px 0;
font-size: 0.86rem;
color: var(--ink-2);
font-style: italic;
}
.cta-line {
text-align: center;
padding-top: 10px;
border-top: 1px dashed rgba(44, 26, 14, 0.18);
}
.cta-line .note {
font-size: 0.8rem;
color: var(--warm-gray);
}
/* Toast */
.toast {
position: fixed;
bottom: 28px;
left: 50%;
transform: translateX(-50%);
background: var(--forest-d);
color: var(--bone);
padding: 10px 18px;
border-radius: 999px;
font-size: 0.86rem;
font-weight: 600;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.18);
z-index: 10;
}const DISHES = [
{
id: "burrata",
section: "Entradas",
name: "Burrata huerta",
price: 16,
kind: "Heirloom tomato · garden basil",
},
{
id: "pulpo",
section: "Entradas",
name: "Pulpo a la brasa",
price: 19,
kind: "Charred octopus · paprika potato",
},
{
id: "ribeye",
section: "Principales",
name: "Ribeye 14oz",
price: 48,
kind: "Dry-aged 28 days · marrow butter",
},
{
id: "branzino",
section: "Principales",
name: "Branzino entero",
price: 38,
kind: "Whole sea bass · fennel",
},
{
id: "risotto",
section: "Principales",
name: "Risotto hongos",
price: 26,
kind: "Wild mushroom · parmesan",
},
{
id: "pappardelle",
section: "Pasta",
name: "Pappardelle ragú",
price: 24,
kind: "Lamb shoulder · gremolata",
},
{
id: "tarta",
section: "Postres",
name: "Tarta de queso quemada",
price: 11,
kind: "Burnt cheesecake · caramel",
},
{
id: "olive",
section: "Postres",
name: "Olive oil cake",
price: 10,
kind: "Citrus · crème fraîche",
},
];
const PAIRINGS = {
burrata: [
{
name: "Albariño Pazo Señorans",
region: "Rías Baixas · Albariño · 2023",
color: "white",
conf: 3,
glass: 11,
bottle: 48,
notes:
"Saline, lemon-zest acidity to cut the cream of the burrata. The natural minerality reads almost like crushed shell.",
why: "Burrata's richness needs acid to stay light — Albariño's salty edge is the classic move.",
},
{
name: "Vermut casa",
region: "Madrid · House · on tap",
color: "rose",
conf: 2,
glass: 9,
bottle: 0,
notes: "Aromatic, slightly bitter herbs lift the basil. Pour with ice and an orange peel.",
why: "If you want something more aperitif than wine — vermut is built for tomato dishes.",
},
{
name: "Rosé d'Anjou natural",
region: "Loire · Cabernet Franc · 2022",
color: "rose",
conf: 2,
glass: 13,
bottle: 56,
notes:
"Light strawberry, a little CO2 spritz, very low intervention. Works because it isn't sweet.",
why: "Rosé pairs by colour with summer tomato — the natural style stays bright.",
},
],
pulpo: [
{
name: "Mencía Adega Algueira",
region: "Ribeira Sacra · Mencía · 2022",
color: "red",
conf: 3,
glass: 12,
bottle: 54,
notes: "Smoky, herbal, light tannin — mirrors the paprika and char on the octopus.",
why: "Galician red with grilled Galician seafood — regional pairing, hard to miss.",
},
{
name: "Manzanilla en rama",
region: "Sanlúcar · Palomino · NV",
color: "white",
conf: 3,
glass: 10,
bottle: 38,
notes: "Briny, almond, bone-dry. Drinks like the sea looks.",
why: "The classic sherry pairing for grilled seafood — bypasses the paprika problem.",
},
{
name: "Godello Valdesil",
region: "Valdeorras · Godello · 2023",
color: "white",
conf: 2,
glass: 13,
bottle: 56,
notes: "Stone fruit, gentle texture. Less sharp than Albariño, more weight.",
why: "When you want a white but the dish has a meatier handle than fish would.",
},
],
ribeye: [
{
name: "Mencía 'Ácrata'",
region: "Bierzo · Mencía · 2020",
color: "red",
conf: 3,
glass: 16,
bottle: 78,
notes: "Bramble fruit, cracked pepper, fine-grain tannin. Built to stand up to dry-age fat.",
why: "Old-vine Mencía has the tannin of a Bordeaux without the heaviness — perfect for a ribeye.",
},
{
name: "Ribera del Duero 'Pago de los Capellanes'",
region: "Castilla y León · Tempranillo · 2019",
color: "red",
conf: 3,
glass: 22,
bottle: 110,
notes: "Black cherry, leather, gentle oak. Plush mid-palate, long finish.",
why: "Classic Spanish steak wine — the oak gives the bone-marrow butter a place to land.",
},
{
name: "Châteauneuf-du-Pape",
region: "S. Rhône · GSM blend · 2018",
color: "red",
conf: 2,
glass: 24,
bottle: 124,
notes: "Garrigue, ripe plum, savoury heat. A bigger pour for a richer eat.",
why: "When the cut is dry-aged 28+ days, a wine with herb notes lifts the funk.",
},
],
branzino: [
{
name: "Albariño Pazo Señorans",
region: "Rías Baixas · Albariño · 2023",
color: "white",
conf: 3,
glass: 11,
bottle: 48,
notes: "Bright lemon, salty finish. Pulls the preserved-lemon up.",
why: "When the fish is whole and roasted, you need acid and salt — Albariño checks both.",
},
{
name: "Vermentino di Sardegna",
region: "Sardegna · Vermentino · 2022",
color: "white",
conf: 2,
glass: 13,
bottle: 56,
notes: "Herbal, almond, slight bitter twist. The fennel loves this.",
why: "Vermentino was bred for sea-bass — the bitterness echoes the fennel.",
},
{
name: "Manzanilla en rama",
region: "Sanlúcar · Palomino · NV",
color: "white",
conf: 3,
glass: 10,
bottle: 38,
notes: "Saline, walnut, dry. Same logic as oysters and white burgundy.",
why: "Sherry under-promises and over-delivers with whole roasted fish.",
},
],
risotto: [
{
name: "Nebbiolo d'Alba",
region: "Piemonte · Nebbiolo · 2021",
color: "red",
conf: 3,
glass: 14,
bottle: 64,
notes: "Rose, tar, high acid. Surprisingly light tannins for the body.",
why: "Mushroom and Nebbiolo is one of the oldest pairings in the book — earth + earth.",
},
{
name: "Côtes du Jura · Chardonnay",
region: "Jura · Chardonnay · 2021",
color: "white",
conf: 2,
glass: 16,
bottle: 78,
notes: "Nutty, slightly oxidative, walnut. Reads almost like dry sherry.",
why: "If you don't want red, this Jura white drinks like a Burgundy with a sense of humour.",
},
{
name: "Trousseau natural",
region: "Jura · Trousseau · 2022",
color: "rose",
conf: 2,
glass: 15,
bottle: 68,
notes: "Light red, cherry, almost rosé in body. Chill it slightly.",
why: "Wild mushroom + chilled light red is one of those 'why does this work' pairings.",
},
],
pappardelle: [
{
name: "Chianti Classico Riserva",
region: "Toscana · Sangiovese · 2019",
color: "red",
conf: 3,
glass: 14,
bottle: 64,
notes: "Cherry, dried herb, gripping acidity. Cuts the lamb fat clean.",
why: "Slow-cooked lamb ragú is the Sangiovese of pasta — they were made for each other.",
},
{
name: "Rioja Reserva",
region: "Rioja · Tempranillo · 2017",
color: "red",
conf: 2,
glass: 13,
bottle: 58,
notes: "Vanilla oak, plum, leather. Comforting without being heavy.",
why: "A safe second choice that pleases most diners at the table.",
},
{
name: "Cesanese del Piglio",
region: "Lazio · Cesanese · 2021",
color: "red",
conf: 2,
glass: 12,
bottle: 54,
notes: "Spicy, brambly, a little wild. Native Lazio grape — drink it with Roman food.",
why: "If the table likes 'Italian wine, off the beaten path' — this is the play.",
},
],
tarta: [
{
name: "PX Don PX 'Convento'",
region: "Montilla-Moriles · PX · 2008",
color: "red",
conf: 3,
glass: 9,
bottle: 0,
notes: "Raisin, espresso, dark caramel. Sweeter than the dessert.",
why: "Burnt cheesecake's caramelised top wants something with equal weight — PX overpowers gracefully.",
},
{
name: "Moscato d'Asti",
region: "Piemonte · Moscato · 2023",
color: "spark",
conf: 2,
glass: 10,
bottle: 38,
notes: "Lightly sparkling, peach, low alcohol. Reset between bites.",
why: "If PX feels like too much, Moscato gives palate cleansing with sweet support.",
},
{
name: "Tokaji 5 puttonyos",
region: "Hungary · Furmint · 2017",
color: "white",
conf: 2,
glass: 14,
bottle: 78,
notes: "Honey, apricot, balancing acidity. A more refined dessert match.",
why: "When you want elegance with the cheesecake instead of comfort — Tokaji walks the line.",
},
],
olive: [
{
name: "Moscato d'Asti",
region: "Piemonte · Moscato · 2023",
color: "spark",
conf: 3,
glass: 10,
bottle: 38,
notes: "Peach blossom, light fizz, gentle sweetness — same energy as the cake.",
why: "Citrus oil cake calls for something effervescent that won't bury the orange.",
},
{
name: "Recioto della Valpolicella",
region: "Veneto · Corvina · 2018",
color: "red",
conf: 2,
glass: 14,
bottle: 78,
notes: "Cherry preserve, light raisin sweetness. A red for dessert.",
why: "Veneto's traditional dessert red — pairs better with cake than people expect.",
},
{
name: "Sherry · Cream",
region: "Jerez · Palomino/PX · NV",
color: "white",
conf: 2,
glass: 8,
bottle: 32,
notes: "Sweet, nutty, low alcohol. Spanish dessert classic.",
why: "If the rest of the table is having coffee, this gives the cake-eater something.",
},
],
};
const dishList = document.getElementById("dishList");
const dishTitle = document.getElementById("dishTitle");
const dishMeta = document.getElementById("dishMeta");
const pairs = document.getElementById("pairings");
const toast = document.getElementById("toast");
let activeId = "ribeye";
function renderDishes() {
let lastSection = "";
dishList.innerHTML = DISHES.map((d) => {
const sec =
d.section !== lastSection ? `<li class="d-section" data-section>${d.section}</li>` : "";
lastSection = d.section;
return `${sec}<li class="${d.id === activeId ? "is-active" : ""}" data-id="${d.id}">
<span>${d.name}</span>
<span class="d-price">$${d.price}</span>
</li>`;
}).join("");
}
function renderPairings() {
const d = DISHES.find((x) => x.id === activeId);
if (!d) return;
dishTitle.textContent = d.name;
dishMeta.textContent = `${d.section} · ${d.kind}`;
const ps = PAIRINGS[d.id] || [];
pairs.innerHTML = ps
.map(
(p, i) => `<li class="pair" data-i="${i}">
<div class="bottle ${p.color}">
<span class="label">${p.name.split(" ")[0]}</span>
</div>
<div class="p-body">
<div class="p-top">
<div>
<p class="p-name">${p.name}</p>
<p class="p-region">${p.region}</p>
<p class="p-conf">
<span class="p-pip">
<span class="${p.conf >= 1 ? "on" : ""}"></span>
<span class="${p.conf >= 2 ? "on" : ""}"></span>
<span class="${p.conf >= 3 ? "on" : ""}"></span>
</span>
<span>${["weak", "good", "strong"][p.conf - 1] || "good"} match</span>
</p>
</div>
<div class="p-price">
<span class="p-glass">$${p.glass}<small> / glass</small></span>
${p.bottle > 0 ? `<small>bottle · $${p.bottle}</small>` : `<small>by the glass only</small>`}
</div>
</div>
<p class="p-notes">${p.notes}</p>
<div class="p-actions">
<button class="p-add" data-action="add" data-name="${p.name}">Add a glass</button>
<button class="p-why" data-action="why" data-i="${i}">Why this match?</button>
</div>
<p class="p-why-body" data-why="${i}" hidden>${p.why}</p>
</div>
</li>`
)
.join("");
}
dishList.addEventListener("click", (e) => {
const li = e.target.closest("[data-id]");
if (!li || li.matches("[data-section]")) return;
activeId = li.dataset.id;
renderDishes();
renderPairings();
});
pairs.addEventListener("click", (e) => {
const btn = e.target.closest("[data-action]");
if (!btn) return;
if (btn.dataset.action === "add") {
showToast(`Added · ${btn.dataset.name} · glass`);
}
if (btn.dataset.action === "why") {
const i = btn.dataset.i;
const body = pairs.querySelector(`[data-why="${i}"]`);
if (body) body.hidden = !body.hidden;
btn.textContent = body && !body.hidden ? "Hide reasoning" : "Why this match?";
}
});
function showToast(msg) {
toast.textContent = msg;
toast.hidden = false;
clearTimeout(showToast._t);
showToast._t = setTimeout(() => (toast.hidden = true), 2400);
}
renderDishes();
renderPairings();<!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:ital,wght@0,700;0,800;1,500&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap"
/>
<link rel="stylesheet" href="style.css" />
<title>Pair the wine · Casa Olivar</title>
</head>
<body>
<main class="page">
<header class="head">
<p class="kicker">Sommelier · Sasha M.</p>
<h1>
Pair the wine. <span class="ital">Drink better.</span>
</h1>
<p class="sub">
Pick a dish on the left — we'll suggest three wines from tonight's
list. Curated by the bar, updated every Tuesday.
</p>
</header>
<section class="layout">
<!-- Dishes -->
<aside class="dishes">
<p class="block-kicker">Tonight's carta</p>
<ul id="dishList"></ul>
</aside>
<!-- Pairings panel -->
<section class="panel">
<header class="panel-head">
<p class="panel-kicker">3 pairings for</p>
<h2 id="dishTitle">—</h2>
<p class="dish-meta" id="dishMeta">—</p>
</header>
<ul class="pairings" id="pairings"></ul>
<p class="cta-line" id="ctaLine">
<span class="note">Pour-by-pour by your server · ask anyone.</span>
</p>
</section>
</section>
</main>
<div class="toast" id="toast" hidden></div>
<script src="script.js"></script>
</body>
</html>Wine Pairing
Sasha-the-bartender’s pairing widget. Pick a dish from the carta sidebar — the panel on the right loads three wine matches with a confidence pip (●●●), tasting notes from the sommelier, region · grape · vintage line, glass price and bottle price, and an “Add to order” CTA. A “Why this match?” expandable shows the reasoning. Pairings are static-curated per dish in the data.