デモ
デモを読み込めませんでした。
仕様
- 用途
- フォーム
- 動作
- 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 は無彩色にします。ずらす間隔を縮めると一斉に灯り、広げると数えるように灯ります。宿泊や商品、アプリのレビュー、満足度のアンケートに向いています。
コード
<!-- 星は 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>
.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; }
}
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();
}
});