Rating
Interactive UI Explorer
아래에서 각 컴포넌트의 다양한 Variants와 States를 문서화된 형태로 테스트해볼 수 있습니다.
Live Preview
3.5 / 5
Rating
별점 입력/표시입니다. 반 칸(0.5) 정밀도, 키보드 ←/→·Home/End, 같은 값 재클릭 해제, 읽기 전용을 지원합니다.
입력 — 반 칸 / 정수
half=true면 별 좌/우 절반으로 0.5점 단위, clearable이면 같은 값 재클릭 시 해제됩니다.
Source Code
3.5 / 5
Live Preview
읽기 전용
onChange 없이(혹은 readOnly로) 표시 전용으로 씁니다.
Source Code
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>
);
}