Rating
Star rating radiogroup with hover preview fill, a staggered spring bounce on pick, optional half-star precision from the pointer position, click-to-clear, arrow/Home/End keyboard control, custom icons and a read-only display mode; controlled/uncontrolled, reduced-motion safe.
Preview
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
// Shared touch primitives. iOS and iPadOS run their own gestures on top of the
// page — the long-press selection callout and the selection it drags in with
// it — and they win: once the platform claims a touch it cancels ours
// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own
// their gesture have to opt out.
//
// What the two classes below cover, precisely:
// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:
// it is not a property other engines have, so it is inert everywhere else.
// - `user-select: none` stops the long-press selection on every engine,
// Android included, and stops a drag from painting a selection under the
// cursor. It is inherited, so it reaches every descendant — which is why the
// two classes differ only in whether they apply it unconditionally.
// What neither covers:
// - Chrome for Android's long-press menu on a link or an image. No CSS
// suppresses it; a gesture surface that wraps one needs its own
// `onContextMenu` with `preventDefault()`.
// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is
// not inherited and plain divs and buttons are not drag sources, so setting
// it on the surface does nothing — the child itself needs `draggable={false}`.
/**
* Classes for a surface that *is* the control: a thumb, a drum, a stage, a
* handle, a hold button. Selection is suppressed on every input, because a
* drag that highlights the control's own label is wrong on a mouse too.
* Compose with `touch-none` when the surface also owns the scroll axis — leave
* it off when the page must still scroll from there.
*/
export const TOUCH_GESTURE_CLASS = "select-none [-webkit-touch-callout:none]";
/**
* The same opt-out for a gesture surface that wraps content the consumer owns:
* a scroller, a context-menu trigger, a sheet header, a list row. Selection is
* suppressed only where the platform runs its own press gestures — a coarse
* pointer — so a mouse user can still select and copy that content. If the
* gesture itself would paint a selection under the cursor, add `select-none`
* for the duration of the gesture rather than reaching for
* `TOUCH_GESTURE_CLASS`.
*
* `pointer: coarse` describes the *primary* pointer and nothing else, so a
* hybrid machine reads it wrong in both directions: a tablet with a mouse
* plugged in keeps touch as primary and loses mouse selection, and a laptop
* with a touchscreen keeps the mouse as primary and leaves selection live
* under a finger. No media query can answer per interaction — the query is
* about the device, and the question is about the gesture in progress. The
* default stays here because it is right on the machines that are one thing or
* the other, and losing a selection is a nuisance; where the miss costs a
* *gesture* instead, the surface pairs it with `holdSelection` on the press.
*/
export const TOUCH_GESTURE_CONTENT_CLASS =
"[-webkit-touch-callout:none] pointer-coarse:select-none";
/**
* Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple
* HIG) on a coarse pointer, without changing what it looks like or the space
* it takes in layout. An invisible `::after` box, centered on the control and
* `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button
* reaches 6px past each edge, a wide 40px button only grows vertically, and a
* control already 44px or larger is left as it is. Hit-testing a pseudo
* element lands on its owner, so handlers, focus and `:active` all behave.
*
* Mouse and trackpad rendering is untouched — nothing here applies outside
* `pointer: coarse`. Requirements on the element it goes on:
* - It must be the containing block: add `relative` (and keep it mergeable, so
* a consumer's `absolute` still wins through `cn`).
* - It must not clip itself (`overflow-hidden`), or the overhang is clipped
* away along with its hit area.
* - It must render children: `<input>` and other void elements have no
* `::after`. Put it on the wrapper or label instead.
* - It must not already use `::after` for something visible.
*
* Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the
* later one wins that strip, which still ends well short of the earlier
* button's visual center. Below a ~32px pitch the later neighbour starts
* eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.
*
* The overhang is real geometry: inside a scroll container it counts as
* scrollable overflow, so a control flush against a scroller's edge can add a
* few pixels of scroll on touch screens. Give such a scroller a little padding
* on that side.
*/
export const TOUCH_TARGET_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter
* than ~32px apart in a row (pagination dots, dense swatches), where growing
* sideways would hand most of each control to its neighbour. Same rules.
*/
export const TOUCH_TARGET_Y_CLASS =
"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']";
/**
* Suppress selection on `element` for as long as a gesture is running on it,
* whatever the primary pointer of the machine happens to be. Returns the
* release. Inline, so it wins over the class above and is gone again the
* moment the gesture ends.
*
* For the press gestures a native selection would otherwise steal — a
* long-press that opens a menu. Elsewhere prefer the classes: a surface that
* takes selection away for the whole session is a surface whose text nobody
* can copy.
*/
export function holdSelection(element: HTMLElement) {
element.style.setProperty("user-select", "none");
element.style.setProperty("-webkit-user-select", "none");
return () => {
element.style.removeProperty("user-select");
element.style.removeProperty("-webkit-user-select");
};
}
/**
* Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer
* is already gone by the time the handler runs — routine on iOS, where the
* system can claim the touch first — and an uncaught throw takes the rest of
* the handler, the gesture included, down with it. Touch pointers carry
* implicit capture anyway, so losing it is never fatal.
*/
export function capturePointer(element: Element, pointerId: number) {
try {
element.setPointerCapture(pointerId);
} catch {
// Pointer is no longer active — implicit capture still applies on touch.
}
}
/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */
export function releasePointer(element: Element, pointerId: number) {
try {
if (element.hasPointerCapture(pointerId)) {
element.releasePointerCapture(pointerId);
}
} catch {
// Capture was already dropped by the browser.
}
}
/**
* Whether this event came from a pointer that is *hovering*: not a touch, and
* not currently pressed. Which input the user is holding right now is not
* something a device capability can answer — a touchscreen laptop hovers and
* taps, and iPadOS reports a fine hovering pointer for a finger — so both
* paths stay live and each handler branches on the event it was given.
*
* A pen resting on the glass is making contact, not hovering: `buttons` is the
* tell, and it sends a pen tap down the same route a finger takes.
*
* This answers what an *enter* asks. A leave is the other half of a pair and
* has to be read against the enter that started it — `useHoverGesture` in
* `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it
* rather than asking this question twice.
*/
export const isHoveringPointer = (event: {
pointerType: string;
buttons: number;
}) => event.pointerType !== "touch" && event.buttons === 0;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/motion/rating
import { Star } from "lucide-react";
import { animate, motion, useReducedMotion } from "motion/react";
import {
type KeyboardEvent,
type MouseEvent,
type PointerEvent,
type ReactNode,
useId,
useRef,
useState,
} from "react";
import { SPRING_PRESS } from "@/lib/ease";
import { isHoveringPointer } from "@/lib/touch";
import { cn } from "@/lib/utils";
export interface RatingProps {
/** Controlled rating. 0 means unrated. */
value?: number;
/** Uncontrolled initial rating. Default 0. */
defaultValue?: number;
onChange?: (value: number) => void;
/** Number of icons. Default 5. */
max?: number;
/** 1 for whole icons, 0.5 for halves (picked by pointer x within the icon). */
precision?: 1 | 0.5;
/** Display only: no hover, click or keyboard. */
readOnly?: boolean;
disabled?: boolean;
/** Clicking the current value clears it to 0. Default true. */
clearable?: boolean;
/** Custom icon, rendered for both the empty and filled layer. Defaults to a star. */
icon?: ReactNode;
/** Icon box size in px. Default 24. */
size?: number;
/** Accessible name for the group. Default "Rating". */
"aria-label"?: string;
/** Builds each option's accessible label. */
getLabel?: (value: number, max: number) => string;
className?: string;
/** Classes for the filled layer — set the fill color here. */
filledClassName?: string;
/** Classes for the empty layer. */
emptyClassName?: string;
}
const defaultLabel = (value: number, max: number) =>
`${value} ${value === 1 ? "star" : "stars"} out of ${max}`;
const round = (n: number, precision: number) =>
Math.round(n / precision) * precision;
export function Rating({
value: valueProp,
defaultValue = 0,
onChange,
max = 5,
precision = 1,
readOnly = false,
disabled = false,
clearable = true,
icon,
size = 24,
"aria-label": ariaLabel = "Rating",
getLabel = defaultLabel,
className,
filledClassName,
emptyClassName,
}: RatingProps) {
const uid = useId();
const reduce = useReducedMotion();
const controlled = valueProp !== undefined;
const [internal, setInternal] = useState(defaultValue);
const value = Math.min(
max,
Math.max(0, round(controlled ? valueProp : internal, precision)),
);
const [hover, setHover] = useState<number | null>(null);
const itemRefs = useRef<(HTMLElement | null)[]>([]);
// Bounce runs on an inner span so it never fights the button's whileTap.
const glyphRefs = useRef<(HTMLElement | null)[]>([]);
const interactive = !readOnly && !disabled;
const shown = interactive && hover !== null ? hover : value;
const iconNode = icon ?? (
<Star className="size-full" fill="currentColor" strokeWidth={0} />
);
// A short pop across every icon up to the picked one, staggered outward.
const bounce = (upTo: number) => {
if (reduce) return;
const last = Math.ceil(upTo);
for (let i = 0; i < last; i++) {
const el = glyphRefs.current[i];
if (!el) continue;
animate(
el,
{ scale: [i === last - 1 ? 1.3 : 1.12, 1] },
{ ...SPRING_PRESS, delay: i * 0.025 },
);
}
};
const commit = (next: number, focus = false) => {
const clean = Math.min(max, Math.max(0, round(next, precision)));
if (!controlled) setInternal(clean);
onChange?.(clean);
if (clean > 0) bounce(clean);
if (focus) {
itemRefs.current[Math.max(Math.ceil(clean) - 1, 0)]?.focus();
}
};
const valueAt = (index: number, clientX: number, rect: DOMRect | null) => {
if (precision === 0.5 && rect && clientX - rect.left < rect.width / 2) {
return index + 0.5;
}
return index + 1;
};
const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
const floor = clearable ? 0 : precision;
const map: Record<string, number> = {
ArrowRight: value + precision,
ArrowUp: value + precision,
ArrowLeft: value - precision,
ArrowDown: value - precision,
Home: precision,
End: max,
};
if (!(e.key in map)) return;
e.preventDefault();
const next = Math.min(max, Math.max(floor, map[e.key]));
if (next !== value) commit(next, true);
};
if (readOnly) {
return (
<div
role="img"
aria-label={`${ariaLabel}: ${getLabel(value, max)}`}
className={cn("inline-flex items-center gap-1", className)}
>
{Array.from({ length: max }, (_, i) => (
<Glyph
// biome-ignore lint/suspicious/noArrayIndexKey: fixed-length row, never reordered.
key={`${uid}-${i}`}
fill={Math.min(1, Math.max(0, value - i))}
size={size}
icon={iconNode}
filledClassName={filledClassName}
emptyClassName={emptyClassName}
/>
))}
</div>
);
}
// Roving tabindex: the checked icon (or the first, when unrated) is the stop.
const checkedIndex = value > 0 ? Math.ceil(value) - 1 : -1;
const tabStop = checkedIndex >= 0 ? checkedIndex : 0;
return (
<div
role="radiogroup"
aria-label={ariaLabel}
aria-disabled={disabled || undefined}
onPointerLeave={() => setHover(null)}
className={cn(
"inline-flex items-center gap-1",
disabled && "opacity-50",
className,
)}
>
{Array.from({ length: max }, (_, i) => {
const checked = i === checkedIndex;
// The checked radio speaks the exact value (halves included).
const optionValue = checked ? value : i + 1;
return (
<motion.button
// biome-ignore lint/suspicious/noArrayIndexKey: fixed-length row, never reordered.
key={`${uid}-${i}`}
ref={(node) => {
itemRefs.current[i] = node;
}}
type="button"
role="radio"
aria-checked={checked}
aria-label={getLabel(optionValue, max)}
tabIndex={tabStop === i ? 0 : -1}
disabled={disabled}
whileTap={reduce ? undefined : { scale: 0.85 }}
transition={SPRING_PRESS}
onPointerMove={(e: PointerEvent<HTMLButtonElement>) => {
if (!isHoveringPointer(e)) return;
setHover(
valueAt(i, e.clientX, e.currentTarget.getBoundingClientRect()),
);
}}
onClick={(e: MouseEvent<HTMLButtonElement>) => {
// Keyboard activation (detail 0) picks the whole icon.
const next =
e.detail === 0
? i + 1
: valueAt(i, e.clientX, e.currentTarget.getBoundingClientRect());
setHover(null);
if (clearable && next === value) {
commit(0);
return;
}
commit(next);
}}
onKeyDown={onKeyDown}
className="relative cursor-pointer rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed"
>
<span
ref={(node) => {
glyphRefs.current[i] = node;
}}
className="block"
>
<Glyph
fill={Math.min(1, Math.max(0, shown - i))}
size={size}
icon={iconNode}
preview={hover !== null}
filledClassName={filledClassName}
emptyClassName={emptyClassName}
/>
</span>
</motion.button>
);
})}
</div>
);
}
function Glyph({
fill,
size,
icon,
preview = false,
filledClassName,
emptyClassName,
}: {
fill: number;
size: number;
icon: ReactNode;
preview?: boolean;
filledClassName?: string;
emptyClassName?: string;
}) {
return (
<span
aria-hidden
className="relative block"
style={{ width: size, height: size }}
>
<span
className={cn(
"absolute inset-0 block text-muted-foreground/30",
emptyClassName,
)}
>
{icon}
</span>
{/* The filled layer is clipped to the fill fraction; its width only
ever switches between 0, half and full, it is never animated. */}
<span
className={cn(
"absolute inset-y-0 left-0 block overflow-hidden text-amber-400 transition-opacity duration-150",
preview && "opacity-70",
filledClassName,
)}
style={{ width: `${fill * 100}%` }}
>
<span className="block" style={{ width: size, height: size }}>
{icon}
</span>
</span>
</span>
);
}
@karaui not found? Add it to components.json
{
"registries": {
"@karaui": "https://karaui.kamisama-v0.my.id/r/{name}.json"
}
}@karaui is in shadcn's public registry directory, so the latest CLI needs no setup. On an older CLI, merge this into the existing file once and every @karaui/… install resolves.
API Reference
value?numberControlled rating. 0 means unrated.
—defaultValue?numberUncontrolled initial rating. Default 0.
0onChange?((value: number) => void)—max?numberNumber of icons. Default 5.
5precision?1 | 0.51 for whole icons, 0.5 for halves (picked by pointer x within the icon).
1readOnly?booleanDisplay only: no hover, click or keyboard.
falsedisabled?booleanfalseclearable?booleanClicking the current value clears it to 0. Default true.
trueicon?ReactNodeCustom icon, rendered for both the empty and filled layer. Defaults to a star.
—size?numberIcon box size in px. Default 24.
24aria-label?stringAccessible name for the group. Default "Rating".
RatinggetLabel?((value: number, max: number) => string)Builds each option's accessible label.
(value: number, max: number) =>
`${value} ${value === 1 ? "star" : "stars"} out of ${max}`className?string—filledClassName?stringClasses for the filled layer — set the fill color here.
—emptyClassName?stringClasses for the empty layer.
—Updated