Rating Group
A shadcn-style rating group component built with Ark UI primitives.
import {
RatingGroup,
RatingGroupLabel,
RatingStars,
} from "@/components/ui/rating-group";
const RatingGroupDemo = () => (
<RatingGroup count={5} defaultValue={3}>
<RatingGroupLabel>Rate this component</RatingGroupLabel>
<RatingStars />
</RatingGroup>
);
export default RatingGroupDemo;
Installation
npx shadcn@latest add @ark-cn/rating-groupInstall the dependency required by this primitive:
npm install @ark-ui/react lucide-reactCopy the component source into your app:
TSXcomponents/ui/rating-group.tsx
"use client";
import {
RatingGroup as RatingGroupPrimitive,
useRatingGroup,
useRatingGroupContext,
} from "@ark-ui/react/rating-group";
import { StarIcon } from "lucide-react";
import { type ComponentProps, type CSSProperties, type ReactNode } from "react";
import { cn } from "@/lib/utils";
export { useRatingGroup, useRatingGroupContext };
export type RatingGroupProps = ComponentProps<typeof RatingGroupPrimitive.Root>;
export const RatingGroup = ({
className,
children,
...props
}: RatingGroupProps) => (
<RatingGroupPrimitive.Root
className={cn(
"group/rating-group flex flex-col gap-1.5 text-foreground",
"data-readonly:pointer-events-none",
className,
)}
data-slot="rating-group"
{...props}
>
{children}
<RatingGroupPrimitive.HiddenInput />
</RatingGroupPrimitive.Root>
);
export type RatingGroupLabelProps = ComponentProps<
typeof RatingGroupPrimitive.Label
>;
export const RatingGroupLabel = ({
className,
...props
}: RatingGroupLabelProps) => (
<RatingGroupPrimitive.Label
className={cn(
"text-sm font-medium text-foreground leading-none select-none",
"data-disabled:pointer-events-none data-disabled:opacity-50",
className,
)}
data-slot="rating-group-label"
{...props}
/>
);
export type RatingGroupControlProps = ComponentProps<
typeof RatingGroupPrimitive.Control
>;
export const RatingGroupControl = ({
className,
...props
}: RatingGroupControlProps) => (
<RatingGroupPrimitive.Control
className={cn("inline-flex items-center gap-0.5", className)}
data-slot="rating-group-control"
{...props}
/>
);
export type RatingGroupItemProps = ComponentProps<
typeof RatingGroupPrimitive.Item
>;
export const RatingGroupItem = ({
className,
...props
}: RatingGroupItemProps) => (
<RatingGroupPrimitive.Item
className={cn(
"inline-flex cursor-pointer items-center justify-center rounded-sm outline-none",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50",
className,
)}
data-slot="rating-group-item"
{...props}
/>
);
export type RatingGroupItemContextProps = ComponentProps<
typeof RatingGroupPrimitive.ItemContext
>;
export const RatingGroupItemContext = (props: RatingGroupItemContextProps) => (
<RatingGroupPrimitive.ItemContext {...props} />
);
export type RatingGroupContextProps = ComponentProps<
typeof RatingGroupPrimitive.Context
>;
export const RatingGroupContext = (props: RatingGroupContextProps) => (
<RatingGroupPrimitive.Context {...props} />
);
export type RatingGroupRootProviderProps = ComponentProps<
typeof RatingGroupPrimitive.RootProvider
>;
export const RatingGroupRootProvider = ({
className,
children,
...props
}: RatingGroupRootProviderProps) => (
<RatingGroupPrimitive.RootProvider
className={cn(
"group/rating-group flex flex-col gap-1.5 text-foreground",
"data-readonly:pointer-events-none",
className,
)}
data-slot="rating-group-root-provider"
{...props}
>
{children}
<RatingGroupPrimitive.HiddenInput />
</RatingGroupPrimitive.RootProvider>
);
/** Layout + colors only; fg fill clip is `style.clipPath` (Tailwind rule order is unreliable for this cascade). */
export const ratingGroupStarIndicatorClassName = cn(
"relative inline-flex size-5 shrink-0",
"[&_svg]:pointer-events-none [&_svg]:absolute [&_svg]:inset-0 [&_svg]:size-full",
"**:data-bg:text-muted-foreground",
"**:data-fg:text-chart-1",
);
/** Same result as Ark `.ItemIndicator` clip rules, without relying on stylesheet order. */
const ratingStarForegroundClipPath = (
highlighted: boolean,
half: boolean,
): NonNullable<CSSProperties["clipPath"]> =>
!highlighted
? "inset(0 100% 0 0)"
: half
? "inset(0 50% 0 0)"
: "inset(0 0 0 0)";
const RatingStarIndicator = ({
half,
highlighted,
}: {
half?: boolean;
highlighted?: boolean;
}) => {
const hi = Boolean(highlighted);
const h = Boolean(half);
return (
<span className={ratingGroupStarIndicatorClassName}>
<StarIcon aria-hidden data-bg="" />
<StarIcon
aria-hidden
data-fg=""
fill="currentColor"
style={{ clipPath: ratingStarForegroundClipPath(hi, h) }}
/>
</span>
);
};
export const RatingStars = ({
withHalf = false,
children,
}: {
withHalf?: boolean;
children?: (item: number, half: boolean, highlighted: boolean) => ReactNode;
}) => (
<RatingGroupControl>
<RatingGroupContext>
{({ items }) =>
items.map((item) => (
<RatingGroupItem index={item} key={item}>
<RatingGroupItemContext>
{children
? ({ half, highlighted }) => children(item, half, highlighted)
: withHalf
? ({ half, highlighted }) => (
<RatingStarIndicator
half={half}
highlighted={highlighted}
/>
)
: ({ highlighted }) => (
<RatingStarIndicator highlighted={highlighted} />
)}
</RatingGroupItemContext>
</RatingGroupItem>
))
}
</RatingGroupContext>
</RatingGroupControl>
);
Update import aliases to match your project setup.
Usage
import * as RatingGroup from "@/components/ui/rating-group"Read exported parts in src/components/ui/rating-group.tsx and compose the primitive according to the Ark UI pattern for this component.
Examples
Basic
import {
RatingGroup,
RatingGroupLabel,
RatingStars,
} from "@/components/ui/rating-group";
const RatingGroupBasic = () => (
<RatingGroup defaultValue={3}>
<RatingGroupLabel>Label</RatingGroupLabel>
<RatingStars />
</RatingGroup>
);
export default RatingGroupBasic;
Controlled
Value: 0
import { useState } from "react";
import {
RatingGroup,
RatingGroupLabel,
RatingStars,
} from "@/components/ui/rating-group";
const RatingGroupControlled = () => {
const [value, setValue] = useState(0);
return (
<div className="flex flex-col gap-2">
<RatingGroup
value={value}
onValueChange={({ value: nextValue }) => {
setValue(nextValue);
}}
>
<RatingGroupLabel>Label</RatingGroupLabel>
<RatingStars />
</RatingGroup>
<p className="text-muted-foreground text-xs">
Value:{" "}
<span className="font-medium tabular-nums text-foreground">
{value}
</span>
</p>
</div>
);
};
export default RatingGroupControlled;
Root provider
import { Button } from "@/components/ui/button";
import {
RatingGroupLabel,
RatingGroupRootProvider,
RatingStars,
useRatingGroup,
} from "@/components/ui/rating-group";
const RatingGroupRootProviderDemo = () => {
const store = useRatingGroup({ count: 5, defaultValue: 4 });
return (
<div className="flex max-w-md flex-col gap-2">
<output className="text-muted-foreground text-xs">
<span className="text-foreground">value:</span>{" "}
<span className="font-medium tabular-nums text-foreground">
{store.value}
</span>
</output>
<RatingGroupRootProvider value={store}>
<RatingGroupLabel>Label</RatingGroupLabel>
<RatingStars />
</RatingGroupRootProvider>
<Button
size="sm"
type="button"
variant="outline"
onClick={() => {
store.setValue(1);
}}
>
Set to 1
</Button>
</div>
);
};
export default RatingGroupRootProviderDemo;
With field
Helper text below the group (Ark
with-field example).import { Field, FieldDescription } from "@/components/ui/field";
import {
RatingGroup,
RatingGroupLabel,
RatingStars,
} from "@/components/ui/rating-group";
const RatingGroupWithField = () => (
<Field className="max-w-md">
<RatingGroup count={5} defaultValue={2}>
<RatingGroupLabel>Label</RatingGroupLabel>
<RatingStars />
</RatingGroup>
<FieldDescription>
Helper text below the group (Ark <code>with-field</code> example).
</FieldDescription>
</Field>
);
export default RatingGroupWithField;
Half steps
import {
RatingGroup,
RatingGroupLabel,
RatingStars,
} from "@/components/ui/rating-group";
const RatingGroupHalf = () => (
<RatingGroup allowHalf defaultValue={2.5}>
<RatingGroupLabel>Label</RatingGroupLabel>
<RatingStars withHalf />
</RatingGroup>
);
export default RatingGroupHalf;
Form
import { type FormEvent, useState } from "react";
import { Button } from "@/components/ui/button";
import {
RatingGroup,
RatingGroupLabel,
RatingStars,
} from "@/components/ui/rating-group";
const RatingGroupForm = () => {
const [submitted, setSubmitted] = useState<string | null>(null);
return (
<form
className="flex max-w-xs flex-col gap-3"
onSubmit={(event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
setSubmitted(String(formData.get("review") ?? ""));
}}
>
<RatingGroup defaultValue={5} name="review">
<RatingGroupLabel>Label</RatingGroupLabel>
<RatingStars />
</RatingGroup>
<Button size="sm" type="submit" variant="default">
Submit
</Button>
{submitted !== null ? (
<p className="text-muted-foreground text-xs">
Rating value:{" "}
<span className="font-medium text-foreground">{submitted}</span>
</p>
) : null}
</form>
);
};
export default RatingGroupForm;
Disabled
import {
RatingGroup,
RatingGroupLabel,
RatingStars,
} from "@/components/ui/rating-group";
const RatingGroupDisabled = () => (
<RatingGroup defaultValue={2} disabled>
<RatingGroupLabel>Label</RatingGroupLabel>
<RatingStars />
</RatingGroup>
);
export default RatingGroupDisabled;
API reference
This component mirrors the upstream Ark UI primitive.
See the ARK UI documentation for the full API.
Accessibility
See the Ark UI documentation for clarification.