GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

별점 UI 9종 — 채움 값이 상태에 따라 바뀌는 방식

별점 UI는 별 아이콘으로 점수를 매기거나 매겨진 평균 점수를 보여주는 입력 장치인데, 아홉 개 중 하나는 손가락이 움직인 가로 값을 따라 채움 값이 바로 바뀌고, 하나는 평균 값의 소수점 부분이 별 하나의 채움 너비 값으로 겹쳐 나타나며, 또 하나는 같은 별을 다시 누른 상태가 되면

별 모양을 그리는 화면들(05 를 뺀 여덟 개)은 유니코드 글자 ★가 아니라 SVG path로 그린 도형이고, 05 는 별 모양 대신 점수 숫자 라벨과 막대로만 값을 보여줍니다. 평점·리뷰 개수는 데모용으로 미리 채워 둔 값이라 실제 브랜드나 인물을 가리키지 않습니다. 04의 "리뷰 추가" 버튼도 실 서버로 fetch 를 보내지 않고 로컬 배열(queue)에서 다음 값을 하나씩 꺼내 더할 뿐입니다. 그래도 아홉 개 전부 리스너는 pointerenter·pointerleave·pointermove·keydown·click·pointerdown·pointerup 같은 실제 사용자 입력 이벤트로 등록돼 있어, 자동 재생 중이라도 직접 별을 가리키거나 눌러 보는 순간 그 자리에서 자동 루프가 멎고 손으로 넣은 값으로 같은 함수가 다시 계산합니다. 자동 루프가 반복하는 값도 무작위가 아니라 고정된 배열을 순서대로 도는 결정적 시퀀스입니다.

아홉 개를 가르는 기준은 채움 값이 어디서 나오느냐입니다 — 포인터의 화면 위치 값이 채움 값을 그대로 정하는 것(01·02·09), 응답 개수를 집계한 값에서 채움 값이 나오는 것(04·05), 클릭이나 방향키로 상태가 바뀔 때마다 채움 값이 다시 계산되는 것(03·06·07·08)으로 나눴습니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

아래 표의 실측 값을 먼저 보면, css 별점 중에서도 05(개수 막대 분해)처럼 화면 전체가 함께 움직이는 항목과 04(평균 값 겹침 채움)처럼 별 한두 개만 조용히 바뀌는 항목이 격자 미리보기 단계에서부터 뚜렷이 갈립니다(정확한 수치는 아래 실측 표).

아홉 개의 해부 — 어떤 값이 언제 바뀌는가

실측 한눈에

항목 초점 누적면적% 강도 평균 움직인 프레임
01 호버 미리보기 채움 순서 값 → ratioOf() 재계산 3.94 75.6 21/23
02 반 칸 정밀 채움 가로 위치 값 → valueFromX() 재계산 4.03 153.2 23/23
03 방향키 값 이동 순환 값 → animateTo() 재계산 3.69 127.6 16/23
04 평균 값 겹침 채움 가중 평균 값 → average() 재계산 0.50 73.1 18/23
05 개수 막대 분해 비율 값 → total() 재계산 22.70 34.0 15/23
06 말풍선 이름표 교체 순서 값 → positionTip() 재계산 6.52 143.6 9/23
07 값별 색 전환 비율 값 → mix() 재계산 3.59 25.7 23/23
08 되돌림 스프링 초기화 채움 값 → reset() 재계산 4.79 108.7 9/23
09 터치 드래그 훑기 속도 값 → runInertia() 재계산 4.96 173.2 17/23

05 가 22.70%로 아홉 중 누적면적이 가장 커 막대 다섯 줄이 한 번에 길이 값을 다시 그린다는 뜻이고, 04 는 0.50%로 가장 작아 별 하나의 채움 너비만 좁게 바뀐다는 뜻입니다. 강도 평균은 09 가 173.2로 가장 높은 축에 들어 손가락이 지나간 자리와 배경의 픽셀 값 차이가 크고, 07 은 25.7로 가장 낮아 색이 서서히 섞이는 보간이라 픽셀 값 차이가 완만합니다. 움직인 프레임은 02·07 이 23/23으로 매 프레임 값을 다시 그려 손을 대는 내내 살아 있고, 06·08 은 9/23으로 가장 낮아 짧은 지연이 끝난 순간에만 값이 바뀝니다.

포인터 위치 값이 채움을 정하는 것 — 01·02·09

01 은 ratioOf()가 채움 개수 n을 전체 별 개수로 나눈 비율을 정수로 반올림해 화면 숫자로 그대로 보여주고, 포인터가 별 위에 놓인 동안만 previewTo()가 이 값을 미리 칠합니다. 02 는 valueFromX()가 각 별의 실제 렌더 폭(getBoundingClientRect)을 기준으로 포인터의 가로 좌표가 별 너비의 왼쪽 절반에 있는지 오른쪽 절반에 있는지를 갈라 정수 또는 0.5 단위로만 값을 스냅합니다. 09 는 onMove가 매 이동마다 속도(px/ms)를 재고, 손을 뗀 뒤에는 runInertia()가 그 속도를 감쇠시키며 위치 값을 계속 옮깁니다.

집계 값에서 채움이 나오는 것 — 04·05

04 는 average()가 별점별 응답 개수(counts)의 가중 평균을 구해 정수 부분만큼 별을 채우고, 남은 소수점 부분 값을 다음 별 하나의 채움 너비로 겹쳐 그립니다. 05 는 total()이 전체 응답 개수 합을 구한 뒤 각 점수의 개수를 그 합으로 나눈 비율 값을 막대 길이로 바꿔, 별점 다섯 줄이 각자 다른 길이로 늘어섭니다.

상태·순서 값이 바뀌며 다시 계산되는 것 — 03·06·07·08

03 은 방향키가 눌릴 때마다 목표 값(target)만 먼저 바뀌고, 화면에 보이는 값(display)은 매 프레임 그 차이의 20%씩만 따라가는 선형보간으로 뒤늦게 목표에 붙습니다. 06 은 가리키는 별의 순서 값이 바뀔 때마다 requestSwap()이 40ms 지연을 두고서야 이름표 문구를 교체합니다. 07 은 선택된 값(1~5)을 0~1 비율로 바꿔 두 색 토큰의 RGB 채널을 각각 선형보간하는 산술 혼합이라 HSL 색상환을 돌리는 방식과는 다릅니다. 08 은 같은 별이 다시 눌린 상태가 되면 채움 값이 0으로 떨어지는 동시에, 이미 채워졌던 별에만 스프링 이징이 걸린 클래스를 씌워 튕기듯 사라지게 합니다.

패턴별 코드

01호버 미리보기 채움 — 가리키는 별의 순서 값까지 채움 상태가 먼저 보였다가 벗어나면 원래 값으로 돌아가는 방식

포인터가 별 위에 놓인 상태가 되면 그 별까지의 순서 값만큼 채움 상태가 미리 나타나고, 포인터가 벗어난 상태가 되면 채움 값이 실제 선택된 값으로 되돌아갑니다. 상품 리뷰 작성 폼의 별점 입력이나 만족도 설문의 점수 선택에 맞는 형태입니다.

채움 개수를 비율 값으로 바꾸는 ratioOf() 와 paint()

// 계산 겹: 채움 개수 n 을 전체 별 개수로 나눈 비율(산술)을 그대로 화면 숫자로 보여준다 —
// 채움은 CSS 클래스가 아니라 이 비율 계산에서 나온 정수를 그대로 쓴다.
function ratioOf(n) { return Math.round((n / TOTAL) * 100); }

function paint(n, isPreview) {
  stars.forEach(function (s, idx) {
    s.classList.toggle('sr__star--filled', idx < n);
  });
  numEl.textContent = String(n);
  pctEl.textContent = String(ratioOf(n));
  readout.classList.toggle('sr__readout--preview', !!isPreview);
  readout.firstChild.textContent = isPreview ? '미리보기 ' : '선택 ';
}
호버 미리보기 채움 — 가리키는 별의 순서 값까지 채움 상태가 먼저 보였다가 벗어나면 원래 값으로 돌아가는 방식
새 창에서 열기

01 · 호버 미리보기 채움 — 가리키는 별의 순서 값까지 채움 상태가 먼저 보였다가 벗어나면 원래 값으로 돌아가는 방식 — 측정 누적면적 3.944% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

ratioOf(n)previewTo(i) / revertToValue()value = 3

02반 칸 정밀 채움 — 가로 위치 값이 별 너비의 절반을 넘었는지에 따라 채움 값이 정수·반칸 단위로 갈리는 방식

포인터의 가로 위치 값이 별 하나의 너비 값 중 어느 절반에 있는지에 따라 채움 값이 정수 값 또는 반 칸 값으로 나뉘어 정해집니다. 세밀한 평점을 남기는 리뷰 폼이나 별 다섯 개 기준 소수점 평점 입력에 맞는 형태입니다.

왼쪽·오른쪽 절반을 가르는 valueFromX()

// 계산 겹: 각 별의 실제 렌더 폭(getBoundingClientRect)을 기준으로 포인터가 왼쪽 절반에 있는지
// 오른쪽 절반에 있는지를 나눠, 정수 또는 .5 단위로만 값을 스냅한다.
function valueFromX(clientX) {
  for (var i = 0; i < stars.length; i++) {
    var r = stars[i].getBoundingClientRect();
    if (clientX < r.left) return i;
    if (clientX <= r.right) {
      var half = r.left + r.width / 2;
      return clientX < half ? i + 0.5 : i + 1;
    }
  }
  return stars.length;
}
반 칸 정밀 채움 — 가로 위치 값이 별 너비의 절반을 넘었는지에 따라 채움 값이 정수·반칸 단위로 갈리는 방식
새 창에서 열기

02 · 반 칸 정밀 채움 — 가로 위치 값이 별 너비의 절반을 넘었는지에 따라 채움 값이 정수·반칸 단위로 갈리는 방식 — 측정 누적면적 4.027% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

valueFromX(clientX)STAR_W = 44steps = [0.5, 1, 1.5, …, 5]

03방향키 값 이동 — 눌린 방향 값만큼 선택된 별의 순서 값이 하나씩 늘거나 줄어드는 방식

오른쪽이나 왼쪽 방향키가 눌릴 때마다 선택된 별의 순서 값이 하나씩 늘거나 줄고, 그 값이 범위의 양 끝을 넘어선 상태가 되면 끝 값에서 멈춥니다. 마우스 없이 쓰는 접근성 별점 입력이나 키보드로만 조작하는 설문 폼에 맞는 형태입니다.

목표 값을 20%씩 따라가는 tick() 과 animateTo()

// 계산 겹: 매 프레임 display 값을 target 값 쪽으로 20% 씩만 이동시키는 선형보간(lerp) —
// 방향키를 눌러도 값이 즉시 튀지 않고 관성 있는 곡선으로 따라간다.
function tick() {
  display += (target - display) * 0.2;
  if (Math.abs(target - display) < 0.01) {
    display = target;
    paint(display);
    raf = null;
    return;
  }
  paint(display);
  raf = requestAnimationFrame(tick);
}
function animateTo(t) {
  target = Math.max(0, Math.min(TOTAL, t));
  row.setAttribute('aria-valuenow', String(target));
  numEl.textContent = String(target);
  if (raf === null) raf = requestAnimationFrame(tick);
}
방향키 값 이동 — 눌린 방향 값만큼 선택된 별의 순서 값이 하나씩 늘거나 줄어드는 방식
새 창에서 열기

03 · 방향키 값 이동 — 눌린 방향 값만큼 선택된 별의 순서 값이 하나씩 늘거나 줄어드는 방식 — 측정 누적면적 3.694% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

tick() / animateTo(t)display += (target - display) * 0.2seq = [4, 5, 3, 1, 0, 2, 3]

04평균 값 겹침 채움 — 평균 값의 소수점 부분이 별 하나의 채움 너비 값으로 겹쳐 나타나는 방식

평균 값에서 정수 부분만큼 별이 채워진 상태가 되고, 남은 소수점 부분 값이 그다음 별 하나의 채움 너비 값으로 겹쳐서 나타납니다. 상품 목록의 평균 평점 표시나 후기 요약 카드의 점수 표시에 맞는 형태입니다.

가중 평균을 구하는 average()

// 계산 겹: 별점별 응답 개수(counts)로 가중 평균(합 / 개수)을 구하고, 정수 부분만큼 별을 채운 뒤
// 남은 소수점 부분을 다음 별 하나의 채움 너비 값으로 겹쳐 그린다.
var counts = { 5: 12, 4: 8, 3: 3, 2: 1, 1: 1 };
var queue = [5, 3, 5, 4, 2, 5, 4, 3, 5, 1];
var qi = 0;

function average() {
  var sum = 0, n = 0;
  for (var k in counts) { sum += Number(k) * counts[k]; n += counts[k]; }
  return { avg: sum / n, n: n };
}
평균 값 겹침 채움 — 평균 값의 소수점 부분이 별 하나의 채움 너비 값으로 겹쳐 나타나는 방식
새 창에서 열기

04 · 평균 값 겹침 채움 — 평균 값의 소수점 부분이 별 하나의 채움 너비 값으로 겹쳐 나타나는 방식 — 측정 누적면적 0.5% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

average()counts = {5:12, 4:8, 3:3, 2:1, 1:1}queue = [5,3,5,4,2,5,4,3,5,1]

05개수 막대 분해 — 별점별 응답 개수 값이 가로 막대의 길이 값으로 나뉘어 나타나는 방식

별 다섯 개부터 하나까지 각 점수의 응답 개수 값이 전체 응답 개수 값 대비 비율 값으로 바뀌어 각 줄 막대의 길이 값이 됩니다. 평점 상세 통계를 보여주는 리뷰 페이지나 설문 결과 요약 화면에 맞는 형태입니다.

비율 값을 막대 길이로 바꾸는 total() 과 paint()

// 계산 겹: 각 별점의 응답 개수를 전체 응답 개수 합으로 나눈 비율(%)을 막대 길이 값으로 쓴다.
function total() {
  var t = 0;
  for (var k in counts) t += counts[k];
  return t;
}

function paint() {
  var t = total();
  rows.forEach(function (row) {
    var star = Number(row.getAttribute('data-star'));
    var pct = t ? (counts[star] / t) * 100 : 0;
    row.querySelector('[data-bar]').style.width = pct.toFixed(1) + '%';
    row.querySelector('[data-pct]').textContent = Math.round(pct) + '% (' + counts[star] + ')';
    row.classList.toggle('sr__row--on', star === selected);
    row.setAttribute('aria-pressed', String(star === selected));
  });
}
개수 막대 분해 — 별점별 응답 개수 값이 가로 막대의 길이 값으로 나뉘어 나타나는 방식
새 창에서 열기

05 · 개수 막대 분해 — 별점별 응답 개수 값이 가로 막대의 길이 값으로 나뉘어 나타나는 방식 — 측정 누적면적 22.699% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

total() / paint()counts = {5:42, 4:21, 3:9, 2:4, 1:3}bump = [5,3,4,5,2,5,1,4,5,3]

06말풍선 이름표 교체 — 가리키는 별의 순서 값에 맞는 이름표 문구 값이 바뀌어 나타나는 방식

가리키고 있는 별의 순서 값이 바뀔 때마다 그 값에 대응하는 이름표 문구 값이 함께 바뀌어 별 옆에 나타나고, 벗어난 상태가 되면 이름표가 사라집니다. 만족도 단계를 말로 확인시켜주는 평가 폼이나 리뷰 작성 전 점수 미리보기에 맞는 형태입니다.

위치를 옮기고 문구를 지연 교체하는 positionTip()·commit()·requestSwap()

// 계산 겹: 별의 실제 렌더 위치(getBoundingClientRect 중심 x)를 말풍선의 translateX 값으로
// 그대로 옮기고, 인덱스 변경은 짧은 지연(debounce)을 거친 뒤에만 이름표 문구를 바꾼다 —
// 빠르게 스쳐 지나갈 때 문구가 매 별마다 깜빡이지 않게 하는 타이밍 계산이다.
function positionTip(i) {
  var star = stars[i - 1];
  var sr = star.getBoundingClientRect();
  var rr = row.getBoundingClientRect();
  var cx = sr.left + sr.width / 2 - rr.left;
  tip.style.transform = 'translateX(' + cx.toFixed(1) + 'px) translateX(-50%)';
}

function commit(i) {
  shownIndex = i;
  tip.textContent = LABELS[i];
  positionTip(i);
  tip.classList.add('sr__tip--show');
  stars.forEach(function (s, idx) { s.classList.toggle('sr__star--filled', idx < i); });
}

function requestSwap(i) {
  if (pendingTimer) clearTimeout(pendingTimer);
  pendingTimer = setTimeout(function () { commit(i); }, 40);
}
말풍선 이름표 교체 — 가리키는 별의 순서 값에 맞는 이름표 문구 값이 바뀌어 나타나는 방식
새 창에서 열기

06 · 말풍선 이름표 교체 — 가리키는 별의 순서 값에 맞는 이름표 문구 값이 바뀌어 나타나는 방식 — 측정 누적면적 6.522% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

LABELS = {1:'매우 별로', …, 5:'최고'}positionTip(i) — getBoundingClientRect 중심 xrequestSwap(i) — 40ms debounce

07값별 색 전환 — 선택된 값의 크기에 따라 별의 색상 값이 단계적으로 바뀌는 방식

선택된 별의 순서 값이 낮은 범위에 있으면 색상 값이 한쪽으로, 높은 범위에 있으면 다른 쪽 색상 값으로 바뀌고 그 사이 값은 두 색 사이에서 이어집니다. 만족도를 색으로도 구분하는 평가 위젯이나 낮은 점수를 눈에 띄게 하는 내부 검수 폼에 맞는 형태입니다.

두 토큰 색을 채널별로 섞는 mix()

// 토큰 사이 보간 — demo-tokens 의 $error(낮은 값)와 $subject-mint(높은 값) hex 를 그대로 두 끝점으로 쓴다.
var LOW = [0xc2, 0x36, 0x1a];
var HIGH = [0x4c, 0xd4, 0xa6];

// 계산 겹: 선택된 값(1~5)을 0~1 비율 t 로 바꾼 뒤, 두 토큰 색의 R·G·B 채널을 각각
// t 만큼 선형보간(mix)해 중간 색을 만든다 — HSL 회전이 아니라 채널별 산술 혼합이다.
function mix(t) {
  var c = LOW.map(function (v, i) { return Math.round(v + (HIGH[i] - v) * t); });
  return 'rgb(' + c.join(',') + ')';
}
값별 색 전환 — 선택된 값의 크기에 따라 별의 색상 값이 단계적으로 바뀌는 방식
새 창에서 열기

07 · 값별 색 전환 — 선택된 값의 크기에 따라 별의 색상 값이 단계적으로 바뀌는 방식 — 측정 누적면적 3.591% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

mix(t) — RGB 채널 선형보간LOW = [0xc2, 0x36, 0x1a]HIGH = [0x4c, 0xd4, 0xa6]

08되돌림 스프링 초기화 — 같은 별을 다시 누른 상태가 되면 채움 값이 0으로 튕기듯 줄어드는 방식

이미 선택된 값과 같은 별이 다시 눌린 상태가 되면 채움 값이 0을 향해 튕기는 움직임 값을 거쳐 줄어들고, 선택 값도 함께 지워집니다. 실수로 매긴 점수를 지우는 리뷰 폼이나 선택을 취소할 수 있는 평가 위젯에 맞는 형태입니다.

채움을 0으로 보내고 튕김 클래스를 씌우는 reset()

// 계산 겹: 초기화가 일어나면 채움 값을 0으로 보내는 동시에, 이미 채워졌던 별에만
// 스프링 이징(퇴장 전 오버슛)이 걸린 팝 애니메이션 클래스를 씌운다.
function reset() {
  var wasFilled = stars.filter(function (s, idx) { return idx < value; });
  value = 0;
  paint(value);
  wasFilled.forEach(function (s) { s.classList.add('sr__star--bounce'); });
  setTimeout(function () {
    wasFilled.forEach(function (s) { s.classList.remove('sr__star--bounce'); });
  }, 620);
}
되돌림 스프링 초기화 — 같은 별을 다시 누른 상태가 되면 채움 값이 0으로 튕기듯 줄어드는 방식
새 창에서 열기

08 · 되돌림 스프링 초기화 — 같은 별을 다시 누른 상태가 되면 채움 값이 0으로 튕기듯 줄어드는 방식 — 측정 누적면적 4.791% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

reset() — wasFilled 필터srBounce — scale(1)→scale(1.3)→scale(.85)value = 4 (초기값)

09터치 드래그 훑기 — 손가락이 움직인 가로 값을 따라 채움 값이 실시간으로 바뀌는 방식

손가락이 별 목록 위를 누른 채 움직인 상태에서 그 가로 위치 값이 바뀔 때마다 채움 값이 같은 화면에서 바로 따라 바뀌고, 손을 뗀 상태가 되면 그 값이 선택 값으로 고정됩니다. 모바일 화면의 빠른 별점 입력이나 터치로 훑어 점수를 매기는 리뷰 폼에 맞는 형태입니다.

손을 뗀 속도 값을 감쇠시키는 runInertia()

function onUp() {
  if (!dragging) return;
  dragging = false;
  runInertia();
}
// 계산 겹: 손을 뗀 시점의 속도(px/ms)를 그대로 이어받아 매 프레임 감쇠(마찰 0.945배)시키며
// 위치를 계속 옮기는 관성 스크럽 — 손을 뗀 순간 값이 뚝 멈추지 않고 흐르다 멈춘다.
function runInertia() {
  velocity *= 0.945;
  setPos(pos + velocity * 16);
  if (Math.abs(velocity) > 0.015 && pos > 0 && pos < width()) {
    inertiaRaf = requestAnimationFrame(runInertia);
  } else {
    inertiaRaf = null;
  }
}
터치 드래그 훑기 — 손가락이 움직인 가로 값을 따라 채움 값이 실시간으로 바뀌는 방식
새 창에서 열기

09 · 터치 드래그 훑기 — 손가락이 움직인 가로 값을 따라 채움 값이 실시간으로 바뀌는 방식 — 측정 누적면적 4.96% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

runInertia() — velocity *= 0.945Math.abs(velocity) > 0.015onDown/onMove — pointerId capture

값이 패턴을 바꾸는 곳 — 위치·비율·시간 값의 경계

실제로 별점 만들기를 손으로 할 때는 값 세 개만 옮겨 봐도 손에 잡히는 인상이 크게 갈립니다.

따라가는 속도 기준 — 03 lerp 계수

03 의 lerp 계수(0.2)는 매 프레임 표시 값이 목표 값 쪽으로 좁혀지는 비율이라, 0.2 면 방향키를 눌러도 표시 값이 목표 값 쪽으로 천천히 곡선을 그리며 따라가 방향 전환이 부드럽게 보이고, 0.4 로 올리면 같은 방향키라도 값이 목표에 훨씬 빨리 붙어 반응이 즉각적으로 느껴지는 대신 관성 곡선은 짧아집니다.

이름표 지연 기준 — 06 requestSwap 지연

06 의 requestSwap 지연(40ms)은 가리키는 별이 바뀐 뒤 실제로 이름표 문구를 바꾸기까지 기다리는 시간 값이라, 40ms 면 별 사이를 빠르게 스쳐도 이름표 문구가 거의 즉시 따라오고, 90ms 로 늘리면 짧게 스쳐 지나가는 호버에는 문구가 아예 안 바뀌어 실수로 스치는 깜빡임은 줄지만 의도적으로 멈춘 호버에도 문구가 살짝 늦게 나타납니다.

관성 마찰 기준 — 09 runInertia 마찰

09 의 runInertia 마찰 값(0.945)은 손을 뗀 뒤 매 프레임 속도에 곱해지는 감쇠 비율이라, 0.945 면 손을 뗀 뒤 관성이 짧게만 흐르다 금방 멈춰 값이 손을 뗀 위치에 가깝게 고정되고, 0.975 로 올리면 같은 속도로 손을 떼도 훨씬 오래 미끄러지듯 흘러가 뗀 지점보다 값이 한참 더 이동한 뒤에 멈춥니다.

어디서 깨지는가 — 빠른 연속 클릭·좁은 화면·터치와 마우스가 섞일 때

아홉 개 다 같은 값을 넣으면 매번 같은 결과가 나오는 계산이지만, 값을 극단으로 밀어붙이는 순간 그 계산의 경계가 화면에 그대로 드러나는 자리가 셋 있습니다.

빠른 연속 클릭 — 08

08 은 reset()이 채움 값을 0으로 되돌리는 동시에 이미 채워져 있던 별에 sr__star--bounce 클래스를 씌우고, 그 클래스를 지우는 setTimeout을 620ms 뒤로 걸어 둡니다. 이 620ms 가 다 지나기 전에 같은 별을 빠르게 다시 눌러 값을 채우면, 방금 값을 0으로 보낸 클릭이 걸어 둔 지연 타이머가 아직 살아 있는 채로 그 별에 채움 값이 다시 올라가 — 튕겨 나가는 애니메이션 클래스가 남아 있는 별 위에서 채움 값이 곧바로 다시 차오르는 장면이 겹쳐 보입니다.

좁은 화면 — 06

06 은 positionTip()이 별의 중심 x 좌표만 계산해 말풍선의 translateX 값으로 쓸 뿐, 화면 경계로 clamp 하는 로직이 이 함수 안에는 없습니다. haq93vzh 화면 폭을 좁혀 첫째 별을 직접 가리켜 보면 이 값이 재현됩니다 — 별이 화면 왼쪽 가장자리에 가까울수록 말풍선의 절반이 화면 밖으로 나가는데도, 계산은 별의 중심 값만 볼 뿐 말풍선 자체의 폭 값은 한 번도 재지 않기 때문입니다.

터치와 마우스가 섞일 때 — 09

09 는 onDown이 눌릴 때마다 dragging을 항상 true로 세우고 그 포인터의 좌표로 lastX·lastT를 새로 씁니다. 마우스로 누른 채 드래그하는 중에 다른 포인터(터치)가 같은 트랙 위에서 또 pointerdown을 일으키면, dragging이 이미 참인지 확인하는 값 비교 없이 onDown이 다시 실행돼 위치 값이 나중 포인터의 좌표로 순간 이동합니다 — 두 입력이 한 트랙을 같이 누르는 순간 값이 계산상 하나의 손가락처럼 다뤄지기 때문입니다.

변형 세 가지와 접근성

별점 만들기를 직접 해 보면 아래 값 세 개만 옮겨도 완성된 느낌이 눈에 띄게 갈립니다.

이름 바뀐 값 느낌
방향키 값 이동의 따라가는 속도 03 lerp계수 0.2 → 0.4 0.2 면 방향키를 눌러도 표시 값이 목표 값 쪽으로 천천히 곡선을 그리며 따라가 방향 전환이 부드럽게 보이고, 0.4 면 같은 방향키라도 값이 목표에 훨씬 빨리 붙어 반응이 즉각적으로 느껴지는 대신 관성 곡선은 짧아진다
말풍선 이름표 교체의 debounce 지연 06 requestSwap 지연 40ms → 90ms 40ms 면 별 사이를 빠르게 스쳐도 이름표 문구가 거의 즉시 따라오고, 90ms 면 짧게 스쳐 지나가는 호버에는 문구가 아예 안 바뀌어 실수로 스치는 깜빡임은 줄지만 의도적으로 멈춘 호버에도 문구가 살짝 늦게 나타난다
터치 드래그 훑기의 관성 마찰 09 runInertia 마찰 0.945 → 0.975 0.945 면 손을 뗀 뒤 관성이 짧게만 흐르다 금방 멈춰 값이 손을 뗀 위치에 가깝게 고정되고, 0.975 면 같은 속도로 손을 떼도 훨씬 오래 미끄러지듯 흘러가 뗀 지점보다 값이 한참 더 이동한 뒤에 멈춘다

접근성 값은 아홉 개가 고르지 않습니다. 01 은 role="radiogroup"과 role="radio"·aria-checked를 별마다 매기고, 03 은 슬라이더 역할(role="slider")에 tabindex="0"과 aria-valuemin·aria-valuemax·aria-valuenow 값을 실제로 갱신하며, 05 는 role="list"·role="listitem"과 aria-pressed 값을 행마다 매깁니다. 나머지 02·04·06·07·08·09 는 별도 role·aria 속성 없이 포인터나 클릭에만 반응합니다. 운영체제 값이 동작 줄임으로 맞춰지면 아홉 개 다 자동 재생 setInterval 루프부터 멈추고 각자 정한 CSS 트랜지션 하나씩(sr__star·sr__fillclip·sr__row·sr__tip·sr__thumb 등)이 함께 꺼지지만, 03 은 예외입니다 — 방향키를 직접 눌러 animateTo()를 부르면 reduce 값과 무관하게 requestAnimationFrame lerp 가 그대로 돌아 표시 값이 매 프레임 다시 그려집니다. prefers-reduced-motion 문서가 다루는 것은 자동 재생 쪽뿐이라, 사람이 직접 조작해 만드는 값 계산까지 막지는 않는다는 뜻입니다.

FAQ

별점 UI에서 반 칸(half star) 값은 어떻게 계산하나요?

02 처럼 각 별의 실제 렌더 폭을 getBoundingClientRect로 재고, 포인터의 가로 좌표가 그 폭의 왼쪽 절반에 있는지 오른쪽 절반에 있는지로 정수 값과 0.5 값을 가릅니다. 별 이미지 자체를 반으로 자른 그림을 따로 준비할 필요 없이, 채움 클립의 너비 값만 50%로 맞추면 됩니다.

css 별점 표시를 코드 하나로 옮겨서 그대로 쓸 수 있나요?

네, 아홉 개 다 별도 라이브러리나 아이콘 폰트 없이 SVG path 하나와 값 계산 함수만으로 동작합니다. 채움 값을 만드는 함수(paint·average·mix 등)와 이벤트 리스너 등록부만 그대로 옮기면 되고, 값의 이름(변수명)만 프로젝트 사정에 맞게 바꾸면 됩니다.

평균 평점에 소수점이 있을 때 별을 어떻게 채우나요?

04 처럼 평균 값에서 정수 부분만큼 별을 온전히 채우고, 남은 소수점 부분 값을 그다음 별 하나의 채움 너비로 겹쳐 그리면 됩니다. 예를 들어 평균이 4.16 이면 별 넷은 완전히 채우고 다섯째 별만 16% 너비로 겹쳐 채우는 식입니다.

더 읽을거리

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.