口コミの評価まとめ(星の分布バー+抜粋の絞り込み)
件数の多い口コミを、平均評価と星5〜1の分布から探せるようにするお客様の声のブロックです。平均・件数・星ごとの件数と割合はすべて同じ口コミデータから計算するので、手で書いた数字が実際の口コミと合わなくなることがありません。分布の横棒はそれぞれが絞り込みボタンで、押すとその評価の口コミだけが並び、もう一度押すか「絞り込みを解除」で全件に戻ります。押した棒には aria-pressed を付け、表示件数と並び順は1文で読み上げます。星の図形は読み上げから外し、「5段階中4」のような数値だけを伝えるので、同じ評価を二重に読ませません。抜粋には投稿した年月と利用したサービス名を添え、新しい順と評価の高い順を切り替えられます。続きは5件ずつ表示し、足した最初の1件へフォーカスを移します。棒が伸びる動きは CSS だけで、prefers-reduced-motion では最初から最終の長さで表示します。角丸のない hairline 罫線でまとめ、新規の依存はありません。掲載の口コミは架空です。
- 追加日:
- 2026-10-22
- 依存:
- なし
- tags
- #block #testimonial #review #rating #star-rating #filter #sort #aria-pressed #aria-live #a11y #hairline #no-image #no-dependency
プレビュー
ご利用いただいた方の評価
平均 5段階中4.1
24件の口コミ
棒を押すと、その評価の口コミだけを表示します
デモ用の架空の口コミです。店舗・評価・投稿内容はすべて実在しません。
すべての口コミ 24件新しい順
評価 5段階中5、
油のにおいがしなくなりました
換気扇とコンロ、壁のタイルまでまとめてお願いしました。次回の目安も教えてもらえたので、来年もお願いするつもりです。
利用したサービス:キッチン・換気扇30代・分譲マンション
評価 5段階中4、
平日の夜に来てもらえました
仕事のあとの時間を指定できたのが助かりました。作業音が少し大きかったので、近所に一声かけておけばよかったです。
利用したサービス:エアコン洗浄20代・賃貸マンション
評価 5段階中5、
ベランダ側の窓が明るくなりました
高層階で外側を自分で拭けなかったので、ずっと気になっていました。サッシの溝の砂も取ってもらえました。
利用したサービス:窓・網戸50代・分譲マンション
評価 5段階中3、
料金のわりに時間が短く感じました
仕上がりに不満はありません。ただ2時間の予定が1時間で終わったので、どこまで洗ったのか説明がほしかったです。
利用したサービス:浴室クリーニング30代・戸建て
評価 5段階中5、
見積もりの説明が丁寧でした
電話で写真を送るように言われ、その場で金額が決まりました。当日も時間どおりに来てもらえました。
利用したサービス:キッチン・換気扇40代・賃貸アパート
"use client";
import { useId, useMemo, useRef, useState, type CSSProperties } from "react";
/**
* 口コミの評価まとめ(星の分布バー+抜粋の絞り込み)(BLK-102)
*
* 差別化: 既存のお客様の声(testimonial-voices / voice-interview-feature)は少数の声を読ませる型で、
* 件数の多い口コミを集計して探す操作を持たない。本ブロックは平均・分布を入口にした絞り込みの道具にした。
* 英字キッカー・句点止めの見出し・末尾の矢印リンク・角丸・スクロール連動の出現演出は外した。
*
* 汎用技法メモ:
* 1) 数字を手で書かない: 平均・件数・星ごとの件数と割合は、すべて REVIEWS の配列から useMemo で計算する。
* 口コミを1件足せば、平均も分布バーも抜粋の件数も一緒に変わる。
* 2) 分布バー=絞り込みボタン: 各行を <button aria-pressed> にし、押した評価だけに絞る(もう一度押すと解除)。
* 絞り込み中は「解除」ボタンを出し、解除したらフォーカスを直前に押していた行へ戻す(消えたボタンに
* フォーカスを残さない)。結果件数と並び順は role="status"(aria-live=polite)の1文で読み上げる。
* 3) 星は二重に読ませない: 星の図形は aria-hidden にし、読み上げには「5段階中4」の数値だけを渡す。
* 平均の端数の星は、灰色の星5つの上にアンバーの星5つを重ね、上側の幅を平均/5 で切る。
* 4) 棒の伸びは CSS の keyframes(scaleX 0 → 割合)で、最終値は CSS 変数で渡す。JS が無くても、
* prefers-reduced-motion で animation を外しても、最初から最終の長さで表示される。
* 5) 幅の切り替えはコンテナクエリ(@container / @4xl: / @md:)で行い、置かれた枠の幅で2列と縦積みを決める。
* 6) 「さらに表示」で足した抜粋は、最初の1件へ tabIndex=-1 でフォーカスを移し、続きから読めるようにする。
*/
type Review = {
id: number;
rating: 1 | 2 | 3 | 4 | 5;
/** 投稿月(YYYY-MM) */
date: string;
service: string;
who: string;
title: string;
body: string;
};
const REVIEWS: Review[] = [
{ id: 1, rating: 5, date: "2025-11", service: "エアコン洗浄", who: "30代・賃貸マンション", title: "吹き出し口の黒い点が消えました", body: "分解して洗った後の汚れた水を見せてもらい、頼んでよかったと思いました。作業後の床もきれいに拭いてありました。" },
{ id: 2, rating: 4, date: "2025-11", service: "浴室クリーニング", who: "40代・戸建て", title: "鏡のうろこ汚れはかなり薄くなりました", body: "完全には取れないと最初に説明があり、そのとおりの仕上がりでした。目地のカビはほぼ見えなくなっています。" },
{ id: 3, rating: 5, date: "2025-12", service: "キッチン・換気扇", who: "50代・戸建て", title: "換気扇の音が静かになりました", body: "10年分の油で羽根が重くなっていたそうです。外した部品を並べて状態を説明してくれたので、次に頼む時期も分かりました。" },
{ id: 4, rating: 3, date: "2025-12", service: "窓・網戸", who: "60代・戸建て", title: "仕上がりは良いが、到着が遅れました", body: "前の作業が延びたとのことで40分ほど遅れました。連絡はありましたが、もう少し早く知らせてほしかったです。" },
{ id: 5, rating: 5, date: "2026-01", service: "エアコン洗浄", who: "20代・賃貸アパート", title: "見積もりから金額が変わりませんでした", body: "当日に追加料金を言われるのが心配でしたが、事前の見積もりどおりでした。作業は1時間半ほどです。" },
{ id: 6, rating: 4, date: "2026-01", service: "浴室クリーニング", who: "30代・分譲マンション", title: "排水口の奥まで洗ってもらえました", body: "においが気にならなくなりました。天井の換気口は別料金だったので、事前に分かっていれば一緒に頼んでいました。" },
{ id: 7, rating: 2, date: "2026-02", service: "キッチン・換気扇", who: "40代・分譲マンション", title: "コンロ周りに拭き残しがありました", body: "換気扇はきれいでしたが、五徳の裏に油が残っていました。連絡したところ翌週にやり直してもらえたので星2つにしています。" },
{ id: 8, rating: 5, date: "2026-02", service: "窓・網戸", who: "30代・戸建て", title: "網戸の外し方まで教えてもらいました", body: "高い窓は脚立を持ってきてくれて、外側もきれいになりました。自分で掃除するときのこつも聞けて助かりました。" },
{ id: 9, rating: 5, date: "2026-03", service: "エアコン洗浄", who: "40代・戸建て", title: "2台まとめて午前中に終わりました", body: "子ども部屋とリビングの2台をお願いしました。運転したときのにおいがまったくなくなっています。" },
{ id: 10, rating: 4, date: "2026-03", service: "キッチン・換気扇", who: "30代・賃貸マンション", title: "シンクの水あかがきれいに取れました", body: "仕上がりには満足です。予約の空きが3週間先までなかったので、そこだけ星を1つ減らしました。" },
{ id: 11, rating: 1, date: "2026-04", service: "浴室クリーニング", who: "50代・分譲マンション", title: "予約の日時が伝わっていませんでした", body: "電話で決めた日に来てもらえず、もう一度日程を調整することになりました。作業そのものは丁寧でした。" },
{ id: 12, rating: 5, date: "2026-04", service: "窓・網戸", who: "70代・戸建て", title: "雨戸のレールまで掃除してもらえました", body: "自分ではしゃがめないところだったので助かりました。終わったあとに開け閉めを一緒に確かめてくれました。" },
{ id: 13, rating: 3, date: "2026-05", service: "エアコン洗浄", who: "30代・分譲マンション", title: "室外機の掃除は対象外でした", body: "室内機はきれいになりました。室外機も洗ってもらえると思っていたので、申し込みの画面に書いてあると助かります。" },
{ id: 14, rating: 5, date: "2026-05", service: "浴室クリーニング", who: "20代・賃貸マンション", title: "退去前に頼んで正解でした", body: "鏡と蛇口が新品のようになり、立ち会いでも何も言われませんでした。作業の前後の写真も送ってもらえました。" },
{ id: 15, rating: 4, date: "2026-06", service: "エアコン洗浄", who: "40代・賃貸マンション", title: "防カビの仕上げを付けて正解でした", body: "梅雨の時期でも、においが戻っていません。作業中は家の中を養生してもらえたので片付けも不要でした。" },
{ id: 16, rating: 5, date: "2026-06", service: "キッチン・換気扇", who: "60代・分譲マンション", title: "レンジフードの中まで見違えました", body: "フィルターだけでなく、奥のファンまで外して洗ってくれました。説明がゆっくりで分かりやすかったです。" },
{ id: 17, rating: 2, date: "2026-07", service: "窓・網戸", who: "40代・戸建て", title: "網戸に小さな破れが見つかりました", body: "作業中にできたものか分からないと言われ、話がまとまるまで時間がかかりました。窓ガラスの仕上がりは良かったです。" },
{ id: 18, rating: 5, date: "2026-07", service: "エアコン洗浄", who: "30代・戸建て", title: "真夏でも当日中に使えました", body: "朝9時に来てもらい、昼には冷房が使えました。風量が明らかに強くなっています。" },
{ id: 19, rating: 4, date: "2026-08", service: "浴室クリーニング", who: "50代・戸建て", title: "床の黒ずみがほとんど取れました", body: "古い浴室なので一部は取れないと言われましたが、見た目はかなり明るくなりました。また頼む予定です。" },
{ id: 20, rating: 5, date: "2026-08", service: "キッチン・換気扇", who: "40代・賃貸アパート", title: "見積もりの説明が丁寧でした", body: "電話で写真を送るように言われ、その場で金額が決まりました。当日も時間どおりに来てもらえました。" },
{ id: 21, rating: 3, date: "2026-09", service: "浴室クリーニング", who: "30代・戸建て", title: "料金のわりに時間が短く感じました", body: "仕上がりに不満はありません。ただ2時間の予定が1時間で終わったので、どこまで洗ったのか説明がほしかったです。" },
{ id: 22, rating: 5, date: "2026-09", service: "窓・網戸", who: "50代・分譲マンション", title: "ベランダ側の窓が明るくなりました", body: "高層階で外側を自分で拭けなかったので、ずっと気になっていました。サッシの溝の砂も取ってもらえました。" },
{ id: 23, rating: 4, date: "2026-10", service: "エアコン洗浄", who: "20代・賃貸マンション", title: "平日の夜に来てもらえました", body: "仕事のあとの時間を指定できたのが助かりました。作業音が少し大きかったので、近所に一声かけておけばよかったです。" },
{ id: 24, rating: 5, date: "2026-10", service: "キッチン・換気扇", who: "30代・分譲マンション", title: "油のにおいがしなくなりました", body: "換気扇とコンロ、壁のタイルまでまとめてお願いしました。次回の目安も教えてもらえたので、来年もお願いするつもりです。" },
];
type SortKey = "new" | "rating";
const SORTS: { key: SortKey; label: string }[] = [
{ key: "new", label: "新しい順" },
{ key: "rating", label: "評価の高い順" },
];
const STARS = [5, 4, 3, 2, 1] as const;
const PAGE = 5;
function formatMonth(ym: string) {
const [y, m] = ym.split("-");
return `${y}年${Number(m)}月`;
}
function StarRow({ className = "" }: { className?: string }) {
return (
<span aria-hidden="true" className={`flex gap-0.5 ${className}`}>
{[0, 1, 2, 3, 4].map((i) => (
<svg key={i} viewBox="0 0 20 20" className="h-full w-auto shrink-0" fill="currentColor">
<path d="M10 1.5l2.6 5.4 5.9.8-4.3 4.1 1 5.9L10 14.9l-5.2 2.8 1-5.9L1.5 7.7l5.9-.8z" />
</svg>
))}
</span>
);
}
/** 星の図形(読み上げない)。value は 0〜5 の小数可 */
function Stars({ value, size }: { value: number; size: string }) {
return (
<span aria-hidden="true" className={`relative inline-block ${size}`}>
<StarRow className="h-full text-stone-200" />
<span className="absolute inset-y-0 left-0 overflow-hidden" style={{ width: `${(value / 5) * 100}%` }}>
<StarRow className="h-full text-amber-600" />
</span>
</span>
);
}
export default function ReviewRatingSummary() {
const uid = useId();
const headingId = `${uid}-h`;
const listId = `${uid}-list`;
const [filter, setFilter] = useState<number | null>(null);
const [sort, setSort] = useState<SortKey>("new");
const [shown, setShown] = useState(PAGE);
const barRefs = useRef<Record<number, HTMLButtonElement | null>>({});
const itemRefs = useRef<Record<number, HTMLLIElement | null>>({});
const lastFilter = useRef<number | null>(null);
const focusAfterMore = useRef<number | null>(null);
const stats = useMemo(() => {
const total = REVIEWS.length;
const sum = REVIEWS.reduce((s, r) => s + r.rating, 0);
const counts = Object.fromEntries(STARS.map((s) => [s, REVIEWS.filter((r) => r.rating === s).length])) as Record<
number,
number
>;
return { total, avg: total ? sum / total : 0, counts };
}, []);
const results = useMemo(() => {
const list = filter === null ? REVIEWS : REVIEWS.filter((r) => r.rating === filter);
return [...list].sort((a, b) => {
if (sort === "rating" && a.rating !== b.rating) return b.rating - a.rating;
if (a.date !== b.date) return a.date < b.date ? 1 : -1;
return b.id - a.id;
});
}, [filter, sort]);
const visible = results.slice(0, shown);
const rest = results.length - visible.length;
const avgText = stats.avg.toFixed(1);
const sortLabel = SORTS.find((s) => s.key === sort)!.label;
const choose = (star: number) => {
const next = filter === star ? null : star;
if (next !== null) lastFilter.current = next;
setFilter(next);
setShown(PAGE);
};
const clear = () => {
const back = lastFilter.current;
setFilter(null);
setShown(PAGE);
// 解除ボタンは消えるので、直前に押していた分布バーへフォーカスを戻す
if (back !== null) requestAnimationFrame(() => barRefs.current[back]?.focus());
};
const showMore = () => {
const first = results[shown];
if (first) focusAfterMore.current = first.id;
setShown((n) => n + PAGE);
};
const setItemRef = (id: number) => (el: HTMLLIElement | null) => {
itemRefs.current[id] = el;
if (el && focusAfterMore.current === id) {
focusAfterMore.current = null;
el.focus();
}
};
return (
<section aria-labelledby={headingId} className="@container w-full max-w-5xl bg-white px-1 text-stone-900">
<style>{`
@keyframes rrs-grow { from { transform: scaleX(0); } to { transform: scaleX(var(--rrs-r)); } }
`}</style>
<div className="grid gap-x-14 gap-y-10 @4xl:grid-cols-[minmax(0,19rem)_minmax(0,1fr)]">
{/* 集計(平均・件数・分布) */}
<div className="max-w-xl">
<h2
id={headingId}
className="text-[clamp(1.45rem,3.2vw,1.9rem)] leading-[1.4] font-bold tracking-tight text-stone-900"
>
ご利用いただいた方の評価
</h2>
<div className="mt-6 flex items-end gap-4 border-t border-stone-200 pt-6">
<p className="font-display text-[56px] leading-none font-bold tracking-tight tabular-nums">
<span className="sr-only">平均 5段階中</span>
{avgText}
</p>
<div className="pb-1.5">
<Stars value={stats.avg} size="h-4" />
<p className="mt-1.5 text-[13px] font-medium text-stone-500">
<span className="font-display tabular-nums">{stats.total}</span>件の口コミ
</p>
</div>
</div>
<p id={`${uid}-bars-note`} className="mt-7 text-[12px] font-medium text-stone-500">
棒を押すと、その評価の口コミだけを表示します
</p>
<ul className="mt-2 list-none p-0" aria-describedby={`${uid}-bars-note`}>
{STARS.map((star, i) => {
const count = stats.counts[star];
const ratio = stats.total ? count / stats.total : 0;
const pressed = filter === star;
const dimmed = filter !== null && !pressed;
return (
<li key={star}>
<button
type="button"
ref={(el) => {
barRefs.current[star] = el;
}}
aria-pressed={pressed}
aria-controls={listId}
disabled={count === 0}
onClick={() => choose(star)}
className={`group grid w-full grid-cols-[2.25rem_minmax(0,1fr)_2rem_2.5rem] items-center gap-3 py-2 text-left transition-opacity duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 disabled:cursor-not-allowed disabled:opacity-40 motion-reduce:transition-none ${
pressed ? "bg-amber-50" : "hover:bg-stone-50"
} ${dimmed ? "opacity-45 hover:opacity-100" : ""}`}
>
<span className={`flex items-center gap-1 pl-1.5 ${pressed ? "text-amber-700" : "text-stone-700"}`}>
<span className="font-display text-[13px] font-bold tabular-nums">{star}</span>
<svg aria-hidden="true" viewBox="0 0 20 20" className="h-3 w-3" fill="currentColor">
<path d="M10 1.5l2.6 5.4 5.9.8-4.3 4.1 1 5.9L10 14.9l-5.2 2.8 1-5.9L1.5 7.7l5.9-.8z" />
</svg>
<span className="sr-only">の評価</span>
</span>
<span aria-hidden="true" className="relative h-2 overflow-hidden bg-stone-100">
<span
className={`absolute inset-0 origin-left animate-[rrs-grow_800ms_cubic-bezier(0.2,0.7,0.2,1)_both] motion-reduce:animate-none ${
pressed ? "bg-amber-600" : "bg-stone-800 group-hover:bg-amber-600"
}`}
style={
{
"--rrs-r": ratio,
transform: "scaleX(var(--rrs-r))",
animationDelay: `${120 + i * 70}ms`,
} as CSSProperties
}
/>
</span>
<span className="text-right font-display text-[13px] font-semibold tabular-nums">
{count}
<span className="sr-only">件</span>
</span>
<span aria-hidden="true" className="text-right font-display text-[12px] text-stone-400 tabular-nums">
{Math.round(ratio * 100)}%
</span>
</button>
</li>
);
})}
</ul>
<p className="mt-7 border-l-2 border-amber-600 pl-3 text-[12px] leading-[1.8] text-stone-500">
デモ用の架空の口コミです。店舗・評価・投稿内容はすべて実在しません。
</p>
</div>
{/* 抜粋(絞り込み結果) */}
<div className="@container min-w-0">
<div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-3 border-b border-stone-900 pb-3">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<p role="status" aria-live="polite" className="text-[14px] font-bold">
{filter === null ? "すべての口コミ" : `星${filter}の口コミ`}{" "}
<span className="font-display tabular-nums">{results.length}</span>件
<span className="ml-2 text-[12px] font-medium text-stone-500">
{filter !== null && `全${stats.total}件中・`}
{sortLabel}
</span>
</p>
{filter !== null && (
<button
type="button"
onClick={clear}
className="border border-stone-300 px-2.5 py-1 text-[12px] font-bold text-stone-700 hover:border-stone-900 hover:text-stone-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
>
絞り込みを解除
</button>
)}
</div>
<div role="group" aria-label="並び順" className="flex border border-stone-300">
{SORTS.map((s) => (
<button
key={s.key}
type="button"
aria-pressed={sort === s.key}
onClick={() => {
setSort(s.key);
setShown(PAGE);
}}
className={`px-3 py-1.5 text-[12px] font-bold focus-visible:relative focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 ${
sort === s.key ? "bg-stone-900 text-white" : "bg-white text-stone-600 hover:text-stone-900"
}`}
>
{s.label}
</button>
))}
</div>
</div>
<ul id={listId} aria-label="口コミの抜粋" className="list-none divide-y divide-stone-200 p-0">
{visible.map((r) => (
<li
key={r.id}
ref={setItemRef(r.id)}
tabIndex={-1}
className="grid gap-x-6 gap-y-2 py-6 outline-none focus-visible:bg-amber-50/60 @md:grid-cols-[8.5rem_minmax(0,1fr)]"
>
<div className="flex items-center gap-3 @md:block">
<Stars value={r.rating} size="h-3.5" />
<p className="text-[12px] font-medium text-stone-500 @md:mt-2">
<span className="sr-only">評価 5段階中{r.rating}、</span>
<time dateTime={r.date}>{formatMonth(r.date)}</time>
</p>
</div>
<div>
<h3 className="text-[15px] leading-[1.6] font-bold">{r.title}</h3>
<p className="mt-1.5 text-[14px] leading-[1.9] text-stone-700">{r.body}</p>
<p className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-[12px] text-stone-500">
<span className="font-bold text-amber-700">
<span className="sr-only">利用したサービス:</span>
{r.service}
</span>
<span aria-hidden="true" className="h-3 w-px bg-stone-300" />
<span>{r.who}</span>
</p>
</div>
</li>
))}
</ul>
{rest > 0 && (
<div className="border-t border-stone-200 pt-5">
<button
type="button"
onClick={showMore}
aria-controls={listId}
className="w-full border border-stone-300 py-3 text-[13px] font-bold text-stone-800 hover:border-stone-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
>
続きの口コミを表示(残り<span className="font-display tabular-nums">{rest}</span>件)
</button>
</div>
)}
</div>
</div>
</section>
);
}
shadcn CLI でプロジェクトに追加
npx shadcn@latest add https://designs.first-ch.com/r/review-rating-summary.json