Logo Grid
Client logos in a hairline grid, grey and all the same weight. A cell can link to its case study: on hover or focus the logo rolls up and the result rises in. Tiers for sponsors.
View codeReviews with their summary: the average rolls up, the stars fill to the decimal, five bars fill in turn. Click a bar to filter, the reviews glide into place. Read more, and a one-line variant.
With your AI
Say it to your AI. It gets the exact code below, not its best guess, and knows where it goes.
Add the Kodza Review Summary to my page.
claude mcp add -t http kodza https://kodza.dev/api/mcp
<div class="kodza-review-summary kodza-review-summary--inline" data-kodza-review-summary>
<span class="kodza-review-summary__stars" style="--kz-score: 4.7" aria-hidden="true"><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span></span>
<span class="kodza-review-summary__value">4.7</span><span class="kodza-review-summary__max"> out of 5</span>
<span class="kodza-review-summary__count">1,284 reviews</span>
</div>
<section class="kodza-review-summary" data-kodza-review-summary>
<div class="kodza-review-summary__inner">
<div class="kodza-review-summary__head">
<p class="kodza-review-summary__eyebrow">Reviews</p>
<h2 class="kodza-review-summary__title">We kept the one-star reviews.</h2>
</div>
<div class="kodza-review-summary__body">
<div class="kodza-review-summary__summary">
<div class="kodza-review-summary__overall">
<p class="kodza-review-summary__score"><span class="kodza-review-summary__value">4.7</span><span class="kodza-review-summary__max"> out of 5</span></p>
<div class="kodza-review-summary__meta">
<span class="kodza-review-summary__stars" style="--kz-score: 4.7" aria-hidden="true"><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span></span>
<p class="kodza-review-summary__count">1,284 reviews</p>
</div>
</div>
<ul class="kodza-review-summary__bars" aria-label="Show reviews by rating">
<li>
<button class="kodza-review-summary__bar" type="button" aria-pressed="false" data-rating="5" style="--kz-share: 0.8">
<span class="kodza-review-summary__bar-label">5 stars</span>
<span class="kodza-review-summary__track" aria-hidden="true"><span class="kodza-review-summary__fill"></span></span>
<span class="kodza-review-summary__share">80%</span>
</button>
</li>
<li>
<button class="kodza-review-summary__bar" type="button" aria-pressed="false" data-rating="4" style="--kz-share: 0.13">
<span class="kodza-review-summary__bar-label">4 stars</span>
<span class="kodza-review-summary__track" aria-hidden="true"><span class="kodza-review-summary__fill"></span></span>
<span class="kodza-review-summary__share">13%</span>
</button>
</li>
<li>
<button class="kodza-review-summary__bar" type="button" aria-pressed="false" data-rating="3" style="--kz-share: 0.04">
<span class="kodza-review-summary__bar-label">3 stars</span>
<span class="kodza-review-summary__track" aria-hidden="true"><span class="kodza-review-summary__fill"></span></span>
<span class="kodza-review-summary__share">4%</span>
</button>
</li>
<li>
<button class="kodza-review-summary__bar" type="button" aria-pressed="false" data-rating="2" style="--kz-share: 0.02">
<span class="kodza-review-summary__bar-label">2 stars</span>
<span class="kodza-review-summary__track" aria-hidden="true"><span class="kodza-review-summary__fill"></span></span>
<span class="kodza-review-summary__share">2%</span>
</button>
</li>
<li>
<button class="kodza-review-summary__bar" type="button" aria-pressed="false" data-rating="1" style="--kz-share: 0.01">
<span class="kodza-review-summary__bar-label">1 star</span>
<span class="kodza-review-summary__track" aria-hidden="true"><span class="kodza-review-summary__fill"></span></span>
<span class="kodza-review-summary__share">1%</span>
</button>
</li>
</ul>
</div>
<div class="kodza-review-summary__list">
<div class="kodza-review-summary__toolbar">
<p class="kodza-review-summary__status"><span class="kodza-review-summary__status-label">All reviews</span> <span class="kodza-review-summary__status-count">8</span></p>
<button class="kodza-review-summary__clear" type="button">Show all</button>
</div>
<div class="kodza-review-summary__reviews" data-more="Read more" data-less="Read less">
<figure class="kodza-review-summary__review" data-rating="5">
<div class="kodza-review-summary__review-top">
<span class="kodza-review-summary__stars kodza-review-summary__stars--small" style="--kz-score: 5" role="img" aria-label="5 out of 5 stars"><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span></span>
<time class="kodza-review-summary__date" datetime="2026-11-02">2 Nov 2026</time>
</div>
<p class="kodza-review-summary__review-title">Two winters of bike commuting, still dry</p>
<blockquote class="kodza-review-summary__quote"><p>Rode to work every day from November to March, rain most of it. The laptop came out dry every time. The roll top looks fussy in the photos; in real life it takes two seconds.</p></blockquote>
<figcaption class="kodza-review-summary__who">
<span class="kodza-review-summary__face" aria-hidden="true">IC</span>
<span class="kodza-review-summary__name">Inès Carvalho</span>
<span class="kodza-review-summary__role">Verified buyer · Lisbon</span>
</figcaption>
</figure>
<figure class="kodza-review-summary__review" data-rating="4">
<div class="kodza-review-summary__review-top">
<span class="kodza-review-summary__stars kodza-review-summary__stars--small" style="--kz-score: 4" role="img" aria-label="4 out of 5 stars"><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span></span>
<time class="kodza-review-summary__date" datetime="2026-10-29">29 Oct 2026</time>
</div>
<p class="kodza-review-summary__review-title">Great bag, tight bottle pocket</p>
<blockquote class="kodza-review-summary__quote"><p>A 16-inch laptop, a lunch box and a jumper, with room to spare. One star off: a litre bottle only goes in the side pocket if you push.</p></blockquote>
<figcaption class="kodza-review-summary__who">
<span class="kodza-review-summary__face" aria-hidden="true">PV</span>
<span class="kodza-review-summary__name">Pieter de Vries</span>
<span class="kodza-review-summary__role">Verified buyer · Utrecht</span>
</figcaption>
</figure>
<figure class="kodza-review-summary__review" data-rating="5">
<div class="kodza-review-summary__review-top">
<span class="kodza-review-summary__stars kodza-review-summary__stars--small" style="--kz-score: 5" role="img" aria-label="5 out of 5 stars"><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span></span>
<time class="kodza-review-summary__date" datetime="2026-10-24">24 Oct 2026</time>
</div>
<p class="kodza-review-summary__review-title">The only bag I didn’t send back</p>
<blockquote class="kodza-review-summary__quote"><p>I ordered four backpacks to compare and returned three. This one won on the boring things: the straps don’t dig in after an hour, the back panel doesn’t turn into a sauna, and the zips run without catching the lining.</p><p>The laptop sleeve stops a few centimetres above the bottom, so dropping the bag on a station floor doesn’t land on the corner of the computer. Nobody mentions it in the description. It’s the reason I kept it.</p></blockquote>
<figcaption class="kodza-review-summary__who">
<span class="kodza-review-summary__face" aria-hidden="true">SL</span>
<span class="kodza-review-summary__name">Sofia Lindqvist</span>
<span class="kodza-review-summary__role">Verified buyer · Malmö</span>
</figcaption>
</figure>
<figure class="kodza-review-summary__review" data-rating="3">
<div class="kodza-review-summary__review-top">
<span class="kodza-review-summary__stars kodza-review-summary__stars--small" style="--kz-score: 3" role="img" aria-label="3 out of 5 stars"><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span></span>
<time class="kodza-review-summary__date" datetime="2026-10-20">20 Oct 2026</time>
</div>
<p class="kodza-review-summary__review-title">Good, but the straps creak</p>
<blockquote class="kodza-review-summary__quote"><p>Comfortable and well made. Full, the buckles on the shoulder straps creak at every step. A bit of wax fixed it. I shouldn’t have had to.</p></blockquote>
<figcaption class="kodza-review-summary__who">
<span class="kodza-review-summary__face" aria-hidden="true">MD</span>
<span class="kodza-review-summary__name">Malik Diallo</span>
<span class="kodza-review-summary__role">Verified buyer · Lille</span>
</figcaption>
</figure>
<figure class="kodza-review-summary__review" data-rating="5">
<div class="kodza-review-summary__review-top">
<span class="kodza-review-summary__stars kodza-review-summary__stars--small" style="--kz-score: 5" role="img" aria-label="5 out of 5 stars"><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span></span>
<time class="kodza-review-summary__date" datetime="2026-10-18">18 Oct 2026</time>
</div>
<p class="kodza-review-summary__review-title">Fits under the seat, no questions asked</p>
<blockquote class="kodza-review-summary__quote"><p>Two low-cost flights, both times under the seat in front. Three days of clothes and a camera.</p></blockquote>
<figcaption class="kodza-review-summary__who">
<span class="kodza-review-summary__face" aria-hidden="true">HK</span>
<span class="kodza-review-summary__name">Hana Kobayashi</span>
<span class="kodza-review-summary__role">Verified buyer · Geneva</span>
</figcaption>
</figure>
<figure class="kodza-review-summary__review" data-rating="2">
<div class="kodza-review-summary__review-top">
<span class="kodza-review-summary__stars kodza-review-summary__stars--small" style="--kz-score: 2" role="img" aria-label="2 out of 5 stars"><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span></span>
<time class="kodza-review-summary__date" datetime="2026-10-15">15 Oct 2026</time>
</div>
<p class="kodza-review-summary__review-title">The zip split after three months</p>
<blockquote class="kodza-review-summary__quote"><p>The main zip split at the corner after three months of daily use. Support answered in a day, sent a new bag in four, and didn’t ask for the broken one back first.</p><p>Two stars for the zip, which shouldn’t break. It would have been one if support hadn’t been that quick. Six weeks on, the new one is fine, and they say they changed zip supplier in September.</p></blockquote>
<figcaption class="kodza-review-summary__who">
<span class="kodza-review-summary__face" aria-hidden="true">BM</span>
<span class="kodza-review-summary__name">Baptiste Morel</span>
<span class="kodza-review-summary__role">Verified buyer · Caen</span>
</figcaption>
</figure>
<figure class="kodza-review-summary__review" data-rating="4">
<div class="kodza-review-summary__review-top">
<span class="kodza-review-summary__stars kodza-review-summary__stars--small" style="--kz-score: 4" role="img" aria-label="4 out of 5 stars"><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span></span>
<time class="kodza-review-summary__date" datetime="2026-10-09">9 Oct 2026</time>
</div>
<p class="kodza-review-summary__review-title">Better in grey than in the photos</p>
<blockquote class="kodza-review-summary__quote"><p>The grey reads warmer in daylight than on screen. The bag itself does what it says. I’d like one more pocket inside.</p></blockquote>
<figcaption class="kodza-review-summary__who">
<span class="kodza-review-summary__face" aria-hidden="true">NA</span>
<span class="kodza-review-summary__name">Noor Al-Amin</span>
<span class="kodza-review-summary__role">Verified buyer · Ghent</span>
</figcaption>
</figure>
<figure class="kodza-review-summary__review" data-rating="1">
<div class="kodza-review-summary__review-top">
<span class="kodza-review-summary__stars kodza-review-summary__stars--small" style="--kz-score: 1" role="img" aria-label="1 out of 5 stars"><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span><span class="kodza-review-summary__star"></span></span>
<time class="kodza-review-summary__date" datetime="2026-10-03">3 Oct 2026</time>
</div>
<p class="kodza-review-summary__review-title">Twelve days for a three-day delivery</p>
<blockquote class="kodza-review-summary__quote"><p>Nothing wrong with the bag. It took twelve days to arrive instead of three, and the tracking said “in transit” the whole time.</p></blockquote>
<figcaption class="kodza-review-summary__who">
<span class="kodza-review-summary__face" aria-hidden="true">LM</span>
<span class="kodza-review-summary__name">Lucía Méndez</span>
<span class="kodza-review-summary__role">Verified buyer · Valencia</span>
</figcaption>
</figure>
<p class="kodza-review-summary__empty" hidden>No review with this rating on this page yet.</p>
</div>
</div>
</div>
</div>
</section>
/* Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license */
/* Kodza — Review Summary
What the customers say, at a glance: the average rolls up to its value, the stars fill to the
decimal (4.7: the last one at 70%) and five bars fill in a cascade as the summary comes into
view. Click a bar and the reviews below are filtered: those that leave fade out, those that stay
glide to their new place. A long review folds behind "Read more". The --inline variant is one
line (stars, average, number of reviews) to put under a buy button.
Each row sets its share with style="--kz-share: 0.8", each set of stars its score with
style="--kz-score: 4.7". Everything else is tuned with the --kz-review-summary-* variables. */
.kodza-review-summary {
--kz-review-summary-ink: #0a0a0a;
--kz-review-summary-dim: rgba(10, 10, 10, 0.64);
--kz-review-summary-card: #ffffff;
--kz-review-summary-line: rgba(10, 10, 10, 0.1);
--kz-review-summary-rail: rgba(10, 10, 10, 0.1); /* empty stars and bar tracks */
--kz-review-summary-muted: rgba(10, 10, 10, 0.26); /* the bars not chosen while filtered */
--kz-review-summary-face: #e6e6e3;
--kz-review-summary-hover: rgba(10, 10, 10, 0.04);
--kz-review-summary-pressed: rgba(10, 10, 10, 0.07);
--kz-review-summary-bg: transparent;
--kz-review-summary-star: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 2.6l2.9 5.9 6.5.9-4.7 4.6 1.1 6.5L12 17.4l-5.8 3.1 1.1-6.5L2.6 9.4l6.5-.9z' stroke='black' stroke-width='1.2' stroke-linejoin='round'/%3E%3C/svg%3E");
--kz-review-summary-lines: 5; /* lines shown before "Read more" */
--kz-review-summary-columns: minmax(280px, 340px) minmax(0, 1fr); /* summary, reviews */
--kz-review-summary-card-min: 280px; /* the narrowest review card */
--kz-review-summary-sticky: 24px; /* where the summary stays while the reviews scroll */
--kz-review-summary-width: 1200px;
--kz-review-summary-pad-y: clamp(64px, 8vw, 112px);
--kz-review-summary-gutter: clamp(16px, 4vw, 40px);
--kz-review-summary-radius: calc(16px * var(--kz-radius-scale, 1));
--kz-review-summary-mono: var(--kz-font-mono, "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace);
--kz-review-summary-ease: cubic-bezier(0.22, 1, 0.36, 1);
container: kodza-review-summary / inline-size;
box-sizing: border-box;
width: 100%;
padding-block: var(--kz-review-summary-pad-y);
padding-left: max(var(--kz-review-summary-gutter), env(safe-area-inset-left, 0px));
padding-right: max(var(--kz-review-summary-gutter), env(safe-area-inset-right, 0px));
background: var(--kz-review-summary-bg);
color: var(--kz-review-summary-ink);
}
.kodza-review-summary__inner {
max-width: var(--kz-review-summary-width);
margin: 0 auto;
}
/* The header */
.kodza-review-summary__head {
margin-bottom: clamp(28px, 3.6vw, 48px);
}
.kodza-review-summary__eyebrow {
margin: 0 0 18px;
font-family: var(--kz-font-label, var(--kz-review-summary-mono));
font-size: var(--kz-font-label-size, 12.5px);
font-weight: var(--kz-font-label-weight, 500);
line-height: var(--kz-font-label-leading, inherit);
letter-spacing: var(--kz-font-label-tracking, 0.02em);
text-transform: var(--kz-font-label-case, inherit);
color: var(--kz-review-summary-dim);
}
.kodza-review-summary__title {
margin: 0;
font-family: var(--kz-font-display, inherit);
font-size: calc(clamp(32px, 4.6vw, 56px) * var(--kz-font-display-scale, 1));
font-weight: var(--kz-font-display-weight, 600);
line-height: 1.02;
letter-spacing: var(--kz-font-display-tracking, -0.045em);
text-transform: var(--kz-font-display-case, none);
text-wrap: balance;
color: inherit;
}
/* The summary on the left, the reviews on the right; one column on a tablet and a phone */
.kodza-review-summary__body {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 16px;
align-items: start;
}
@container kodza-review-summary (min-width: 860px) {
.kodza-review-summary__body {
grid-template-columns: var(--kz-review-summary-columns);
gap: 32px;
}
.kodza-review-summary__summary {
position: sticky;
top: var(--kz-review-summary-sticky);
}
}
/* The summary card */
.kodza-review-summary__summary {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 24px;
padding: 28px;
border: 1px solid var(--kz-review-summary-line);
border-radius: var(--kz-review-summary-radius);
background: var(--kz-review-summary-card);
}
.kodza-review-summary__overall {
display: flex;
align-items: center;
gap: 18px;
}
.kodza-review-summary__score {
margin: 0;
font-family: var(--kz-font-display, inherit);
font-size: calc(64px * var(--kz-font-display-scale, 1));
font-weight: var(--kz-font-display-weight, 600);
line-height: 1;
letter-spacing: var(--kz-font-display-tracking, -0.05em);
text-transform: var(--kz-font-display-case, none);
font-variant-numeric: tabular-nums;
}
.kodza-review-summary__meta {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.kodza-review-summary__count {
margin: 0;
font-family: var(--kz-font-label, var(--kz-review-summary-mono));
font-size: var(--kz-font-label-size, 12.5px);
font-weight: var(--kz-font-label-weight, 400);
line-height: var(--kz-font-label-leading, 1.4);
letter-spacing: var(--kz-font-label-tracking, 0);
text-transform: var(--kz-font-label-case, inherit);
color: var(--kz-review-summary-dim);
white-space: nowrap;
}
/* Stars: an empty star, and over it the ink, scaled to the decimal */
.kodza-review-summary__stars {
--kz-review-summary-star-size: 20px;
display: inline-flex;
gap: 2px;
flex: none;
}
.kodza-review-summary__stars--small {
--kz-review-summary-star-size: 14px;
gap: 1px;
}
.kodza-review-summary__star {
--kz-review-summary-i: 0;
--kz-review-summary-fill: clamp(0, calc(var(--kz-score, 0) - var(--kz-review-summary-i)), 1);
position: relative;
width: var(--kz-review-summary-star-size);
height: var(--kz-review-summary-star-size);
overflow: hidden;
background: var(--kz-review-summary-rail);
-webkit-mask: var(--kz-review-summary-star) center / contain no-repeat;
mask: var(--kz-review-summary-star) center / contain no-repeat;
}
.kodza-review-summary__star:nth-child(2) { --kz-review-summary-i: 1; }
.kodza-review-summary__star:nth-child(3) { --kz-review-summary-i: 2; }
.kodza-review-summary__star:nth-child(4) { --kz-review-summary-i: 3; }
.kodza-review-summary__star:nth-child(5) { --kz-review-summary-i: 4; }
.kodza-review-summary__star::before {
content: "";
position: absolute;
inset: 0;
background: var(--kz-review-summary-ink);
transform-origin: left center;
scale: var(--kz-review-summary-fill) 1;
}
/* The share of each rating, as a button that filters the reviews */
.kodza-review-summary__bars {
display: grid;
gap: 4px;
margin: 0;
padding: 20px 0 0;
border-top: 1px solid var(--kz-review-summary-line);
list-style: none;
}
.kodza-review-summary__bars > li {
--kz-review-summary-i: 0;
margin: 0;
padding: 0;
}
.kodza-review-summary__bars > li:nth-child(2) { --kz-review-summary-i: 1; }
.kodza-review-summary__bars > li:nth-child(3) { --kz-review-summary-i: 2; }
.kodza-review-summary__bars > li:nth-child(4) { --kz-review-summary-i: 3; }
.kodza-review-summary__bars > li:nth-child(5) { --kz-review-summary-i: 4; }
.kodza-review-summary__bar {
position: relative;
isolation: isolate;
display: grid;
grid-template-columns: 54px minmax(0, 1fr) 36px;
align-items: center;
column-gap: 14px;
width: 100%;
height: 32px;
margin: 0;
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
/* The hover and pressed background reaches past the row, so the labels stay in line with the card */
.kodza-review-summary__bar::before {
content: "";
position: absolute;
z-index: -1;
inset: 0 -12px;
border-radius: calc(10px * var(--kz-radius-scale, 1));
background: transparent;
transition: background-color calc(0.2s * var(--kz-motion-scale, 1));
}
@media (hover: hover) {
.kodza-review-summary__bar:hover::before {
background: var(--kz-review-summary-hover);
}
}
.kodza-review-summary__bar[aria-pressed="true"]::before {
background: var(--kz-review-summary-pressed);
}
.kodza-review-summary__bar:focus-visible {
outline: none;
}
.kodza-review-summary__bar:focus-visible::before {
outline: 2px solid var(--kz-review-summary-ink);
outline-offset: 0;
}
.kodza-review-summary__bar-label,
.kodza-review-summary__share {
font-family: var(--kz-font-label, var(--kz-review-summary-mono));
font-size: var(--kz-font-label-size, 12.5px);
font-weight: var(--kz-font-label-weight, 500);
line-height: var(--kz-font-label-leading, 1);
letter-spacing: var(--kz-font-label-tracking, 0);
text-transform: var(--kz-font-label-case, inherit);
white-space: nowrap;
}
.kodza-review-summary__share {
font-weight: var(--kz-font-label-weight, 400);
font-variant-numeric: tabular-nums;
text-align: right;
color: var(--kz-review-summary-dim);
}
.kodza-review-summary__track {
position: relative;
height: 8px;
overflow: hidden;
border-radius: var(--kz-radius-pill, 999px);
background: var(--kz-review-summary-rail);
}
.kodza-review-summary__fill {
position: absolute;
inset: 0;
background: var(--kz-review-summary-ink);
clip-path: inset(0 calc(100% - var(--kz-share, 0) * 100%) 0 0 round var(--kz-radius-pill, 999px));
transition: background-color calc(0.3s * var(--kz-motion-scale, 1));
}
.kodza-review-summary.is-filtered .kodza-review-summary__bar[aria-pressed="false"] .kodza-review-summary__fill {
background: var(--kz-review-summary-muted);
}
/* The entrance: empty until the summary comes into view, then the stars and the bars fill in turn */
.kodza-review-summary.is-armed:not(.is-in) .kodza-review-summary__meta .kodza-review-summary__star::before,
.kodza-review-summary--inline.is-armed:not(.is-in) .kodza-review-summary__star::before {
scale: 0 1;
}
.kodza-review-summary.is-armed:not(.is-in) .kodza-review-summary__fill {
clip-path: inset(0 100% 0 0 round var(--kz-radius-pill, 999px));
}
.kodza-review-summary.is-in .kodza-review-summary__meta .kodza-review-summary__star::before,
.kodza-review-summary--inline.is-in .kodza-review-summary__star::before {
transition: scale calc(0.7s * var(--kz-motion-scale, 1)) var(--kz-review-summary-ease) calc((0.15s + var(--kz-review-summary-i) * 0.11s) * var(--kz-motion-scale, 1));
}
.kodza-review-summary.is-in .kodza-review-summary__fill {
transition:
clip-path calc(0.9s * var(--kz-motion-scale, 1)) var(--kz-review-summary-ease) calc((0.3s + var(--kz-review-summary-i) * 0.09s) * var(--kz-motion-scale, 1)),
background-color calc(0.3s * var(--kz-motion-scale, 1));
}
/* The rolling average (the engine of Counter): a column of digits per digit */
.kodza-review-summary__value {
display: inline-flex;
}
.kodza-review-summary__digit {
position: relative;
display: inline-block;
height: 1.1em;
overflow: hidden;
overflow: clip;
overflow-clip-margin: 0.1em;
}
.kodza-review-summary__fit {
visibility: hidden;
}
.kodza-review-summary__strip {
position: absolute;
top: 0;
left: 50%;
translate: -50% 0;
text-align: center;
}
.kodza-review-summary__strip > span {
display: block;
height: 1.1em;
line-height: 1.1;
}
.kodza-review-summary__char {
white-space: pre;
}
.kodza-review-summary__sr,
.kodza-review-summary__max {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
}
/* The reviews */
.kodza-review-summary__list {
min-width: 0;
}
.kodza-review-summary__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 36px;
margin-bottom: 16px;
}
.kodza-review-summary__status {
display: flex;
align-items: baseline;
gap: 10px;
margin: 0;
font-size: 15px;
font-weight: 500;
line-height: 1.2;
letter-spacing: -0.01em;
white-space: nowrap;
}
.kodza-review-summary__status-count {
font-family: var(--kz-font-label, var(--kz-review-summary-mono));
font-size: var(--kz-font-label-size, 12.5px);
font-weight: var(--kz-font-label-weight, 400);
letter-spacing: var(--kz-font-label-tracking, 0);
text-transform: var(--kz-font-label-case, inherit);
font-variant-numeric: tabular-nums;
color: var(--kz-review-summary-dim);
}
/* A word that changes rolls: the old one goes up, the new one comes from below */
.kodza-review-summary__slot {
display: inline-grid;
overflow: hidden;
overflow: clip;
overflow-clip-margin: 0.15em;
vertical-align: bottom;
}
.kodza-review-summary__slot > span {
grid-area: 1 / 1;
white-space: nowrap;
}
.kodza-review-summary__clear {
display: inline-flex;
align-items: center;
height: 32px;
margin: 0;
padding: 0 14px;
border: 1px solid var(--kz-review-summary-line);
border-radius: var(--kz-radius-pill, 999px);
background: var(--kz-review-summary-card);
color: inherit;
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 13px);
font-weight: var(--kz-font-label-weight, 500);
line-height: var(--kz-font-label-leading, 1);
letter-spacing: var(--kz-font-label-tracking, 0);
text-transform: var(--kz-font-label-case, inherit);
white-space: nowrap;
cursor: pointer;
transition:
opacity calc(0.25s * var(--kz-motion-scale, 1)) var(--kz-review-summary-ease),
translate calc(0.25s * var(--kz-motion-scale, 1)) var(--kz-review-summary-ease),
border-color calc(0.2s * var(--kz-motion-scale, 1)),
visibility 0s;
}
.kodza-review-summary:not(.is-filtered) .kodza-review-summary__clear {
opacity: 0;
translate: 0 6px;
visibility: hidden;
transition:
opacity calc(0.2s * var(--kz-motion-scale, 1)),
translate calc(0.2s * var(--kz-motion-scale, 1)),
visibility 0s calc(0.2s * var(--kz-motion-scale, 1));
}
@media (hover: hover) {
.kodza-review-summary__clear:hover {
border-color: var(--kz-review-summary-ink);
}
}
.kodza-review-summary__clear:focus-visible {
outline: 2px solid var(--kz-review-summary-ink);
outline-offset: 3px;
}
.kodza-review-summary__reviews {
position: relative;
isolation: isolate; /* a review on its way out goes behind the others, never behind the page */
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--kz-review-summary-card-min)), 1fr));
gap: 16px;
align-items: start;
}
/* While the list takes its new height, what is past it stays hidden */
.kodza-review-summary__reviews.is-sizing {
overflow: hidden;
overflow: clip;
}
.kodza-review-summary__review {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 14px;
margin: 0;
padding: 28px;
border: 1px solid var(--kz-review-summary-line);
border-radius: var(--kz-review-summary-radius);
background: var(--kz-review-summary-card);
}
.kodza-review-summary__review[hidden] {
display: none;
}
.kodza-review-summary__review-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.kodza-review-summary__date,
.kodza-review-summary__role {
font-family: var(--kz-font-label, var(--kz-review-summary-mono));
font-size: var(--kz-font-label-size, 12px);
font-weight: var(--kz-font-label-weight, 400);
line-height: var(--kz-font-label-leading, 1.4);
letter-spacing: var(--kz-font-label-tracking, 0);
text-transform: var(--kz-font-label-case, inherit);
color: var(--kz-review-summary-dim);
}
.kodza-review-summary__date {
white-space: nowrap;
}
.kodza-review-summary__review-title {
margin: 4px 0 -4px;
font-size: 16px;
font-weight: 600;
line-height: 1.35;
letter-spacing: -0.015em;
text-wrap: balance;
}
.kodza-review-summary__quote {
margin: 0;
padding: 0;
border: 0;
font-size: 15px;
line-height: 1.55;
text-wrap: pretty;
}
.kodza-review-summary__quote p {
margin: 0;
}
.kodza-review-summary__quote p + p {
margin-top: 0.8em;
}
/* "Read more": a long review stops after a few lines and fades out; open, the fade slides away
below the text (the mask grows) while the height follows */
.kodza-review-summary__quote.is-long {
max-height: calc(var(--kz-review-summary-lines) * 1.55em);
overflow: hidden;
-webkit-mask: linear-gradient(to bottom, #000 calc(100% - 1.55em), transparent) top / 100% 100% no-repeat;
mask: linear-gradient(to bottom, #000 calc(100% - 1.55em), transparent) top / 100% 100% no-repeat;
transition:
-webkit-mask-size calc(0.5s * var(--kz-motion-scale, 1)) var(--kz-review-summary-ease),
mask-size calc(0.5s * var(--kz-motion-scale, 1)) var(--kz-review-summary-ease);
}
.kodza-review-summary__quote.is-long.is-open {
max-height: none;
-webkit-mask-size: 100% 400%;
mask-size: 100% 400%;
}
.kodza-review-summary__quote.is-long.is-closing {
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
}
.kodza-review-summary__more {
align-self: flex-start;
display: inline-grid;
margin: -4px 0 0;
padding: 2px 0;
overflow: hidden;
overflow: clip;
overflow-clip-margin: 4px;
border: 0;
background: none;
color: inherit;
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 13px);
font-weight: var(--kz-font-label-weight, 500);
line-height: var(--kz-font-label-leading, 1.2);
letter-spacing: var(--kz-font-label-tracking, 0);
text-transform: var(--kz-font-label-case, inherit);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 3px;
cursor: pointer;
}
.kodza-review-summary__more > span {
grid-area: 1 / 1;
transition:
translate calc(0.38s * var(--kz-motion-scale, 1)) var(--kz-review-summary-ease),
opacity calc(0.38s * var(--kz-motion-scale, 1)) var(--kz-review-summary-ease);
}
.kodza-review-summary__more > span:last-child,
.kodza-review-summary__more.is-open > span:first-child {
translate: 0 -110%;
opacity: 0;
}
.kodza-review-summary__more > span:last-child {
translate: 0 110%;
}
.kodza-review-summary__more.is-open > span:last-child {
translate: 0 0;
opacity: 1;
}
.kodza-review-summary__more:focus-visible {
outline: 2px solid var(--kz-review-summary-ink);
outline-offset: 3px;
border-radius: calc(4px * var(--kz-radius-scale, 1));
}
.kodza-review-summary__who {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
grid-template-rows: auto auto;
align-items: center;
column-gap: 12px;
row-gap: 3px;
margin-top: 8px;
}
.kodza-review-summary__face {
grid-row: 1 / 3;
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: var(--kz-radius-pill, 999px);
background: var(--kz-review-summary-face);
object-fit: cover;
font-size: 13px;
font-weight: 600;
line-height: 1;
}
.kodza-review-summary__name {
align-self: end;
font-size: 14px;
font-weight: 500;
line-height: 1.3;
}
.kodza-review-summary__role {
align-self: start;
}
.kodza-review-summary__empty {
grid-column: 1 / -1;
margin: 0;
padding: 28px;
border: 1px dashed var(--kz-review-summary-line);
border-radius: var(--kz-review-summary-radius);
font-size: 15px;
line-height: 1.55;
color: var(--kz-review-summary-dim);
}
.kodza-review-summary__empty[hidden] {
display: none;
}
/* Between 560px and 860px, the average on the left of the summary and the bars on its right */
@container kodza-review-summary (min-width: 560px) and (max-width: 859.98px) {
.kodza-review-summary__summary {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 32px;
}
.kodza-review-summary__overall {
flex-direction: column;
align-items: flex-start;
gap: 14px;
}
.kodza-review-summary__bars {
padding: 0 0 0 32px;
border-top: 0;
border-left: 1px solid var(--kz-review-summary-line);
}
}
@container kodza-review-summary (max-width: 559.98px) {
.kodza-review-summary__summary,
.kodza-review-summary__review {
padding: 24px;
}
}
/* The one-line variant, under a buy button: stars, average, number of reviews */
.kodza-review-summary--inline {
container-type: normal;
display: flex;
align-items: center;
gap: 8px;
width: fit-content;
max-width: 100%;
padding: 0;
}
.kodza-review-summary--inline .kodza-review-summary__stars {
--kz-review-summary-star-size: 16px;
gap: 1px;
}
.kodza-review-summary--inline > .kodza-review-summary__value {
font-size: 14px;
font-weight: 600;
line-height: 1.1;
letter-spacing: -0.01em;
font-variant-numeric: tabular-nums;
}
.kodza-review-summary--inline > .kodza-review-summary__count {
font-size: var(--kz-font-label-size, 12.5px);
}
@media (prefers-reduced-motion: reduce) {
.kodza-review-summary *,
.kodza-review-summary *::before,
.kodza-review-summary *::after {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
animation-duration: 0.01ms !important;
}
}
/* kodza-guard e8ff9acde7ea — generated by Kodza, do not edit.
Theme guard: keeps a site's own CSS (WordPress themes, Bootstrap, CSS resets…) off this
component. :not(#_) is always true and weighs as much as an id, so these lines win over
theme rules made of classes and tags. Customise the component through its --kz-* variables. */
/* What the browser gives, where the component sets it only in some states: at the lowest weight, so its own rules below put it back */
:where(.kodza-review-summary__count):not(#_),
:where(.kodza-review-summary__eyebrow):not(#_),
:where(.kodza-review-summary__title):not(#_),
:where(.kodza-review-summary__score):not(#_),
:where(.kodza-review-summary__status):not(#_),
:where(.kodza-review-summary__review):not(#_),
:where(.kodza-review-summary__date):not(#_),
:where(.kodza-review-summary__review-title):not(#_),
:where(.kodza-review-summary__who):not(#_),
:where(.kodza-review-summary__empty):not(#_),
:where(.kodza-review-summary__bars li:where(:is(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))):not(.kodza-review-summary__bars :not(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))) *))):not(#_),
:where(.kodza-review-summary__quote p:where(:is(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))):not(.kodza-review-summary__quote :not(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))) *))):not(#_) {
transition-duration: revert;
transition-delay: revert;
}
:where(.kodza-review-summary__bars):not(#_) {
border-left-width: revert;
border-left-style: revert;
border-left-color: revert;
transition-duration: revert;
transition-delay: revert;
}
:where(.kodza-review-summary__bar):not(#_) {
outline: revert;
transition-duration: revert;
transition-delay: revert;
}
:where(.kodza-review-summary__clear):not(#_) {
outline: revert;
outline-offset: revert;
}
:where(.kodza-review-summary__quote):not(#_) {
transition: revert;
max-height: revert;
}
:where(.kodza-review-summary__more):not(#_) {
border-radius: revert;
outline: revert;
outline-offset: revert;
transition-duration: revert;
transition-delay: revert;
}
:where(.kodza-review-summary__empty):not(#_):where(:not([hidden])) {
display: revert;
}
:where(.kodza-review-summary__empty):not(#_):where([hidden]) {
display: none;
}
/* What the browser gives, on everything the component leaves to it */
.kodza-review-summary__stars:not(#_),
.kodza-review-summary__star:not(#_),
.kodza-review-summary__value:not(#_),
.kodza-review-summary__digit:not(#_),
.kodza-review-summary__fit:not(#_),
.kodza-review-summary__strip:not(#_),
.kodza-review-summary__char:not(#_),
.kodza-review-summary__sr:not(#_),
.kodza-review-summary__max:not(#_),
.kodza-review-summary__inner:not(#_),
.kodza-review-summary__head:not(#_),
.kodza-review-summary__body:not(#_),
.kodza-review-summary__overall:not(#_),
.kodza-review-summary__meta:not(#_),
.kodza-review-summary__bar-label:not(#_),
.kodza-review-summary__track:not(#_),
.kodza-review-summary__fill:not(#_),
.kodza-review-summary__share:not(#_),
.kodza-review-summary__list:not(#_),
.kodza-review-summary__toolbar:not(#_),
.kodza-review-summary__status-label:not(#_),
.kodza-review-summary__status-count:not(#_),
.kodza-review-summary__reviews:not(#_),
.kodza-review-summary__review-top:not(#_),
.kodza-review-summary__face:not(#_),
.kodza-review-summary__name:not(#_),
.kodza-review-summary__role:not(#_),
.kodza-review-summary__strip span:where(:not([class*="kodza-"])):not(#_),
.kodza-review-summary__status-label span:where(:not([class*="kodza-"])):not(#_),
.kodza-review-summary__status-count span:where(:not([class*="kodza-"])):not(#_),
.kodza-review-summary__more span:where(:not([class*="kodza-"])):not(#_) {
box-sizing: revert;
}
.kodza-review-summary__count:not(#_),
.kodza-review-summary__eyebrow:not(#_),
.kodza-review-summary__title:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-style: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
transition-property: revert;
transition-timing-function: revert;
}
.kodza-review-summary__score:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-style: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
transition-property: revert;
transition-timing-function: revert;
}
.kodza-review-summary__bars:not(#_) {
border-radius: revert;
outline: revert;
text-decoration: revert;
box-sizing: revert;
float: revert;
position: revert;
border-right-width: revert;
border-bottom-width: revert;
border-right-style: revert;
border-bottom-style: revert;
border-right-color: revert;
border-bottom-color: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
transition-property: revert;
transition-timing-function: revert;
}
.kodza-review-summary__bar:not(#_) {
border-radius: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
transition-property: revert;
transition-timing-function: revert;
white-space: revert;
min-height: revert;
}
.kodza-review-summary__status:not(#_),
.kodza-review-summary__review-title:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-style: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
transition-property: revert;
transition-timing-function: revert;
}
.kodza-review-summary__clear:not(#_) {
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
box-shadow: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-style: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
min-height: revert;
}
.kodza-review-summary__review:not(#_) {
outline: revert;
text-decoration: revert;
list-style: revert;
float: revert;
position: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
transition-property: revert;
transition-timing-function: revert;
}
.kodza-review-summary__date:not(#_) {
border: revert;
margin: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-style: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
transition-property: revert;
transition-timing-function: revert;
}
.kodza-review-summary__quote:not(#_) {
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
color: revert;
font-family: revert;
font-weight: revert;
font-style: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
}
.kodza-review-summary__who:not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
margin-right: revert;
margin-bottom: revert;
margin-left: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
transition-property: revert;
transition-timing-function: revert;
}
.kodza-review-summary__more:not(#_) {
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
box-shadow: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-style: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
transition-property: revert;
transition-timing-function: revert;
white-space: revert;
min-height: revert;
}
.kodza-review-summary__empty:not(#_) {
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
font-family: revert;
font-weight: revert;
font-style: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
transition-property: revert;
transition-timing-function: revert;
}
.kodza-review-summary__bars li:where(:is(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))):not(.kodza-review-summary__bars :not(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))) *)):not(#_) {
border: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
transition-property: revert;
transition-timing-function: revert;
}
.kodza-review-summary__quote p:where(:is(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))):not(.kodza-review-summary__quote :not(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))) *)):not(#_) {
border: revert;
padding: revert;
border-radius: revert;
outline: revert;
text-decoration: revert;
list-style: revert;
box-sizing: revert;
float: revert;
position: revert;
background-color: revert;
background-image: revert;
box-shadow: revert;
outline-offset: revert;
appearance: revert;
max-width: revert;
max-height: revert;
color: revert;
font-family: revert;
font-size: revert;
font-weight: revert;
font-style: revert;
line-height: revert;
letter-spacing: revert;
text-transform: revert;
text-underline-offset: revert;
text-align: revert;
text-indent: revert;
text-shadow: revert;
vertical-align: revert;
transition-property: revert;
transition-timing-function: revert;
}
.kodza-review-summary__count:not(#_):where(:not([hidden])),
.kodza-review-summary__eyebrow:not(#_):where(:not([hidden])),
.kodza-review-summary__title:not(#_):where(:not([hidden])),
.kodza-review-summary__score:not(#_):where(:not([hidden])),
.kodza-review-summary__date:not(#_):where(:not([hidden])),
.kodza-review-summary__review-title:not(#_):where(:not([hidden])),
.kodza-review-summary__quote:not(#_):where(:not([hidden])),
.kodza-review-summary__bars li:where(:is(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))):not(.kodza-review-summary__bars :not(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))) *)):not(#_):where(:not([hidden])),
.kodza-review-summary__quote p:where(:is(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))):not(.kodza-review-summary__quote :not(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))) *)):not(#_):where(:not([hidden])) {
display: revert;
}
.kodza-review-summary__count:not(#_):where([hidden]),
.kodza-review-summary__eyebrow:not(#_):where([hidden]),
.kodza-review-summary__title:not(#_):where([hidden]),
.kodza-review-summary__score:not(#_):where([hidden]),
.kodza-review-summary__date:not(#_):where([hidden]),
.kodza-review-summary__review-title:not(#_):where([hidden]),
.kodza-review-summary__quote:not(#_):where([hidden]),
.kodza-review-summary__bars li:where(:is(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))):not(.kodza-review-summary__bars :not(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))) *)):not(#_):where([hidden]),
.kodza-review-summary__quote p:where(:is(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))):not(.kodza-review-summary__quote :not(:not([class]), [class=""], [class^="is-"]:not([class*="kodza-"]):not([class*=" "]:not([class*=" is-"]))) *)):not(#_):where([hidden]) {
display: none;
}
.kodza-review-summary__star:not(#_)::before,
.kodza-review-summary__bar:not(#_)::before {
box-sizing: revert;
}
/* The component's own rules, one step stronger: real buttons and fields, what it sets on tags themes style (links, lists, headings, images…), and the root width (a theme may narrow it, never widen it) */
.kodza-review-summary__eyebrow:not(#_) {
margin: 0 0 18px;
font-family: var(--kz-font-label, var(--kz-review-summary-mono));
font-size: var(--kz-font-label-size, 12.5px);
font-weight: var(--kz-font-label-weight, 500);
line-height: var(--kz-font-label-leading, inherit);
letter-spacing: var(--kz-font-label-tracking, 0.02em);
text-transform: var(--kz-font-label-case, inherit);
color: var(--kz-review-summary-dim);
}
.kodza-review-summary__title:not(#_) {
margin: 0;
font-family: var(--kz-font-display, inherit);
font-size: calc(clamp(32px, 4.6vw, 56px) * var(--kz-font-display-scale, 1));
font-weight: var(--kz-font-display-weight, 600);
line-height: 1.02;
letter-spacing: var(--kz-font-display-tracking, -0.045em);
text-transform: var(--kz-font-display-case, none);
color: inherit;
}
.kodza-review-summary__score:not(#_) {
margin: 0;
font-family: var(--kz-font-display, inherit);
font-size: calc(64px * var(--kz-font-display-scale, 1));
font-weight: var(--kz-font-display-weight, 600);
line-height: 1;
letter-spacing: var(--kz-font-display-tracking, -0.05em);
text-transform: var(--kz-font-display-case, none);
}
.kodza-review-summary__count:where(p):not(#_) {
margin: 0;
font-family: var(--kz-font-label, var(--kz-review-summary-mono));
font-size: var(--kz-font-label-size, 12.5px);
font-weight: var(--kz-font-label-weight, 400);
line-height: var(--kz-font-label-leading, 1.4);
letter-spacing: var(--kz-font-label-tracking, 0);
text-transform: var(--kz-font-label-case, inherit);
color: var(--kz-review-summary-dim);
}
.kodza-review-summary__bars:not(#_) {
display: grid;
margin: 0;
padding: 20px 0 0;
border-top: 1px solid var(--kz-review-summary-line);
list-style: none;
}
.kodza-review-summary__bars > li:not(#_) {
margin: 0;
padding: 0;
}
.kodza-review-summary__bar:not(#_) {
position: relative;
isolation: isolate;
display: grid;
grid-template-columns: 54px minmax(0, 1fr) 36px;
align-items: center;
column-gap: 14px;
width: 100%;
height: 32px;
margin: 0;
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.kodza-review-summary__bar:not(#_)::before {
content: "";
position: absolute;
z-index: -1;
inset: 0 -12px;
border-radius: calc(10px * var(--kz-radius-scale, 1));
background: transparent;
transition: background-color calc(0.2s * var(--kz-motion-scale, 1));
}
@media (hover: hover) {
.kodza-review-summary__bar:hover:not(#_)::before {
background: var(--kz-review-summary-hover);
}
}
.kodza-review-summary__bar[aria-pressed="true"]:not(#_)::before {
background: var(--kz-review-summary-pressed);
}
.kodza-review-summary__bar:focus-visible:not(#_) {
outline: none;
}
.kodza-review-summary__bar:focus-visible:not(#_)::before {
outline: 2px solid var(--kz-review-summary-ink);
outline-offset: 0;
}
.kodza-review-summary.is-in .kodza-review-summary__fill:not(#_) {
transition: clip-path calc(0.9s * var(--kz-motion-scale, 1)) var(--kz-review-summary-ease) calc((0.3s + var(--kz-review-summary-i) * 0.09s) * var(--kz-motion-scale, 1)),
background-color calc(0.3s * var(--kz-motion-scale, 1));
}
.kodza-review-summary__status:not(#_) {
display: flex;
margin: 0;
font-size: 15px;
font-weight: 500;
line-height: 1.2;
letter-spacing: -0.01em;
}
.kodza-review-summary__clear:not(#_) {
display: inline-flex;
align-items: center;
height: 32px;
margin: 0;
padding: 0 14px;
border: 1px solid var(--kz-review-summary-line);
border-radius: var(--kz-radius-pill, 999px);
background: var(--kz-review-summary-card);
color: inherit;
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 13px);
font-weight: var(--kz-font-label-weight, 500);
line-height: var(--kz-font-label-leading, 1);
letter-spacing: var(--kz-font-label-tracking, 0);
text-transform: var(--kz-font-label-case, inherit);
white-space: nowrap;
cursor: pointer;
transition: opacity calc(0.25s * var(--kz-motion-scale, 1)) var(--kz-review-summary-ease),
translate calc(0.25s * var(--kz-motion-scale, 1)) var(--kz-review-summary-ease),
border-color calc(0.2s * var(--kz-motion-scale, 1)),
visibility 0s;
}
.kodza-review-summary:not(.is-filtered) .kodza-review-summary__clear:not(#_) {
opacity: 0;
translate: 0 6px;
visibility: hidden;
transition: opacity calc(0.2s * var(--kz-motion-scale, 1)),
translate calc(0.2s * var(--kz-motion-scale, 1)),
visibility 0s calc(0.2s * var(--kz-motion-scale, 1));
}
@media (hover: hover) {
.kodza-review-summary__clear:hover:not(#_) {
border-color: var(--kz-review-summary-ink);
}
}
.kodza-review-summary__clear:focus-visible:not(#_) {
outline: 2px solid var(--kz-review-summary-ink);
outline-offset: 3px;
}
.kodza-review-summary__review:not(#_) {
box-sizing: border-box;
display: flex;
margin: 0;
padding: 28px;
border: 1px solid var(--kz-review-summary-line);
border-radius: var(--kz-review-summary-radius);
background: var(--kz-review-summary-card);
}
.kodza-review-summary__review[hidden]:not(#_) {
display: none;
}
.kodza-review-summary__date:not(#_) {
font-family: var(--kz-font-label, var(--kz-review-summary-mono));
font-size: var(--kz-font-label-size, 12px);
font-weight: var(--kz-font-label-weight, 400);
line-height: var(--kz-font-label-leading, 1.4);
letter-spacing: var(--kz-font-label-tracking, 0);
text-transform: var(--kz-font-label-case, inherit);
color: var(--kz-review-summary-dim);
}
.kodza-review-summary__review-title:not(#_) {
margin: 4px 0 -4px;
font-size: 16px;
font-weight: 600;
line-height: 1.35;
letter-spacing: -0.015em;
}
.kodza-review-summary__quote:not(#_) {
margin: 0;
padding: 0;
border: 0;
font-size: 15px;
line-height: 1.55;
}
.kodza-review-summary__quote p:not(#_) {
margin: 0;
}
.kodza-review-summary__quote p + p:not(#_) {
margin-top: 0.8em;
}
.kodza-review-summary__quote.is-long:not(#_) {
max-height: calc(var(--kz-review-summary-lines) * 1.55em);
transition: -webkit-mask-size calc(0.5s * var(--kz-motion-scale, 1)) var(--kz-review-summary-ease),
mask-size calc(0.5s * var(--kz-motion-scale, 1)) var(--kz-review-summary-ease);
}
.kodza-review-summary__quote.is-long.is-open:not(#_) {
max-height: none;
}
.kodza-review-summary__more:not(#_) {
align-self: flex-start;
display: inline-grid;
margin: -4px 0 0;
padding: 2px 0;
overflow: hidden;
overflow: clip;
overflow-clip-margin: 4px;
border: 0;
background: none;
color: inherit;
font-family: var(--kz-font-label, inherit);
font-size: var(--kz-font-label-size, 13px);
font-weight: var(--kz-font-label-weight, 500);
line-height: var(--kz-font-label-leading, 1.2);
letter-spacing: var(--kz-font-label-tracking, 0);
text-transform: var(--kz-font-label-case, inherit);
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 3px;
cursor: pointer;
}
.kodza-review-summary__more:focus-visible:not(#_) {
outline: 2px solid var(--kz-review-summary-ink);
outline-offset: 3px;
border-radius: calc(4px * var(--kz-radius-scale, 1));
}
.kodza-review-summary__who:not(#_) {
display: grid;
margin-top: 8px;
}
.kodza-review-summary__empty:not(#_) {
margin: 0;
padding: 28px;
border: 1px dashed var(--kz-review-summary-line);
border-radius: var(--kz-review-summary-radius);
font-size: 15px;
line-height: 1.55;
color: var(--kz-review-summary-dim);
}
.kodza-review-summary__empty[hidden]:not(#_) {
display: none;
}
@container kodza-review-summary (min-width: 560px) and (max-width: 859.98px) {
.kodza-review-summary__bars:not(#_) {
padding: 0 0 0 32px;
border-top: 0;
border-left: 1px solid var(--kz-review-summary-line);
}
}
@container kodza-review-summary (max-width: 559.98px) {
.kodza-review-summary__review:not(#_) {
padding: 24px;
}
}
.kodza-review-summary--inline > .kodza-review-summary__count:where(p):not(#_) {
font-size: var(--kz-font-label-size, 12.5px);
}
@media (prefers-reduced-motion: reduce) {
.kodza-review-summary *:not(#_),
.kodza-review-summary *:not(#_)::before,
.kodza-review-summary *:not(#_)::after {
transition-duration: 0.01ms !important;
transition-delay: 0s !important;
animation-duration: 0.01ms !important;
}
}
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Kodza — Review Summary
// The average rolls up to its value (the engine of Counter) while the stars and the five bars fill
// in turn, once the summary comes into view. A bar is a button: it shows only the reviews with that
// rating, the others fade out and those that stay glide to their new place; again, or "Show all",
// brings them all back. A long review stops after a few lines, with "Read more" under it.
// The words come from the markup ("Read more" and "Read less": data-more and data-less on
// .kodza-review-summary__reviews); the timings are the constants below.
// "kodza:replay" on the element plays the entrance again.
(() => {
const SELECTOR = '.kodza-review-summary';
const GLIDE = 560; // ms, a review sliding to its new place, and the list to its new height
const LEAVE = 240; // ms, a review fading out of the filter
const ENTER = 420; // ms, a review coming back
const OPEN = 520; // ms, "Read more" opening the review
const FOLD = 420; // ms, and folding it back
const ROLL = 380; // ms, a word of the status rolling to the next
const THRESHOLD = 0.35; // share of the summary in view before it fills
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)';
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// The site's pace, --kz-motion-scale (set on :root, read on the instance): every duration of the
// script times it, and 0 moves nothing, like reduced motion
const paceOf = (el) => {
const pace = parseFloat(getComputedStyle(el).getPropertyValue('--kz-motion-scale'));
return pace >= 0 ? pace : 1;
};
const make = (tag, className, text) => Object.assign(document.createElement(tag), className ? { className } : {}, text != null ? { textContent: text } : {});
// One instance. Everything it starts is tied to `signal`, so kodza:destroy stops it all
function mount(root, signal) {
const pace = paceOf(root);
const still = reduceMotion || !pace; // the still path: no motion asked, by the visitor or the site
const inline = root.classList.contains('kodza-review-summary--inline');
// The average: a column of spinning digits per digit (screen readers keep the plain text)
const strips = [];
root.querySelectorAll('.kodza-review-summary__value').forEach((value) => {
const text = value.textContent.trim();
const count = (text.match(/\d/g) || []).length;
let rank = 0;
value.setAttribute('aria-hidden', 'true');
value.after(make('span', 'kodza-review-summary__sr', text));
value.replaceChildren(...[...text].map((char) => {
if (!/\d/.test(char)) return make('span', 'kodza-review-summary__char', char);
// The digits on the right spin more: one turn on the left, up to three on the right
const turns = 1 + Math.round((rank / Math.max(1, count - 1)) * 2);
const column = make('span', 'kodza-review-summary__digit');
const strip = make('span', 'kodza-review-summary__strip');
for (let i = 0; i < (turns + 1) * 10; i++) strip.append(make('span', '', i % 10));
column.append(make('span', 'kodza-review-summary__fit', char), strip);
strips.push({ strip, stop: turns * 10 + Number(char), rank: rank++ });
return column;
}));
});
const land = () => strips.forEach(({ strip, stop }) => (strip.style.transform = `translateY(${-stop * 1.1}em)`));
const roll = () => strips.forEach(({ strip, stop, rank }) => {
strip.getAnimations().forEach((animation) => animation.cancel());
strip.style.transform = '';
strip.animate([{ transform: 'translateY(0)' }, { transform: `translateY(${-stop * 1.1}em)` }], {
duration: (1500 + rank * 160) * pace,
easing: 'cubic-bezier(0.16, 1, 0.3, 1)',
fill: 'forwards',
});
});
// The entrance: empty until the summary is in view, then the stars and the bars fill (CSS)
const enter = () => {
root.classList.add('is-in');
roll();
};
if (still || !('IntersectionObserver' in window)) land();
else {
root.classList.add('is-armed');
const io = new IntersectionObserver((entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return;
io.disconnect();
enter();
}, { threshold: THRESHOLD });
io.observe(inline ? root : root.querySelector('.kodza-review-summary__summary') || root);
signal.addEventListener('abort', () => io.disconnect());
root.addEventListener('kodza:replay', () => {
root.classList.remove('is-in');
void root.offsetWidth; // empty again for one frame, so the fill plays from the start
enter();
}, { signal });
}
const list = root.querySelector('.kodza-review-summary__reviews');
if (!list) return;
const reviews = [...list.querySelectorAll('.kodza-review-summary__review')];
const bars = [...root.querySelectorAll('.kodza-review-summary__bar')];
const empty = list.querySelector('.kodza-review-summary__empty');
// "Read more": a long review stops after a few lines, and opens at its real height
const moreText = list.dataset.more || 'Read more';
const lessText = list.dataset.less || 'Read less';
const folds = new Map();
reviews.forEach((review) => {
const quote = review.querySelector('.kodza-review-summary__quote');
if (!quote) return;
let button = null;
let open = false;
let width = 0;
let run = null;
const toggle = () => {
const from = quote.offsetHeight;
run?.cancel();
open = !open;
button.classList.toggle('is-open', open);
button.setAttribute('aria-expanded', String(open));
button.firstChild.toggleAttribute('aria-hidden', open);
button.lastChild.toggleAttribute('aria-hidden', !open);
quote.classList.toggle('is-closing', !open);
quote.classList.remove('is-open');
const low = quote.offsetHeight;
quote.classList.add('is-open');
const to = open ? quote.offsetHeight : low;
const done = () => {
run = null;
if (!open) quote.classList.remove('is-open', 'is-closing');
};
if (still) return done();
run = quote.animate([{ height: `${from}px` }, { height: `${to}px` }], {
duration: (open ? OPEN : FOLD) * pace,
easing: open ? EASE : 'cubic-bezier(0.65, 0, 0.35, 1)',
});
run.onfinish = done;
};
// Long or not depends on the width: measured again when it changes, never while hidden or open
folds.set(quote, () => {
const w = quote.clientWidth;
if (!w || w === width || open) return;
width = w;
quote.classList.add('is-long');
const long = quote.scrollHeight > quote.clientHeight + 1;
quote.classList.toggle('is-long', long);
if (long && !button) {
button = make('button', 'kodza-review-summary__more');
button.type = 'button';
button.setAttribute('aria-expanded', 'false');
const less = make('span', '', lessText);
less.setAttribute('aria-hidden', 'true');
button.append(make('span', '', moreText), less);
button.addEventListener('click', toggle, { signal });
quote.after(button);
} else if (!long && button) {
button.remove();
button = null;
}
});
});
if ('ResizeObserver' in window) {
const ro = new ResizeObserver((entries) => entries.forEach((entry) => folds.get(entry.target)?.()));
folds.forEach((_, quote) => ro.observe(quote));
signal.addEventListener('abort', () => ro.disconnect());
} else folds.forEach((measure) => measure());
// The status over the list: its words roll (the old one goes up, the new one comes from below)
const status = root.querySelector('.kodza-review-summary__status');
const label = root.querySelector('.kodza-review-summary__status-label');
const count = root.querySelector('.kodza-review-summary__status-count');
const allLabel = label ? label.textContent.trim() : '';
let live = null;
[label, count].forEach((el) => {
if (!el) return;
el.replaceChildren(make('span', '', el.textContent.trim()));
el.classList.add('kodza-review-summary__slot');
});
if (status) {
// Screen readers hear the change once, from a quiet copy, not the words on their way out
status.setAttribute('aria-hidden', 'true');
live = make('span', 'kodza-review-summary__sr', `${allLabel}: ${reviews.length}`);
live.setAttribute('aria-live', 'polite');
status.after(live);
}
const rollTo = (el, text) => {
if (!el) return;
const words = [...el.children];
const current = words.pop();
if (current.textContent === text) return;
words.forEach((word) => word.remove());
const word = make('span', '', text);
if (still) return current.replaceWith(word);
[el, current].forEach((node) => node.getAnimations().forEach((animation) => animation.cancel()));
const from = el.offsetWidth;
current.replaceWith(word);
const to = el.offsetWidth;
el.prepend(current);
current.setAttribute('aria-hidden', 'true');
const timing = { duration: ROLL * pace, easing: EASE };
current.animate([{ transform: 'none', opacity: 1 }, { transform: 'translateY(-100%)', opacity: 0 }], { ...timing, fill: 'forwards' })
.onfinish = () => current.remove();
word.animate([{ transform: 'translateY(100%)', opacity: 0 }, { transform: 'none', opacity: 1 }], timing);
if (from !== to) el.animate([{ width: `${from}px` }, { width: `${to}px` }], timing);
};
// The filter. A review on its way out is laid over the grid where it was, behind the others, and
// fades; the others glide from where they were (measured before and after, FLIP)
const leaving = new Set();
const moves = new Map();
let sizing = null;
let current = null;
const release = (review) => {
['position', 'left', 'top', 'width', 'margin', 'z-index'].forEach((name) => review.style.removeProperty(name));
review.inert = false;
};
const arrange = (shows, shown) => {
if (still) {
reviews.forEach((review) => (review.hidden = !shows(review)));
if (empty) empty.hidden = shown > 0;
return;
}
// A parent scaled by transform (the catalogue, a theme): screen pixels back to the list's own
const scale = list.getBoundingClientRect().width / list.offsetWidth || 1;
const box = list.getBoundingClientRect();
const height = list.offsetHeight;
const from = new Map();
reviews.forEach((review) => {
if (review.hidden || (leaving.has(review) && !shows(review))) return;
const rect = review.getBoundingClientRect();
from.set(review, {
x: (rect.left - box.left) / scale,
y: (rect.top - box.top) / scale,
width: review.offsetWidth,
opacity: Number(getComputedStyle(review).opacity),
});
});
if (sizing) {
sizing.cancel();
sizing = null;
list.classList.remove('is-sizing');
}
reviews.forEach((review) => {
if (leaving.has(review) && !shows(review)) return; // already on its way out
moves.get(review)?.cancel();
moves.delete(review);
if (leaving.delete(review)) release(review);
});
reviews.forEach((review) => {
if (leaving.has(review)) return;
const was = from.get(review);
if (shows(review) || !was) return void (review.hidden = !shows(review));
leaving.add(review);
review.inert = true;
Object.assign(review.style, { position: 'absolute', left: `${was.x}px`, top: `${was.y}px`, width: `${was.width}px`, margin: '0', zIndex: '-1' });
const out = review.animate([{ opacity: was.opacity, transform: 'none' }, { opacity: 0, transform: 'scale(0.96)' }], {
duration: LEAVE * pace,
easing: 'cubic-bezier(0.4, 0, 1, 1)',
fill: 'forwards',
});
moves.set(review, out);
out.onfinish = () => {
leaving.delete(review);
moves.delete(review);
review.hidden = true;
release(review);
out.cancel();
};
});
if (empty) {
const was = !empty.hidden;
empty.hidden = shown > 0;
if (!empty.hidden && !was) empty.animate([{ opacity: 0 }, { opacity: 1 }], { duration: ENTER * pace, delay: LEAVE * pace, easing: EASE, fill: 'backwards' });
}
const next = list.offsetHeight;
let k = 0;
reviews.forEach((review) => {
if (review.hidden || leaving.has(review)) return;
const was = from.get(review);
if (!was) {
moves.set(review, review.animate([{ opacity: 0, transform: 'translateY(16px)' }, { opacity: 1, transform: 'none' }], {
duration: ENTER * pace,
delay: (LEAVE * 0.6 + k++ * 50) * pace,
easing: EASE,
fill: 'backwards',
}));
return;
}
const dx = was.x - review.offsetLeft;
const dy = was.y - review.offsetTop;
if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5 && was.opacity === 1) return;
moves.set(review, review.animate([{ transform: `translate(${dx}px, ${dy}px)`, opacity: was.opacity }, { transform: 'none', opacity: 1 }], {
duration: GLIDE * pace,
easing: EASE,
}));
});
// The list takes its new height in the same time, so the page under it never jumps. Shorter,
// it waits for the reviews on their way out to be mostly gone
if (Math.abs(next - height) > 0.5) {
list.classList.add('is-sizing');
sizing = list.animate([{ height: `${height}px` }, { height: `${next}px` }], {
duration: GLIDE * pace,
delay: next < height ? LEAVE * 0.6 * pace : 0,
easing: EASE,
fill: 'backwards',
});
sizing.onfinish = () => {
sizing = null;
list.classList.remove('is-sizing');
};
}
};
const filter = (rating) => {
current = rating === current ? null : rating;
bars.forEach((bar) => bar.setAttribute('aria-pressed', String(bar.dataset.rating === current)));
root.classList.toggle('is-filtered', current !== null);
const shows = (review) => current === null || review.dataset.rating === current;
const shown = reviews.filter(shows).length;
const bar = bars.find((item) => item.dataset.rating === current);
const word = bar ? (bar.querySelector('.kodza-review-summary__bar-label') || bar).textContent.trim() : allLabel;
rollTo(label, word);
rollTo(count, String(shown));
if (live) live.textContent = `${word}: ${shown}`;
arrange(shows, shown);
};
bars.forEach((bar) => bar.addEventListener('click', () => filter(bar.dataset.rating), { signal }));
root.querySelector('.kodza-review-summary__clear')?.addEventListener('click', (event) => {
const bar = bars.find((item) => item.dataset.rating === current);
filter(null);
// From the keyboard, the focus goes back to the bar instead of the button that just went away
if (event.detail === 0) bar?.focus();
}, { signal });
signal.addEventListener('abort', () => {
sizing?.cancel();
moves.forEach((animation) => animation.cancel());
});
}
// Kodza lifecycle, the same in every component: `kodza:init` starts the instances inside
// event.detail.root (the whole page by default), `kodza:destroy` stops them and puts their
// markup back. For themes and apps that swap content without a page load. Each running root
// keeps its markup as written under KODZA, shared by every Kodza script on the page: a Kodza
// component inside another one is saved as written, not half-started, and stopped with it. Two
// components on one root (a reveal on a stat card) both start, and stop together.
const KODZA = Symbol.for('kodza');
const RUNNING = Symbol.for(`kodza ${SELECTOR}`); // a root this component runs on, for every copy of this script
const instances = new WeakMap();
const find = (scope = document) => [scope, ...scope.querySelectorAll(SELECTOR)].filter((el) => el.matches?.(SELECTOR));
const attrsOf = (el) => [...el.attributes].map((a) => [a.name, a.value]);
const put = (el, saved) => {
[...el.attributes].forEach((a) => el.removeAttribute(a.name));
saved.attrs.forEach(([name, value]) => el.setAttribute(name, value));
el.innerHTML = saved.html;
};
// The markup inside a root as written: Kodza components already running in it give back their own
const written = (root) => {
const all = [...root.querySelectorAll('*')];
if (!all.some((el) => el[KODZA])) return root.innerHTML;
const copy = root.cloneNode(true);
const twins = copy.querySelectorAll('*');
let last = null;
all.forEach((el, i) => {
if (!el[KODZA] || last?.contains(el)) return;
last = el;
put(twins[i], el[KODZA]);
});
return copy.innerHTML;
};
const init = (scope) => find(scope).forEach((root) => {
// Already running, maybe from another copy of this script. Marked is-ready with no Kodza component
// running on it, a root is a copy of a started one (a cloned slide): it is left as it is
if (root[RUNNING] || (root.classList.contains('is-ready') && !root[KODZA])) return;
// Rendered by React (data-kodza-manual): React starts it, with an init aimed at that markup once
// it has hydrated it. Any earlier start would change markup React has not taken over yet
const owner = root.closest('[data-kodza-manual]');
if (owner && !(scope && owner.contains(scope))) return;
const controller = new AbortController();
// Another Kodza component already running on this root: its markup as written is kept, and the
// two stop together
const before = root[KODZA];
const saved = { html: before ? before.html : written(root), attrs: before ? before.attrs : attrsOf(root), stop: () => destroy(root) };
if (before) before.above = saved;
instances.set(root, { controller, saved, before });
root[KODZA] = saved;
root[RUNNING] = true;
mount(root, controller.signal);
root.classList.add('is-ready');
});
const destroy = (scope) => find(scope).forEach((root) => {
const instance = instances.get(root);
if (!instance) return;
instances.delete(root);
instance.saved.above?.stop(); // a Kodza component started after it on the same root stops first
// The Kodza components running inside it stop first: once its markup is put back, their own
// script could not find them any more
root.querySelectorAll('*').forEach((el) => el[KODZA]?.stop());
instance.controller.abort();
delete root[RUNNING];
// Started on top of another one: that one stops next, and puts the markup back
if (instance.before) {
delete instance.before.above;
root[KODZA] = instance.before;
return instance.before.stop();
}
delete root[KODZA];
put(root, instance.saved);
});
document.addEventListener('kodza:init', (event) => init(event.detail?.root));
document.addEventListener('kodza:destroy', (event) => destroy(event.detail?.root));
init();
// Loaded in <head>, the script runs before the markup exists: once more when it is parsed
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => init(), { once: true });
})();
'use client';
// Kodza — Review Summary, for React. From https://kodza.dev/components/review-summary/
// Kodza License: free to use, client work included. Not to resell or redistribute, ported or not: https://kodza.dev/docs/#print-license
// Next to this file: review-summary.css and review-summary.js, the component's CSS and JS from Kodza, as they are.
// Yours to edit: the markup is plain JSX below.
// The script changes this markup once React has rendered it (it adds layers, splits text, moves
// nodes), so React must not patch it afterwards. When a prop changes, the key gives React a fresh
// element: kodza:destroy stops the script on the old one, kodza:init starts it on the new one.
// data-kodza-manual keeps the script from starting it on its own: the effect below does, once
// React has hydrated the markup (on a page that streams in, the script can load first).
// Built from your data (tabs, rows, options)? Put what the markup is made of in the key, as plain
// values (ids, text, numbers): a change starts the script again on fresh markup. What the visitor
// changes (the open tab, the sort, what they type) is the script's: keep it out of the key, follow
// it with onClick or onInput on the root, and change it from your code as a visitor would (click
// the tab; set the field's value, then dispatch a bubbling input event).
import { useRef, useEffect, type ComponentPropsWithoutRef, type ComponentRef, type CSSProperties } from 'react';
import './review-summary.css';
type Variant = "inline";
export type ReviewSummaryProps = Omit<ComponentPropsWithoutRef<'div'>, "children" | "variant" | "vars" | "kodzaReviewSummary"> & {
/** Modifier class(es): .kodza-review-summary--inline */
variant?: Variant | Variant[];
/** data-kodza-review-summary on the root */
kodzaReviewSummary?: string;
/** Settings, as CSS variables on the root */
vars?: {
/** default: #0a0a0a */
"--kz-review-summary-ink"?: string | number;
/** default: rgba(10, 10, 10, 0.64) */
"--kz-review-summary-dim"?: string | number;
/** default: #ffffff */
"--kz-review-summary-card"?: string | number;
/** default: rgba(10, 10, 10, 0.1) */
"--kz-review-summary-line"?: string | number;
/** empty stars and bar tracks — default: rgba(10, 10, 10, 0.1) */
"--kz-review-summary-rail"?: string | number;
/** the bars not chosen while filtered — default: rgba(10, 10, 10, 0.26) */
"--kz-review-summary-muted"?: string | number;
/** default: #e6e6e3 */
"--kz-review-summary-face"?: string | number;
/** default: rgba(10, 10, 10, 0.04) */
"--kz-review-summary-hover"?: string | number;
/** default: rgba(10, 10, 10, 0.07) */
"--kz-review-summary-pressed"?: string | number;
/** default: transparent */
"--kz-review-summary-bg"?: string | number;
/** default: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 2.6l2.9 5.9 6.5.9-4.7 4.6 1.1 6.5L12 17.4l-5.8 3.1 1.1-6.5L2.6 9.4l6.5-.9z' stroke='black' stroke-width='1.2' stroke-linejoin='round'/%3E%3C/svg%3E") */
"--kz-review-summary-star"?: string | number;
/** lines shown before "Read more" — default: 5 */
"--kz-review-summary-lines"?: string | number;
/** summary, reviews — default: minmax(280px, 340px) minmax(0, 1fr) */
"--kz-review-summary-columns"?: string | number;
/** the narrowest review card — default: 280px */
"--kz-review-summary-card-min"?: string | number;
/** where the summary stays while the reviews scroll — default: 24px */
"--kz-review-summary-sticky"?: string | number;
/** default: 1200px */
"--kz-review-summary-width"?: string | number;
/** default: clamp(64px, 8vw, 112px) */
"--kz-review-summary-pad-y"?: string | number;
/** default: clamp(16px, 4vw, 40px) */
"--kz-review-summary-gutter"?: string | number;
/** default: calc(16px * var(--kz-radius-scale, 1)) */
"--kz-review-summary-radius"?: string | number;
/** default: var(--kz-font-mono, "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace) */
"--kz-review-summary-mono"?: string | number;
/** default: cubic-bezier(0.22, 1, 0.36, 1) */
"--kz-review-summary-ease"?: string | number;
};
className?: string;
};
export function ReviewSummary({ variant, kodzaReviewSummary, vars, className, style, ...rest }: ReviewSummaryProps) {
const ref = useRef<ComponentRef<'div'>>(null);
const classes = ["kodza-review-summary", "kodza-review-summary--inline", ...([] as string[]).concat(variant ?? []).map((v) => `kodza-review-summary--${v}`), className].filter(Boolean).join(' ');
// Everything the markup is made of: when it changes, React gets a fresh element (see the top)
const key = JSON.stringify([classes, kodzaReviewSummary, vars, style, rest]);
// Starts the script on this element once React has rendered it, stops it when it goes
useEffect(() => {
const root = ref.current;
let live = true;
// @ts-ignore: a plain script, no types to find
import('./review-summary.js').then(() => {
if (live) document.dispatchEvent(new CustomEvent('kodza:init', { detail: { root } }));
});
return () => {
live = false;
document.dispatchEvent(new CustomEvent('kodza:destroy', { detail: { root } }));
};
}, [key]);
return (
<div key={key} {...rest} className={classes} data-kodza-manual data-kodza-review-summary={kodzaReviewSummary} style={{ ...vars, ...style } as CSSProperties} ref={ref}>
<span className="kodza-review-summary__stars" style={{ "--kz-score": "4.7" } as CSSProperties} aria-hidden="true">
<span className="kodza-review-summary__star" />
<span className="kodza-review-summary__star" />
<span className="kodza-review-summary__star" />
<span className="kodza-review-summary__star" />
<span className="kodza-review-summary__star" />
</span>
{' '}
<span className="kodza-review-summary__value">4.7</span>
<span className="kodza-review-summary__max"> out of 5</span>
{' '}
<span className="kodza-review-summary__count">1,284 reviews</span>
</div>
);
}
Checked
Robots poked at it before you did. Every component goes through the same checks, and again each time it changes.