Rating
FormWhole-star ratings with controlled, disabled, and read-only modes.
Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.
"use client";
import { Rating } from "@/components/ui/rating";
import { useState } from "react";
import { Button } from "@dashboardpack/core/components/ui/button";
export default function RatingExample() {
const [rating, setRating] = useState(3);
return (
<div className="space-y-5">
<div className="space-y-2">
<p className="text-sm font-medium">How was your experience?</p>
<Rating
label="Your experience"
value={rating}
onValueChange={setRating}
/>
<p role="status" className="text-xs text-muted-foreground">
{rating} of 5 stars
</p>
<Button size="sm" variant="ghost" onClick={() => setRating(0)}>
Clear rating
</Button>
</div>
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
Read-only product rating
</p>
<Rating label="Product rating" value={4} readOnly />
</div>
<Rating label="Unavailable rating" defaultValue={2} disabled />
</div>
);
}Usage
Use value/onValueChange for a rating, or defaultValue for local state. Zero means no rating. max supports one to ten whole stars. A read-only rating renders an image-like semantic summary rather than interactive radio buttons.
import { Rating } from "@/components/ui/rating";Shipped as editable source in src/components/ui/rating.tsx in both apps. Copy the implementation below when moving it to another project. Copy the complete example from Source code for all imports and state. Add "use client" when using hooks or event handlers. See installation and theming for project setup.
Component source
The complete implementation is included in both apps. It uses the React, Radix, Lucide, and shared-core dependencies already installed in Apex. Keep your project’s semantic theme tokens when reusing it.
src/components/ui/rating.tsx
"use client";
import { useDocumentDirection } from "@/lib/use-document-direction";
import { useState } from "react";
import { RadioGroup } from "radix-ui";
import { Star } from "lucide-react";
import { cn } from "@dashboardpack/core/lib/utils";
export interface RatingProps {
value?: number;
defaultValue?: number;
onValueChange?: (value: number) => void;
max?: number;
label?: string;
readOnly?: boolean;
disabled?: boolean;
className?: string;
}
export function Rating({
value,
defaultValue = 0,
onValueChange,
max = 5,
label = "Rating",
readOnly,
disabled,
className,
}: RatingProps) {
const dir = useDocumentDirection();
const [internal, setInternal] = useState(defaultValue);
const count = Math.max(
1,
Math.min(10, Number.isFinite(max) ? Math.floor(max) : 5),
);
const current = Math.max(0, Math.min(count, Math.round(value ?? internal)));
const stars = Array.from({ length: count }, (_, i) => i + 1);
if (readOnly)
return (
<span
role="img"
aria-label={`${label}: ${current} of ${count} ${count === 1 ? "star" : "stars"}`}
className={cn("inline-flex gap-1", className)}
>
{stars.map((n) => (
<Star
aria-hidden
key={n}
className={cn(
"size-5",
n <= current
? "fill-primary text-primary"
: "text-muted-foreground",
)}
/>
))}
</span>
);
return (
<RadioGroup.Root
dir={dir}
aria-label={label}
disabled={disabled}
value={current ? String(current) : ""}
onValueChange={(next) => {
setInternal(Number(next));
onValueChange?.(Number(next));
}}
className={cn("inline-flex flex-wrap gap-1", className)}
>
{stars.map((n) => (
<RadioGroup.Item
key={n}
value={String(n)}
aria-label={`${n} ${n === 1 ? "star" : "stars"}`}
className="rounded p-1 outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
>
<Star
aria-hidden
className={cn(
"size-6",
n <= current
? "fill-primary text-primary"
: "text-muted-foreground",
)}
/>
</RadioGroup.Item>
))}
</RadioGroup.Root>
);
}Required helper: src/lib/use-document-direction.ts
Copy this helper alongside the component. It keeps keyboard navigation aligned with the page direction and supports live RTL changes.
"use client";
import { useSyncExternalStore } from "react";
function subscribe(notify: () => void) {
const observer = new MutationObserver(notify);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["dir"],
});
return () => observer.disconnect();
}
function snapshot(): "ltr" | "rtl" {
return document.documentElement.dir === "rtl" ? "rtl" : "ltr";
}
/** Keep headless controls aligned with the document, including live RTL changes. */
export function useDocumentDirection() {
return useSyncExternalStore(subscribe, snapshot, () => "ltr" as const);
}API reference
Common props and defaults. Slot-specific props are named below; native elements also accept their standard HTML attributes. The component TypeScript definitions are the complete type reference.
| Prop | Type / Default | Description |
|---|---|---|
| value / defaultValue | numberDefault: 0 | Controlled or initial whole-star rating. |
| onValueChange | (value: number) => voidDefault: — | Receives the selected integer. |
| max | numberDefault: 5 | Maximum stars, clamped to 1–10. |
| label | stringDefault: Rating | Accessible group or summary name. |
| readOnly / disabled | booleanDefault: false | Summary mode or unavailable interactive state. |