Rating

Interactive UI Explorer

아래에서 각 컴포넌트의 다양한 Variants States를 문서화된 형태로 테스트해볼 수 있습니다.

Live Preview

3.5 / 5

Rating

별점 입력/표시입니다. 반 칸(0.5) 정밀도, 키보드 ←/→·Home/End, 같은 값 재클릭 해제, 읽기 전용을 지원합니다.

입력 — 반 칸 / 정수

half=true면 별 좌/우 절반으로 0.5점 단위, clearable이면 같은 값 재클릭 시 해제됩니다.

Source Code
<Rating value={value} onChange={setValue} half />

3.5 / 5

Live Preview

읽기 전용

onChange 없이(혹은 readOnly로) 표시 전용으로 씁니다.

Source Code
<Rating value={4.5} half readOnly />
4.5 / 5
Live Preview
Implementation

제작 코드

이 컴포넌트가 실제로 어떻게 구현되어 있는지 — 본체 .tsx 파일을 그대로 보여줍니다. variant 매핑·접근성 처리·forwardRef 패턴 등 디테일을 그대로 확인할 수 있어요.

Ratingtypescript
'use client';

/**
 * Rating 컴포넌트
 *
 * 별점 입력/표시. 반 칸(0.5) 정밀도·키보드·읽기 전용을 지원합니다.
 * - 각 별은 빈 별 위에 채운 별을 ratio(0/0.5/1)만큼 width clip해서 덮습니다.
 * - half=true면 별의 좌/우 절반으로 0.5점, 키보드 ←/→·Home/End로 조작.
 * - readOnly면 표시 전용(포커스/포인터 비활성).
 */

import { useState } from 'react';
import { cn } from '@/lib/cn';

export interface RatingProps {
  value: number;
  onChange?: (value: number) => void;
  max?: number;
  /** 반 칸(0.5) 정밀도 */
  half?: boolean;
  /** 같은 값을 다시 클릭하면 0으로 해제 */
  clearable?: boolean;
  readOnly?: boolean;
  size?: 'sm' | 'md' | 'lg';
  ariaLabel?: string;
  className?: string;
}

const sizeClass: Record<NonNullable<RatingProps['size']>, string> = {
  sm: 'h-6 w-6',
  md: 'h-8 w-8',
  lg: 'h-10 w-10',
};

function StarShape({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" className={className} aria-hidden>
      <path
        fill="currentColor"
        d="M12 2.5l2.7 5.47 6.04.88-4.37 4.26 1.03 6.01L12 16.9l-5.4 2.84 1.03-6.01-4.37-4.26 6.04-.88L12 2.5z"
      />
    </svg>
  );
}

function Star({ ratio, size }: { ratio: number; size: NonNullable<RatingProps['size']> }) {
  return (
    <span className={cn('relative block', sizeClass[size])}>
      <StarShape className="absolute inset-0 h-full w-full text-slate-200" />
      <span
        className="absolute inset-0 overflow-hidden transition-[width] duration-150 ease-out"
        style={{ width: `${ratio * 100}%` }}
      >
        <StarShape className={cn('h-full text-amber-400 drop-shadow-[0_1px_3px_rgba(251,191,36,0.45)]', sizeClass[size])} />
      </span>
    </span>
  );
}

export function Rating({
  value,
  onChange,
  max = 5,
  half = false,
  clearable = true,
  readOnly = false,
  size = 'md',
  ariaLabel = '별점',
  className,
}: RatingProps) {
  const [hover, setHover] = useState<number | null>(null);
  const step = half ? 0.5 : 1;
  const interactive = !readOnly && !!onChange;

  const valueFromPointer = (index: number, e: React.PointerEvent<HTMLButtonElement>) => {
    if (!half) return index + 1;
    const rect = e.currentTarget.getBoundingClientRect();
    const isLeft = e.clientX - rect.left < rect.width / 2;
    return index + (isLeft ? 0.5 : 1);
  };

  const display = hover ?? value;

  const commit = (next: number) => {
    if (!onChange) return;
    onChange(clearable && next === value ? 0 : next);
  };

  const onKeyDown = (e: React.KeyboardEvent) => {
    if (!interactive) return;
    let next = value;
    if (e.key === 'ArrowRight' || e.key === 'ArrowUp') next = value + step;
    else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') next = value - step;
    else if (e.key === 'Home') next = 0;
    else if (e.key === 'End') next = max;
    else return;
    e.preventDefault();
    onChange?.(Math.min(max, Math.max(0, next)));
  };

  return (
    <div
      role={interactive ? 'slider' : 'img'}
      tabIndex={interactive ? 0 : undefined}
      aria-label={ariaLabel}
      aria-valuemin={interactive ? 0 : undefined}
      aria-valuemax={interactive ? max : undefined}
      aria-valuenow={interactive ? value : undefined}
      aria-valuetext={`${value} / ${max}`}
      onKeyDown={onKeyDown}
      onPointerLeave={() => setHover(null)}
      className={cn(
        'inline-flex gap-1 rounded-2xl p-1 outline-none',
        interactive && 'focus-visible:ring-2 focus-visible:ring-amber-400',
        className,
      )}
    >
      {Array.from({ length: max }, (_, i) => {
        const ratio = Math.min(1, Math.max(0, display - i));
        return (
          <button
            key={i}
            type="button"
            tabIndex={-1}
            aria-hidden
            disabled={!interactive}
            onPointerMove={(e) => interactive && setHover(valueFromPointer(i, e))}
            onPointerEnter={(e) => interactive && setHover(valueFromPointer(i, e))}
            onClick={(e) => interactive && commit(valueFromPointer(i, e as React.PointerEvent<HTMLButtonElement>))}
            className={cn(
              'rounded-md transition-transform duration-200',
              interactive ? 'cursor-pointer hover:scale-110 active:scale-95' : 'cursor-default',
            )}
          >
            <Star ratio={ratio} size={size} />
          </button>
        );
      })}
    </div>
  );
}