Oteto Blogのロゴ

【React】5段階の星評価(star rating)をCSS・SVGで実装する

やりたいこと

Googleの検索結果(リッチリザルト)などでよく見る、5段階評価のスコアに応じて星が満ち欠けするUI

Googleの検索結果(リッチリザルト)などでよく見る、5段階評価のスコアに応じて星が満ち欠けするUI。

口コミサイトなどに限らず個人ブログなどでも何かと使う機会が多いと思うので、今回は下記の要件で実装してみる。

  • 使い回したいので、Reactコンポーネントとして実装する
  • 小数点対応(端数の部分も満ち欠けに反映される)

実装方法

1. CSSのみで実装する方法

import styles from "./starRating.module.scss";

type Props = { score: number; showsScore?: boolean };
const MAX_STAR_COUNT = 5;

export default function StarRating({ score, showsScore = true }: Props) {
  return (
    <div className={styles.module}>
      <div
        className={styles.starsBg}
        role="img"
        aria-label={`${MAX_STAR_COUNT}点中${score}点の評価`}
      >
        <div
          className={styles.stars}
          style={
            score >= MAX_STAR_COUNT
              ? undefined
              : { width: `${(score / MAX_STAR_COUNT) * 100}%` }
          }
        ></div>
      </div>
      {showsScore && <span className={styles.score}>{score.toFixed(1)}</span>}
    </div>
  );
}
.module {
  display: flex;
  align-items: center;
  column-gap: 0.5em;
}

.starsBg,
.stars {
  background-repeat: repeat-x;
}

.starsBg {
  width: 5.4rem;
  height: 1rem;
  overflow: hidden;
  background-image: url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20110%20100%22%3E%3Cpath%20d%3D%22M100%2040.4c0-5.1-4-9.3-8.9-9.3h-25L58.5%206.4C57%201.6%2052-1.1%2047.3.4c-2.7.9-4.9%203.2-5.7%206l3.8%201.3-3.8-1.3L34%2031.1H9c-3.9%200-7.3%202.6-8.5%206.4s.1%208%203.3%2010.4L24%2063.1l-7.8%2024.7c-1.2%203.8.1%208%203.2%2010.4s7.4%202.4%2010.5%200L50%2082.8l20.1%2015.4c3.1%202.4%207.4%202.4%2010.5%200%203.1-2.4%204.4-6.6%203.2-10.4L76%2063.1%2096.3%2048c2.3-1.8%203.7-4.6%203.7-7.6zm-8.4.7-22.7%2017c-1.4%201.1-2%203-1.5%204.7l8.7%2027.6v.3c0%20.3-.1.5-.4.7-.3.2-.7.2-1%200L52.4%2074.2c-1.4-1.1-3.4-1.1-4.8%200L25.1%2091.4c-.4.3-.9.2-1.1-.2-.1-.1-.2-.3-.2-.5v-.3l8.7-27.6c.5-1.8%200-3.7-1.5-4.7l-22.7-17c-.2-.2-.4-.4-.4-.7v-.3c.1-.3.4-.6.8-.6h27.9c1.8%200%203.3-1.2%203.8-2.9L49.2%209c.1-.4.6-.7%201-.5.3.1.4.3.5.5l8.5%2027.6c.5%201.8%202.1%202.9%203.8%202.9h27.9c.4%200%20.7.2.8.6v.3c.2.3.1.5-.1.7z%22%20fill%3D%22%23ffb200%22%3E%3C%2Fpath%3E%3C%2Fsvg%3E");
}

.stars {
  height: 100%;
  background-image: url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20110%20100%22%3E%3Cpath%20d%3D%22M99.6%2037.6c-1.2-3.8-4.6-6.4-8.5-6.4h-25L58.5%206.4C57%201.6%2052-1.1%2047.3.5c-2.7.9-4.9%203.2-5.7%206L34%2031.2H9c-3.9%200-7.3%202.6-8.5%206.4-1.2%203.8.1%208%203.3%2010.4L24%2063.1l-7.8%2024.7c-1.2%203.8.1%208%203.2%2010.4%203.1%202.4%207.4%202.4%2010.5%200L50%2082.8l20.1%2015.4c3.1%202.4%207.4%202.4%2010.5%200%203.1-2.4%204.4-6.6%203.2-10.4L76%2063.1%2096.3%2048c2.3-1.8%203.7-4.6%203.7-7.6%200-.9-.1-1.9-.4-2.8z%22%20fill%3D%22%23ffb200%22%3E%3C%2Fpath%3E%3C%2Fsvg%3E");
}

.score {
  line-height: 1;
}
  • 「5つの空の星が並んだ画像」を背景に敷き、「5つの星が並んだ画像」をスコアに応じて幅を調整しつつその上に乗せるという方法で実装
    • 画像はイラレで加工したものをbase64形式に書き出し、直接CSSに埋め込んでいる
  • <img>を使えばaltを使えるが今回はCSS側で画像を読み込んでいるため、スコアであることを示すべくrolearia-labelも指定
export default function SampleComponent() {
  return <StarRating score={3.5} />;
}

あとは上記のようにスコアをpropsに指定して呼び出すだけ。

端数の部分も欠けて正常に表示された

端数の0.5の部分も欠けて正常に表示された。

2. SVGで描画する方法

import styles from "./starRating.module.scss";

type Props = { score: number; showsScore?: boolean };
const MAX_STAR_COUNT = 5;

export default function StarRating({ score, showsScore = true }: Props) {
  const starPaths: JSX.Element[] = [];

  for (let i = 0; i < MAX_STAR_COUNT; i++) {
    if (i < Math.floor(score)) {
      starPaths.push(createStarPath(10, i)); // 100%の星
      continue;
    }
    if (i > Math.floor(score)) {
      starPaths.push(createStarPath(0, i)); // 0%の星
      continue;
    }

    const scoreFraction = Math.round((score - Math.trunc(score)) * 10); // 小数第一位以下の値
    starPaths.push(createStarPath(scoreFraction, i));
  }

  return (
    <div className={styles.module}>
      <svg
        className={styles.stars}
        xmlns="http://www.w3.org/2000/svg"
        viewBox="0 0 540 100"
        aria-label={`${MAX_STAR_COUNT}点中${score}点の評価`}
      >
        {starPaths}
      </svg>
      {showsScore && <span className={styles.score}>{score.toFixed(1)}</span>}
    </div>
  );
}

/**
 * 星1つのpathを生成
 */
function createStarPath(percent: number, index: number) {
  if (percent < 0 || percent > 10) return <></>;

  return (
    <path
      className={styles.star}
      d={dValues[percent]}
      {...(index !== 0 && {
        transform: `translate(${index * 100 + 10 * index})`,
      })}
      key={index}
    />
  );
}

const dValues = [
  "M100 40.4c0-5.1-4-9.3-8.9-9.3h-25L58.5 6.4C57 1.6 52-1.1 47.3.4c-2.7.9-4.9 3.2-5.7 6l3.8 1.3-3.8-1.3L34 31.1H9c-3.9 0-7.3 2.6-8.5 6.4s.1 8 3.3 10.4L24 63.1l-7.8 24.7c-1.2 3.8.1 8 3.2 10.4s7.4 2.4 10.5 0L50 82.8l20.1 15.4c3.1 2.4 7.4 2.4 10.5 0 3.1-2.4 4.4-6.6 3.2-10.4L76 63.1 96.3 48c2.3-1.8 3.7-4.6 3.7-7.6zm-8.4.7-22.7 17c-1.4 1.1-2 3-1.5 4.7l8.7 27.6v.3c0 .3-.1.5-.4.7-.3.2-.7.2-1 0L52.4 74.2c-1.4-1.1-3.4-1.1-4.8 0L25.1 91.4c-.4.3-.9.2-1.1-.2-.1-.1-.2-.3-.2-.5v-.3l8.7-27.6c.5-1.8 0-3.7-1.5-4.7l-22.7-17c-.2-.2-.4-.4-.4-.7v-.3c.1-.3.4-.6.8-.6h27.9c1.8 0 3.3-1.2 3.8-2.9L49.2 9c.1-.4.6-.7 1-.5.3.1.4.3.5.5l8.5 27.6c.5 1.8 2.1 2.9 3.8 2.9h27.9c.4 0 .7.2.8.6v.3c.2.3.1.5-.1.7z",
  "M91.1 31.1h-25L58.5 6.4C57 1.6 52-1.1 47.3.4c-2.7.9-4.9 3.2-5.7 6L34 31.1H9c-3.9 0-7.3 2.6-8.5 6.4s.1 8 3.3 10.4L24 63.1l-7.8 24.7c-1.2 3.8.1 8 3.2 10.4 3.1 2.4 7.4 2.4 10.5 0L50 82.8l20.1 15.4c3.1 2.4 7.4 2.4 10.5 0 3.1-2.4 4.4-6.6 3.2-10.4L76 63.1 96.3 48c2.3-1.8 3.7-4.6 3.7-7.6 0-5.1-4-9.3-8.9-9.3zm.5 10-22.7 17c-1.4 1.1-2 3-1.5 4.7l8.7 27.6v.3c0 .3-.1.5-.4.7-.3.2-.7.2-1 0L52.4 74.2c-1.4-1.1-3.4-1.1-4.8 0L25.1 91.4c-.4.3-.9.2-1.1-.2-.1-.1-.2-.3-.2-.5v-.3l8.7-27.6c.5-1.8 0-3.7-1.5-4.7l-22.7-17-.2-.2 8.3 6.2v-7.6h20.2c1.8 0 3.3-1.2 3.8-2.9L49.2 9c.1-.4.6-.7 1-.5.3.1.4.3.5.5l8.5 27.6c.5 1.8 2.1 2.9 3.8 2.9h27.9c.4 0 .7.2.8.6v.3c.2.3.1.5-.1.7z",
  "M91.1 31.1h-25L58.5 6.4C57 1.6 52-1.1 47.3.4c-2.7.9-4.9 3.2-5.7 6L34 31.1H9c-3.9 0-7.3 2.6-8.5 6.4s.1 8 3.3 10.4L24 63.1l-7.8 24.7c-1.2 3.8.1 8 3.2 10.4 3.1 2.4 7.4 2.4 10.5 0L50 82.8l20.1 15.4c3.1 2.4 7.4 2.4 10.5 0 3.1-2.4 4.4-6.6 3.2-10.4L76 63.1 96.3 48c2.3-1.8 3.7-4.6 3.7-7.6 0-5.1-4-9.3-8.9-9.3zm.5 10-22.7 17c-1.4 1.1-2 3-1.5 4.7l8.7 27.6v.3c0 .3-.1.5-.4.7-.3.2-.7.2-1 0L52.4 74.2c-1.4-1.1-3.4-1.1-4.8 0L25.1 91.4c-.4.3-.9.2-1.1-.2-.1-.1-.2-.3-.2-.5v-.3l8.7-27.6c.5-1.8 0-3.7-1.5-4.7l-22.7-17-.2-.2 16.6 12.4V39.5h11.9c1.8 0 3.3-1.2 3.8-2.9L49.2 9c.1-.4.6-.7 1-.5.3.1.4.3.5.5l8.5 27.6c.5 1.8 2.1 2.9 3.8 2.9h27.9c.4 0 .7.2.8.6v.3c.2.3.1.5-.1.7z",
  "M91.1 31.1h-25L58.5 6.4C57 1.6 52-1.1 47.3.4c-2.7.9-4.9 3.2-5.7 6L34 31.1H9c-3.9 0-7.3 2.6-8.5 6.4s.1 8 3.3 10.4L24 63.1l-7.8 24.7c-1.2 3.8.1 8 3.2 10.4 3.1 2.4 7.4 2.4 10.5 0L50 82.8l20.1 15.4c3.1 2.4 7.4 2.4 10.5 0 3.1-2.4 4.4-6.6 3.2-10.4L76 63.1 96.3 48c2.3-1.8 3.7-4.6 3.7-7.6 0-5.1-4-9.3-8.9-9.3zM24.8 91.5l.1-.1v.1c-.1 0-.2.1-.2.1l.1-.1zm66.8-50.4-22.7 17c-1.4 1.1-2 3-1.5 4.7l8.7 27.6v.3c0 .3-.1.5-.4.7-.3.2-.7.2-1 0L52.4 74.2c-1.4-1.1-3.4-1.1-4.8 0L33.3 85.1V39.5h3.3c1.8 0 3.3-1.2 3.8-2.9L49.2 9c.1-.4.6-.7 1-.5.3.1.4.3.5.5l8.5 27.6c.5 1.8 2.1 2.9 3.8 2.9h27.9c.4 0 .7.2.8.6v.3c.2.3.1.5-.1.7z",
  "M91.1 31.1h-25L58.5 6.4C57 1.6 52-1.1 47.3.4c-2.7.9-4.9 3.2-5.7 6L34 31.1H9c-3.9 0-7.3 2.6-8.5 6.4s.1 8 3.3 10.4L24 63.1l-7.8 24.7c-1.2 3.8.1 8 3.2 10.4 3.1 2.4 7.4 2.4 10.5 0L50 82.8l20.1 15.4c3.1 2.4 7.4 2.4 10.5 0 3.1-2.4 4.4-6.6 3.2-10.4L76 63.1 96.3 48c2.3-1.8 3.7-4.6 3.7-7.6 0-5.1-4-9.3-8.9-9.3zM24.4 91.5h.3c0 .1-.1.1-.3 0zm67.2-50.4-22.7 17c-1.4 1.1-2 3-1.5 4.7l8.7 27.6v.3c0 .3-.1.5-.4.7-.3.2-.7.2-1 0L52.4 74.2c-1.4-1.1-3.4-1.1-4.8 0l-5.8 4.4V32.2L49.2 9c.1-.4.6-.7 1-.5.3.1.4.3.5.5l8.5 27.6c.5 1.8 2.1 2.9 3.8 2.9h27.9c.4 0 .7.2.8.6v.3c.2.3.1.5-.1.7z",
  "M91.1 31.1h-25L58.5 6.4C57 1.6 52-1.1 47.3.4c-2.7.9-4.9 3.2-5.7 6L34 31.1H9c-3.9 0-7.3 2.6-8.5 6.4s.1 8 3.3 10.4L24 63.1l-7.8 24.7c-1.2 3.8.1 8 3.2 10.4 3.1 2.4 7.4 2.4 10.5 0L50 82.8l20.1 15.4c3.1 2.4 7.4 2.4 10.5 0 3.1-2.4 4.4-6.6 3.2-10.4L76 63.1 96.3 48c2.3-1.8 3.7-4.6 3.7-7.6 0-5.1-4-9.3-8.9-9.3zm.5 10-22.7 17c-1.4 1.1-2 3-1.5 4.7l8.7 27.6v.3c0 .3-.1.5-.4.7-.3.2-.7.2-1 0L52.4 74.2c-.6-.5-1.4-.8-2.2-.8l-.1-64.9h.1c.3.1.4.3.5.5l8.5 27.6c.5 1.8 2.1 2.9 3.8 2.9h27.9c.4 0 .7.2.8.6v.3c.2.3.1.5-.1.7z",
  "M91.1 31.1h-25L58.5 6.4C57 1.6 52-1.1 47.3.4c-2.7.9-4.9 3.2-5.7 6L34 31.1H9c-3.9 0-7.3 2.6-8.5 6.4s.1 8 3.3 10.4L24 63.1l-7.8 24.7c-1.2 3.8.1 8 3.2 10.4 3.1 2.4 7.4 2.4 10.5 0L50 82.8l20.1 15.4c3.1 2.4 7.4 2.4 10.5 0 3.1-2.4 4.4-6.6 3.2-10.4L76 63.1 96.3 48c2.3-1.8 3.7-4.6 3.7-7.6 0-5.1-4-9.3-8.9-9.3zM25.7 84.5l-1.9 6.1v-.2l1.9-5.9zm65.9-43.4-22.7 17c-1.4 1.1-2 3-1.5 4.7l8.7 27.6v.3c0 .3-.1.5-.4.7-.3.2-.7.2-1 0L58.3 78.8V33.7l.9 2.9c.5 1.8 2.1 2.9 3.8 2.9h27.9c.4 0 .7.2.8.6v.3c.2.3.1.5-.1.7z",
  "M91.1 31.1h-25L58.5 6.4C57 1.6 52-1.1 47.3.4c-2.7.9-4.9 3.2-5.7 6L34 31.1H9c-3.9 0-7.3 2.6-8.5 6.4s.1 8 3.3 10.4L24 63.1l-7.8 24.7c-1.2 3.8.1 8 3.2 10.4 3.1 2.4 7.4 2.4 10.5 0L50 82.8l20.1 15.4c3.1 2.4 7.4 2.4 10.5 0 3.1-2.4 4.4-6.6 3.2-10.4L76 63.1 96.3 48c2.3-1.8 3.7-4.6 3.7-7.6 0-5.1-4-9.3-8.9-9.3zm.5 10-22.7 17c-1.4 1.1-2 3-1.5 4.7l8.7 27.6v.3c0 .3-.1.5-.4.7-.3.2-.7.2-1 0l-8.1-6.2V39.5h24.3c.4 0 .7.2.8.6v.3c.2.3.1.5-.1.7z",
  "M91.1 31.1h-25L58.5 6.4C57 1.6 52-1.1 47.3.4c-2.7.9-4.9 3.2-5.7 6L34 31.1H9c-3.9 0-7.3 2.6-8.5 6.4s.1 8 3.3 10.4L24 63.1l-7.8 24.7c-1.2 3.8.1 8 3.2 10.4 3.1 2.4 7.4 2.4 10.5 0L50 82.8l20.1 15.4c3.1 2.4 7.4 2.4 10.5 0 3.1-2.4 4.4-6.6 3.2-10.4L76 63.1 96.3 48c2.3-1.8 3.7-4.6 3.7-7.6 0-5.1-4-9.3-8.9-9.3zm-31.9 5.5c0 .2.1.3.2.5-.2-.3-.4-.7-.5-1.1l-5.3-17.7 5.6 18.3zm16.9 53.8v.5l-8.6-27.6c-.1-.3-.1-.6-.2-.9 0 .1 0 .3.1.4l8.7 27.6zm15.5-49.3L75.2 53.4V39.5h15.7c.4 0 .7.2.8.6v.3c.2.3.1.5-.1.7z",
  "M91.1 31.1h-25L58.5 6.4C57 1.6 52-1.1 47.3.4c-2.7.9-4.9 3.2-5.7 6L34 31.1H9c-3.9 0-7.3 2.6-8.5 6.4s.1 8 3.3 10.4L24 63.1l-7.8 24.7c-1.2 3.8.1 8 3.2 10.4 3.1 2.4 7.4 2.4 10.5 0L50 82.8l20.1 15.4c3.1 2.4 7.4 2.4 10.5 0 3.1-2.4 4.4-6.6 3.2-10.4L76 63.1 96.3 48c2.3-1.8 3.7-4.6 3.7-7.6 0-5.1-4-9.3-8.9-9.3zm.5 10L79.7 50V39.5h11.2c.4 0 .7.2.8.6v.3c.2.3.1.5-.1.7z",
  "M99.6 37.6c-1.2-3.8-4.6-6.4-8.5-6.4h-25L58.5 6.4C57 1.6 52-1.1 47.3.5c-2.7.9-4.9 3.2-5.7 6L34 31.2H9c-3.9 0-7.3 2.6-8.5 6.4-1.2 3.8.1 8 3.3 10.4L24 63.1l-7.8 24.7c-1.2 3.8.1 8 3.2 10.4 3.1 2.4 7.4 2.4 10.5 0L50 82.8l20.1 15.4c3.1 2.4 7.4 2.4 10.5 0 3.1-2.4 4.4-6.6 3.2-10.4L76 63.1 96.3 48c2.3-1.8 3.7-4.6 3.7-7.6 0-.9-.1-1.9-.4-2.8z",
];
.module {
  display: flex;
  align-items: center;
  column-gap: 0.5em;
}

.stars {
  height: 1rem;
}

.star {
  fill: #ffb200;
}

.score {
  line-height: 1;
}

あらかじめ0〜100%の11パターン(10%刻み)のパスにおけるd属性の値を列挙しておき、割合に応じて星の<path>を生成している。

渡した数値に応じて満ち欠けした星が表示された

こちらも同じように、渡した数値に応じて満ち欠けした星が表示された。

星1つ1つをインラインSVGで実装しているので細かいスタイルを変更しやすく、前者の画像を使う方法と比べると色々融通が効きやすいというメリットがある。

しかし星の複雑なパスを表現する文字列が含まれる分DOMが肥大化してしまうというデメリットもあるため、ページ各所でこのコンポーネントを使い回す場合は前者の方が良さそうに思える。