BLK-102Blocks

Review Rating Summary — Star Distribution Bars with Filtering

A customer-review block for sites with many reviews, letting visitors explore them through the average rating and a 5-to-1 star distribution. The average, the total, and each star's count and share are all computed from the same review data, so the numbers can never drift from the reviews themselves. Every distribution bar is a filter button: press it to list only reviews with that rating, and press it again or use "Clear filter" to return to all reviews. The active bar carries aria-pressed, and the result count and sort order are announced in one sentence. Star shapes are hidden from screen readers, which hear only a value such as "4 out of 5", so no rating is read twice. Each excerpt shows the month it was posted and the service used, and the list can be sorted newest first or highest rated first. More reviews load five at a time, with focus moving to the first new one. The bars grow with CSS alone and appear at their final length under prefers-reduced-motion. Grouping uses square hairline rules, with no new dependencies. All reviews shown are fictional.

Added:
2026-10-22
Dependencies:
None
tags
#block #testimonial #review #rating #star-rating #filter #sort #aria-pressed #aria-live #a11y #hairline #no-image #no-dependency

Preview

ご利用いただいた方の評価

平均 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>
  );
}

Add to your project via shadcn CLI

npx shadcn@latest add https://designs.first-ch.com/r/review-rating-summary.json