本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
フォーム

評価の星が順に灯って弾む

デモ

操作:カーソルを重ねる・クリック。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

用途
フォーム
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

1 つの星に、空・決定(グレージュ)・仮(淡いグレージュ)の 3 枚を重ねて描きます。星をなぞると左からその星まで淡く灯り、下の言葉も「不満」「普通」「満足」と替わります。押すと左から順にグレージュの星が弾んで灯り、星のマスごと小さく跳ねて、決まったことを知らせます。決めるまで送信ボタンは淡い灰にしておき、決めたら押せる墨色にします。

決定の星は 0.3 倍から 1 倍へ、0.42 秒の back.out(3) で行き過ぎさせて弾ませ、星ごとに 0.08 秒ずつずらします。マスの跳ねは 10px ほどで、上がって戻る形の自前のイージング sin(πp) を 1 本のトゥイーンにかけるので、終わりで位置が飛びません。数を減らしたときは、余った星を 0.6 倍に縮めながら消します。

決めた数と違う星をなぞっている間は、いちばん上の仮の星で決めた星を覆い、なぞった数だけを淡く灯します。決めた星の上に足すだけだと、5 つ決めたあとで 2 つ目をなぞっても 5 つ光ったままで、減らす下見ができません。3 枚は position: relative で書いた順に重ねます。GSAP で拡大した星は transform が付き、何もしないとほかの 2 枚より上に描かれるためです。決定の星は visibility: hidden だけでなく opacity: 0 でも隠しておきます。透明度が 1 のままだと、消すトゥイーンが 1 から 0 へ動かし、隠れていた星が一瞬光ります。

星は radio のまとまりで作り、見えなくした input を label で包みます。クリックでも ← → キーでも change が届き、フォームの送信もそのまま使えます。radio の名前は「星4つ(満足)」のように言葉まで含め、下の言葉の行は読み上げから外します。星の間のすきまも星の列に含めておくと、星から星へ移る間に灯りが途切れません。動きを減らす設定では跳ねさせず、灯すのもほぼ一瞬で済ませます。

星の色は中身として持ち、くすんだグレージュ 1 色の濃淡で、空・仮・決定を明るさの差で見分けます。カード・言葉・ボタンなど周りの UI は無彩色にします。ずらす間隔を縮めると一斉に灯り、広げると数えるように灯ります。宿泊や商品、アプリのレビュー、満足度のアンケートに向いています。

コード

HTML
<!-- 星は radio のまとまり。キーボード(← →)・読み上げ・送信は radio がそのまま受け持つ -->
<form class="review" action="/review/" method="post">
  <fieldset class="rating">
    <legend class="rating__legend">ご滞在はいかがでしたか?</legend>
    <!-- 星の形は 1 か所に書き、use で使い回す -->
    <svg width="0" height="0" aria-hidden="true" style="position: absolute">
      <symbol id="rating-star" viewBox="0 0 24 24">
        <path d="M12 2.4L14.53 9.12L21.7 9.45L16.09 13.93L18 20.85L12 16.9L6 20.85L7.91 13.93L2.3 9.45L9.47 9.12Z" />
      </symbol>
    </svg>
    <div class="rating__stars">
      <!-- 1 つの星に、空・決定(GSAP で弾ませる)・仮(なぞっている間)の 3 枚を重ねる -->
      <label class="rating__star">
        <input class="rating__input" type="radio" name="rating" value="1" aria-label="星1つ(とても不満)" data-word="とても不満">
        <svg class="rating__empty" aria-hidden="true"><use href="#rating-star" /></svg>
        <svg class="rating__lit" aria-hidden="true"><use href="#rating-star" /></svg>
        <svg class="rating__preview" aria-hidden="true"><use href="#rating-star" /></svg>
      </label>
      <label class="rating__star">
        <input class="rating__input" type="radio" name="rating" value="2" aria-label="星2つ(不満)" data-word="不満">
        <svg class="rating__empty" aria-hidden="true"><use href="#rating-star" /></svg>
        <svg class="rating__lit" aria-hidden="true"><use href="#rating-star" /></svg>
        <svg class="rating__preview" aria-hidden="true"><use href="#rating-star" /></svg>
      </label>
      <label class="rating__star">
        <input class="rating__input" type="radio" name="rating" value="3" aria-label="星3つ(普通)" data-word="普通">
        <svg class="rating__empty" aria-hidden="true"><use href="#rating-star" /></svg>
        <svg class="rating__lit" aria-hidden="true"><use href="#rating-star" /></svg>
        <svg class="rating__preview" aria-hidden="true"><use href="#rating-star" /></svg>
      </label>
      <label class="rating__star">
        <input class="rating__input" type="radio" name="rating" value="4" aria-label="星4つ(満足)" data-word="満足">
        <svg class="rating__empty" aria-hidden="true"><use href="#rating-star" /></svg>
        <svg class="rating__lit" aria-hidden="true"><use href="#rating-star" /></svg>
        <svg class="rating__preview" aria-hidden="true"><use href="#rating-star" /></svg>
      </label>
      <label class="rating__star">
        <input class="rating__input" type="radio" name="rating" value="5" aria-label="星5つ(とても満足)" data-word="とても満足">
        <svg class="rating__empty" aria-hidden="true"><use href="#rating-star" /></svg>
        <svg class="rating__lit" aria-hidden="true"><use href="#rating-star" /></svg>
        <svg class="rating__preview" aria-hidden="true"><use href="#rating-star" /></svg>
      </label>
    </div>
    <!-- 言葉は目で見る人向け。読み上げには、radio の名前が同じ言葉を届ける -->
    <p class="rating__word" aria-hidden="true">星を選んで評価してください</p>
  </fieldset>
  <button class="review__submit" type="submit" disabled>レビューを投稿する</button>
</form>

CSS
.review {
  --ink: #262626;            /* 文字と押せるボタン */
  --mute: #898989;           /* 案内の文 */
  --off: #c8c7c3;            /* まだ押せないボタン */
  --empty: #f2f1ed;          /* 空の星 */
  --empty-edge: #d9d8d3;
  --preview: #d6ccbe;        /* なぞっている間の淡い星 */
  --preview-edge: #b6a289;
  --lit: #a28767;            /* 決めた星 */
  --lit-edge: #8f785d;
  --star: 40px;              /* 星の大きさ */
  display: grid;
  gap: 24px;
  max-width: 464px;
  color: var(--ink);
}

.rating { min-width: 0; margin: 0; padding: 0; border: 0; text-align: center; }
.rating__legend { padding: 0; margin: 0 auto 20px; font-size: 20px; font-weight: 700; }

/* 星の間のすきまも列の中なので、星から星へ移る間に灯りが途切れない */
.rating__stars { display: inline-flex; gap: 12px; vertical-align: top; }

.rating__star { position: relative; display: grid; width: var(--star); height: var(--star); cursor: pointer; }

/* 3 枚とも relative にして、重なり順を書いた順にそろえる(GSAP で transform の付いた星だけが上に出ないように) */
.rating__star svg {
  position: relative;
  grid-area: 1 / 1;
  width: var(--star);
  height: var(--star);
  overflow: visible;
  stroke-width: 1.3;
  stroke-linejoin: round;
}

.rating__empty { fill: var(--empty); stroke: var(--empty-edge); }
/* 透明度も 0 にしておく(GSAP が消すときに、1 から 0 へ動かして一瞬見せてしまわない) */
.rating__lit { fill: var(--lit); stroke: var(--lit-edge); opacity: 0; visibility: hidden; }

/* 仮の星はいちばん上。なぞっている間だけ出し、決めた星を覆って、なぞった数だけを淡く灯す */
.rating__preview { fill: var(--empty); stroke: var(--empty-edge); visibility: hidden; }
.rating.is-previewing .rating__preview { visibility: visible; }
.rating__star.is-on .rating__preview { fill: var(--preview); stroke: var(--preview-edge); }

/* radio は見えなくするだけにして、焦点と読み上げは残す */
.rating__input { position: absolute; inset: 0; margin: 0; opacity: 0; cursor: inherit; }
.rating__input:focus-visible ~ .rating__empty { outline: 2px solid var(--ink); outline-offset: 4px; border-radius: 4px; }

.rating__word { min-height: 32px; margin: 12px 0 0; font-size: 16px; line-height: 32px; color: var(--mute); }
.rating__word.is-set { color: var(--ink); font-weight: 700; }

.review__submit {
  height: 48px;
  border: 0;
  border-radius: 999px;
  background-color: var(--ink);
  color: #ffffff;
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  letter-spacing: 0.04em;
  cursor: pointer;
  transition: background-color 0.3s;
}

.review__submit:disabled { background-color: var(--off); cursor: default; }
.review__submit:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }

@media (prefers-reduced-motion: reduce) {
  .review__submit { transition: none; }
}

JavaScript
import { gsap } from "gsap";

const STAGGER = 0.08; // 星ごとのずれ(秒)
const HOP = 10; // マスが跳ねる高さ(px)
const hop = (p) => Math.sin(Math.PI * p); // 上がって戻る(p が 0 と 1 のとき 0)
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

document.querySelectorAll(".review").forEach((review) => {
  const group = review.querySelector(".rating");
  const word = review.querySelector(".rating__word");
  const submit = review.querySelector(".review__submit");
  const hint = word.textContent;
  const stars = [...review.querySelectorAll(".rating__star")].map((star) => ({
    star,
    input: star.querySelector(".rating__input"),
    lit: star.querySelector(".rating__lit"),
  }));
  const lits = stars.map((s) => s.lit);
  gsap.set(lits, { transformOrigin: "50% 55%" }); // 星の形の中心から弾ませる

  let rating = 0;
  let hovered = 0;
  let anim = null;

  // 決めた数と違う星をなぞっている間は、仮の星(いちばん上)でなぞった数だけを淡く灯す
  const paint = () => {
    const previewing = hovered > 0 && hovered !== rating;
    const shown = previewing ? hovered : rating;
    group.classList.toggle("is-previewing", previewing);
    stars.forEach(({ star }, i) => star.classList.toggle("is-on", i < hovered));
    word.textContent = shown ? stars[shown - 1].input.dataset.word : hint;
    word.classList.toggle("is-set", !previewing && rating > 0);
  };

  // 決めた数に合わせて、増えた星を左から順に弾ませて灯し、減った星は縮めて消す。決めた数までのマスが跳ねる
  const rate = (next) => {
    anim?.progress(1); // 前の動きを終わらせてから(同じ星を 2 本の動きで取り合わない)
    anim = gsap.timeline();
    if (next < lits.length) anim.to(lits.slice(next), { autoAlpha: 0, scale: 0.6, duration: 0.2, ease: "power2.in" }, 0);
    if (next > rating) {
      anim
        .fromTo(lits.slice(rating, next), { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.08, ease: "none", stagger: STAGGER }, 0)
        .fromTo(lits.slice(rating, next), { scale: 0.3 }, { scale: 1, duration: 0.42, ease: "back.out(3)", stagger: STAGGER }, 0);
    }
    // 動きを減らす設定では跳ねさせず、灯すのもほぼ一瞬で済ませる
    if (!reduceMotion) anim.fromTo(stars.slice(0, next).map((s) => s.star), { y: 0 }, { y: -HOP, duration: 0.4, ease: hop, stagger: STAGGER }, 0);
    else anim.timeScale(20);
    rating = next;
    submit.disabled = false;
    paint();
  };

  stars.forEach(({ star, input }, i) => {
    star.addEventListener("pointerenter", () => {
      hovered = i + 1;
      paint();
    });
    // クリックでも、← → キーでも、選ばれた radio から change が届く
    input.addEventListener("change", () => rate(i + 1));
  });
  group.querySelector(".rating__stars").addEventListener("pointerleave", () => {
    hovered = 0;
    paint();
  });

  // 最初から選ばれている(書きかけを読み込んだときなど)なら、動かさずに灯しておく
  const checked = stars.findIndex(({ input }) => input.checked);
  if (checked >= 0) {
    gsap.set(lits.slice(0, checked + 1), { autoAlpha: 1 });
    rating = checked + 1;
    submit.disabled = false;
    paint();
  }
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。