UI Components Easy
Reviews Feed
Customer review feed: aggregated score with 5-star breakdown bars, filter chips (all · recent · 5★ · with photos), tag pills, helpful counter and owner replies.
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: 36px 24px 64px;
}
.hidden-h {
position: absolute;
left: -9999px;
}
.head {
display: grid;
grid-template-columns: 1fr auto;
gap: 24px;
align-items: center;
padding-bottom: 24px;
border-bottom: 1px solid rgba(44, 26, 14, 0.08);
}
.kicker {
font-size: 0.72rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--terracotta);
font-weight: 700;
margin-bottom: 6px;
}
.head h1 {
font-family: var(--font-display);
font-weight: 800;
font-size: 2rem;
letter-spacing: -0.015em;
}
.agg {
text-align: right;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.agg-score {
font-family: var(--font-display);
font-weight: 800;
font-size: 3rem;
line-height: 1;
letter-spacing: -0.02em;
color: var(--ink);
}
.agg-stars {
font-family: var(--font-display);
font-size: 1.4rem;
color: var(--gold);
letter-spacing: 0.04em;
}
.agg-count {
font-size: 0.82rem;
color: var(--warm-gray);
font-weight: 600;
}
/* Distribution */
.distro {
margin: 22px 0 18px;
}
.distro ul {
list-style: none;
display: flex;
flex-direction: column;
gap: 4px;
}
.d-row {
display: grid;
grid-template-columns: 60px 1fr 56px;
align-items: center;
gap: 12px;
}
.d-star {
font-size: 0.84rem;
font-weight: 600;
color: var(--ink-2);
font-family: var(--font-mono);
}
.d-bar {
height: 8px;
background: var(--cream-2);
border-radius: 999px;
overflow: hidden;
}
.d-fill {
height: 100%;
background: var(--gold);
border-radius: 999px;
transition: width 0.4s ease;
}
.d-row[data-s="4"] .d-fill {
background: var(--gold-light);
}
.d-row[data-s="3"] .d-fill {
background: var(--cream-2);
}
.d-row[data-s="2"] .d-fill,
.d-row[data-s="1"] .d-fill {
background: var(--warm-gray);
}
.d-pct {
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--warm-gray);
text-align: right;
font-weight: 600;
}
/* Chips */
.chips {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.chip {
background: var(--bone);
border: 1px solid rgba(44, 26, 14, 0.1);
color: var(--ink-2);
padding: 7px 14px;
border-radius: 999px;
font-family: inherit;
font-size: 0.84rem;
font-weight: 600;
cursor: pointer;
display: inline-flex;
gap: 6px;
}
.chip span {
font-family: var(--font-mono);
font-size: 0.7rem;
background: var(--cream-2);
padding: 2px 7px;
border-radius: 999px;
color: var(--warm-gray);
font-weight: 700;
}
.chip:hover {
border-color: var(--terracotta);
color: var(--terracotta-d);
}
.chip.is-active {
background: var(--forest);
color: var(--bone);
border-color: var(--forest-d);
}
.chip.is-active span {
background: rgba(250, 247, 241, 0.18);
color: var(--gold-light);
}
/* Reviews */
.reviews {
list-style: none;
display: flex;
flex-direction: column;
gap: 12px;
}
.review {
background: var(--bone);
border: 1px solid rgba(44, 26, 14, 0.08);
border-radius: 14px;
padding: 18px 20px 16px;
display: flex;
flex-direction: column;
gap: 10px;
transition: opacity 0.18s, transform 0.18s;
}
.review.is-hidden {
display: none !important;
}
.r-head {
display: flex;
align-items: center;
gap: 12px;
}
.r-avatar {
width: 44px;
height: 44px;
border-radius: 999px;
background: var(--cream-2);
color: var(--ink);
display: grid;
place-items: center;
font-family: var(--font-display);
font-weight: 800;
font-size: 1rem;
}
.r-name {
font-weight: 700;
}
.r-meta {
font-size: 0.76rem;
color: var(--warm-gray);
}
.r-stars {
margin-left: auto;
font-family: var(--font-display);
color: var(--gold);
font-size: 1.1rem;
letter-spacing: 0.04em;
}
.r-stars .dim {
color: rgba(44, 26, 14, 0.18);
}
.r-tags {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.r-tag {
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
background: var(--cream-2);
color: var(--ink-2);
padding: 3px 9px;
border-radius: 999px;
}
.r-tag[data-tone="dish"] {
background: rgba(193, 113, 74, 0.15);
color: var(--terracotta-d);
}
.r-tag[data-tone="vibe"] {
background: rgba(45, 74, 62, 0.13);
color: var(--forest-d);
}
.r-body {
font-size: 0.96rem;
color: var(--ink-2);
line-height: 1.55;
}
.r-photos {
display: flex;
gap: 6px;
}
.r-photo {
width: 72px;
height: 72px;
border-radius: 8px;
}
.p-1 {
background: linear-gradient(135deg, var(--terracotta), var(--terracotta-d));
}
.p-2 {
background: linear-gradient(135deg, var(--forest), var(--forest-d));
}
.p-3 {
background: linear-gradient(135deg, var(--gold), var(--gold-light));
}
.p-4 {
background: linear-gradient(135deg, var(--warm-gray), var(--ink));
}
.r-foot {
display: flex;
align-items: center;
gap: 6px;
padding-top: 6px;
border-top: 1px dashed rgba(44, 26, 14, 0.1);
}
.helpful {
background: transparent;
border: 1px solid rgba(44, 26, 14, 0.16);
color: var(--ink-2);
border-radius: 999px;
padding: 6px 12px;
font-family: inherit;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
}
.helpful:hover {
border-color: var(--forest);
color: var(--forest);
}
.helpful.is-on {
background: var(--forest);
border-color: var(--forest-d);
color: var(--bone);
}
.report {
background: transparent;
border: none;
color: var(--warm-gray);
font-size: 0.78rem;
font-family: inherit;
cursor: pointer;
margin-left: auto;
}
.report:hover {
color: var(--danger);
}
/* Owner reply */
.r-reply {
background: var(--cream-2);
border-left: 3px solid var(--forest);
padding: 10px 14px;
border-radius: 0 8px 8px 0;
margin-top: 6px;
}
.r-reply-head {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.76rem;
color: var(--forest-d);
font-weight: 700;
}
.r-reply-head::before {
content: "↳";
color: var(--forest);
font-weight: 700;
}
.r-reply-body {
margin-top: 4px;
font-size: 0.88rem;
color: var(--ink-2);
font-style: italic;
}
.more {
display: block;
margin: 22px auto 0;
background: transparent;
border: 1px solid rgba(44, 26, 14, 0.18);
border-radius: 999px;
padding: 11px 22px;
font-family: inherit;
font-size: 0.88rem;
font-weight: 700;
color: var(--ink-2);
cursor: pointer;
}
.more:hover {
background: var(--bone);
color: var(--ink);
}const DISTRO = [
{ s: 5, pct: 78 },
{ s: 4, pct: 14 },
{ s: 3, pct: 5 },
{ s: 2, pct: 2 },
{ s: 1, pct: 1 },
];
const REVIEWS = [
{
id: 1,
name: "Maite H.",
date: "2 days ago",
rating: 5,
tags: [
{ label: "Ribeye 14oz", tone: "dish" },
{ label: "Cosy vibe", tone: "vibe" },
],
body: "Best ribeye I've had in Madrid. The dry-age comes through, and the bone marrow butter is dangerous. Marco the server was attentive without hovering — the kind of place you go for a quiet anniversary.",
helpful: 18,
photos: 2,
reply: "Maite — thank you for the kind words, see you both again next year. ✦ Aitor",
recent: true,
},
{
id: 2,
name: "Park S.",
date: "1 week ago",
rating: 5,
tags: [
{ label: "Pulpo brasa", tone: "dish" },
{ label: "Service", tone: "vibe" },
],
body: "Pulpo was perfectly charred and the salsa verde was bright — paired with Sasha's natural-wine recommendation it was the best meal of our trip.",
helpful: 9,
photos: 1,
reply: null,
recent: true,
},
{
id: 3,
name: "Davies L.",
date: "2 weeks ago",
rating: 4,
tags: [{ label: "Pappardelle ragú", tone: "dish" }],
body: "Loved everything except the wait between courses (close to 25 min before mains). Pappardelle ragú was excellent — the lamb is slow-cooked long enough to fall apart but still has texture.",
helpful: 4,
photos: 0,
reply:
"Thanks for the feedback Davies — we tightened up second-course timing this weekend after several similar notes. Hope you'll give us another try. — Iria",
recent: false,
},
{
id: 4,
name: "Khoury R.",
date: "3 weeks ago",
rating: 5,
tags: [
{ label: "Tarta de queso", tone: "dish" },
{ label: "Birthday", tone: "vibe" },
],
body: "They surprised my partner with a candle in the burnt cheesecake without us asking — small thing that meant a lot. Cake itself is a top-3 burnt cheesecake in Madrid.",
helpful: 12,
photos: 1,
reply: null,
recent: false,
},
{
id: 5,
name: "Iyengar V.",
date: "1 month ago",
rating: 5,
tags: [
{ label: "Risotto hongos", tone: "dish" },
{ label: "Veg", tone: "vibe" },
],
body: "As a vegetarian I'm used to getting the leftover salad. Here the risotto was the dish of the night. The kitchen treats vegetables like the main event.",
helpful: 21,
photos: 0,
reply: null,
recent: false,
},
{
id: 6,
name: "Tanaka M.",
date: "5 weeks ago",
rating: 3,
tags: [{ label: "Loud at peak", tone: "vibe" }],
body: "Food is great, but the room gets loud at 21:00 — you basically have to shout. If you're going for a quiet date, try the 19:00 seating instead.",
helpful: 6,
photos: 0,
reply:
"Thanks Tanaka — we're working on acoustic panels in the centre section, expect by August. — Iria",
recent: false,
},
];
const distroEl = document.getElementById("distro");
const reviewsEl = document.getElementById("reviews");
const chips = document.getElementById("chips");
const moreBtn = document.getElementById("more");
distroEl.innerHTML = DISTRO.map(
(d) => `<li class="d-row" data-s="${d.s}">
<span class="d-star">${d.s} ★</span>
<span class="d-bar"><span class="d-fill" style="width:${d.pct}%"></span></span>
<span class="d-pct">${d.pct}%</span>
</li>`
).join("");
function stars(n) {
return Array.from({ length: 5 }, (_, i) =>
i < n ? `<span>★</span>` : `<span class="dim">★</span>`
).join("");
}
function photos(n) {
if (!n) return "";
return `<div class="r-photos">${Array.from({ length: n }, (_, i) => `<span class="r-photo p-${(i % 4) + 1}"></span>`).join("")}</div>`;
}
function render() {
reviewsEl.innerHTML = REVIEWS.map(
(
r
) => `<li class="review" data-id="${r.id}" data-recent="${r.recent}" data-rating="${r.rating}" data-photos="${r.photos > 0}">
<div class="r-head">
<span class="r-avatar">${r.name[0]}</span>
<div>
<p class="r-name">${r.name}</p>
<p class="r-meta">${r.date} · verified diner</p>
</div>
<p class="r-stars" aria-label="${r.rating} of 5 stars">${stars(r.rating)}</p>
</div>
${
r.tags.length
? `<div class="r-tags">${r.tags.map((t) => `<span class="r-tag" data-tone="${t.tone}">${t.label}</span>`).join("")}</div>`
: ""
}
<p class="r-body">${r.body}</p>
${photos(r.photos)}
<div class="r-foot">
<button class="helpful" type="button" data-action="helpful" data-id="${r.id}">👍 Helpful · <b>${r.helpful}</b></button>
<button class="report" type="button">Report</button>
</div>
${
r.reply
? `<div class="r-reply">
<p class="r-reply-head">Casa Olivar · owner reply</p>
<p class="r-reply-body">${r.reply}</p>
</div>`
: ""
}
</li>`
).join("");
}
function filter(kind) {
reviewsEl.querySelectorAll(".review").forEach((el) => {
let show = true;
if (kind === "recent") show = el.dataset.recent === "true";
if (kind === "5") show = el.dataset.rating === "5";
if (kind === "photos") show = el.dataset.photos === "true";
el.classList.toggle("is-hidden", !show);
});
}
chips.addEventListener("click", (e) => {
const btn = e.target.closest("[data-filter]");
if (!btn) return;
chips.querySelectorAll(".chip").forEach((c) => c.classList.toggle("is-active", c === btn));
filter(btn.dataset.filter);
});
reviewsEl.addEventListener("click", (e) => {
const btn = e.target.closest("[data-action='helpful']");
if (!btn) return;
const r = REVIEWS.find((x) => x.id === Number(btn.dataset.id));
if (!r) return;
btn.classList.toggle("is-on");
r.helpful += btn.classList.contains("is-on") ? 1 : -1;
btn.querySelector("b").textContent = r.helpful;
});
moreBtn.addEventListener("click", () => {
moreBtn.textContent = "All loaded · 824 reviews in total";
moreBtn.disabled = true;
});
render();<!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>Reviews · Casa Olivar</title>
</head>
<body>
<main class="page">
<header class="head">
<div class="head-left">
<p class="kicker">What guests say</p>
<h1>Reviews</h1>
</div>
<div class="agg">
<p class="agg-score" id="aggScore">4.7</p>
<p class="agg-stars" aria-label="4.7 out of 5 stars">★★★★★</p>
<p class="agg-count" id="aggCount">based on 824 reviews</p>
</div>
</header>
<section class="distro" aria-label="Rating distribution">
<h2 class="hidden-h">Distribution</h2>
<ul id="distro"></ul>
</section>
<nav class="chips" id="chips">
<button class="chip is-active" data-filter="all">All <span>6</span></button>
<button class="chip" data-filter="recent">Recent</button>
<button class="chip" data-filter="5">5★ only</button>
<button class="chip" data-filter="photos">With photos</button>
</nav>
<ul class="reviews" id="reviews"></ul>
<button class="more" type="button" id="more">Load more reviews</button>
</main>
<script src="script.js"></script>
</body>
</html>Reviews Feed
The page diners see before booking. Aggregate score (4.7) with total review count, a 5-star distribution bar chart (rows for each star), filter chips (All · Recent · 5★ only · With photos), then a list of reviews — each with avatar, name, date, star rating, dish tags, body, helpful counter, and one owner reply collapsed/expanded under it.