Rating Group

Star-based rating input component supporting controlled/uncontrolled modes, half-star ratings, and disabled/readonly states.

import { RatingGroup, RatingGroupLabel } from "~/components/rating-group";

export default function RatingGroupBasicDemo() {
  return (
    <RatingGroup count={5} defaultValue={3}>
      <RatingGroupLabel>Rate this</RatingGroupLabel>
    </RatingGroup>
  );
}

Installation

CLI

Run the following command to add the component to your project:

npx @ark-preset/cli@latest add rating-group

Manual

Create the recipe file at src/components/recipes/rating-group.ts:

import { tv, type VariantProps } from "tailwind-variants";

export const ratingGroupVariants = tv({
  slots: {
    root: "flex items-center gap-2",
    control: "flex items-center gap-0.5",
    item: [
      "group cursor-pointer transition duration-300 motion-safe:hover:-translate-y-0.5 motion-safe:active:translate-y-0.5 active:duration-150 ease-out",
      "data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed",
      "data-[readonly]:cursor-default",
      "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-sm",
    ],
    itemIndicator: "relative inline-flex items-center justify-center",
  },
  variants: {
    size: {
      sm: {
        item: "size-4",
      },
      md: {
        item: "size-5",
      },
      lg: {
        item: "size-6",
      },
    },
    orientation: {
      horizontal: {
        root: "flex-row items-center gap-2",
      },
      vertical: {
        root: "flex-col items-start gap-1",
      },
    },
  },
  defaultVariants: {
    size: "md",
    orientation: "horizontal",
  },
});

export type RatingGroupVariants = VariantProps<typeof ratingGroupVariants>;

Create the component directory and files.

src/components/rating-group/rating-group.base.tsx:

import { RatingGroup as ArkRatingGroup } from "@ark-ui/solid/rating-group";
import { createContext, useContext, splitProps, type Component } from "solid-js";
import { ratingGroupVariants, labelVariants, type RatingGroupVariants } from "../recipes/rating-group";

type RatingGroupVariantContextValue = Pick<RatingGroupVariants, "size" | "orientation">;

const RatingGroupVariantContext = createContext<RatingGroupVariantContextValue>();

const useRatingGroupVariant = () => useContext(RatingGroupVariantContext);

const styles = ratingGroupVariants();

const Root: Component<ArkRatingGroup.RootProps & RatingGroupVariants> = (props) => {
  const [local, others] = splitProps(props, ["class", "size", "orientation"]);
  return (
    <RatingGroupVariantContext.Provider
      value={{ size: local.size, orientation: local.orientation }}
    >
      <ArkRatingGroup.Root
        class={styles.root({
          class: local.class,
          size: local.size,
          orientation: local.orientation,
        })}
        {...others}
      />
    </RatingGroupVariantContext.Provider>
  );
};

const RootProvider: Component<ArkRatingGroup.RootProviderProps & RatingGroupVariants> = (props) => {
  const [local, others] = splitProps(props, ["class", "size", "orientation"]);
  return (
    <RatingGroupVariantContext.Provider
      value={{ size: local.size, orientation: local.orientation }}
    >
      <ArkRatingGroup.RootProvider
        class={styles.root({
          class: local.class,
          size: local.size,
          orientation: local.orientation,
        })}
        {...others}
      />
    </RatingGroupVariantContext.Provider>
  );
};

const Label: Component<ArkRatingGroup.LabelProps> = (props) => {
  const [local, others] = splitProps(props, ["class"]);
  return <ArkRatingGroup.Label class={labelVariants({ class: local.class })} {...others} />;
};

const Control: Component<ArkRatingGroup.ControlProps> = (props) => {
  const [local, others] = splitProps(props, ["class"]);
  return <ArkRatingGroup.Control class={styles.control({ class: local.class })} {...others} />;
};

const Item: Component<ArkRatingGroup.ItemProps & RatingGroupVariants> = (props) => {
  const ctx = useRatingGroupVariant();
  const [local, others] = splitProps(props, ["class", "size", "orientation"]);
  return (
    <ArkRatingGroup.Item
      class={styles.item({
        class: local.class,
        size: local.size ?? ctx?.size,
        orientation: local.orientation ?? ctx?.orientation,
      })}
      {...others}
    />
  );
};

const ItemContext = ArkRatingGroup.ItemContext;

const Context = ArkRatingGroup.Context;

const HiddenInput = ArkRatingGroup.HiddenInput;

export const RatingGroup = {
  Root,
  RootProvider,
  Label,
  Control,
  Item,
  ItemContext,
  Context,
  HiddenInput,
};

export { RatingGroupVariantContext, useRatingGroupVariant };

src/components/rating-group/index.tsx:

import { Index, splitProps, type Component } from "solid-js";
import { RatingGroup as RatingGroupBase } from "./rating-group.base";
import { RatingGroup as ArkRatingGroup } from "@ark-ui/solid/rating-group";
import { ratingGroupVariants, type RatingGroupVariants } from "../recipes/rating-group";

const styles = ratingGroupVariants();

const RatingGroupItem: Component<ArkRatingGroup.ItemProps & RatingGroupVariants> = (props) => {
  const [local, others] = splitProps(props, ["index", "class", "size"]);
  return (
    <RatingGroupBase.Item
      index={local.index}
      class={styles.item({ class: local.class, size: local.size })}
      {...others}
    >
      <RatingGroupBase.ItemContext>
        {() => (
          <span class={styles.itemIndicator()}>
            {/* Background unfilled star */}
            <svg
              xmlns="http://www.w3.org/2000/svg"
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              stroke-width="2"
              stroke-linecap="round"
              stroke-linejoin="round"
              class="size-full"
            >
              <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
            </svg>
            {/* Foreground filled star (hidden when not highlighted, clip-path for half) */}
            <svg
              xmlns="http://www.w3.org/2000/svg"
              viewBox="0 0 24 24"
              fill="currentColor"
              stroke="currentColor"
              stroke-width="2"
              stroke-linecap="round"
              stroke-linejoin="round"
              class="absolute inset-0 size-full opacity-0 group-data-[highlighted]:opacity-100 group-data-[half]:[clip-path:inset(0_50%_0_0)]"
            >
              <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
            </svg>
          </span>
        )}
      </RatingGroupBase.ItemContext>
    </RatingGroupBase.Item>
  );
};

const RatingGroup: Component<ArkRatingGroup.RootProps & RatingGroupVariants> = (props) => {
  const [local, others] = splitProps(props, ["size", "orientation", "children"]);
  return (
    <RatingGroupBase.Root size={local.size} orientation={local.orientation} {...others}>
      {local.children}
      <RatingGroupBase.Context>
        {(context) => (
          <RatingGroupBase.Control>
            <Index each={context().items}>{(item) => <RatingGroupItem index={item()} />}</Index>
            <RatingGroupBase.HiddenInput />
          </RatingGroupBase.Control>
        )}
      </RatingGroupBase.Context>
    </RatingGroupBase.Root>
  );
};

const RatingGroupLabel: Component<ArkRatingGroup.LabelProps> = (props) => {
  return <RatingGroupBase.Label {...props} />;
};

const RatingGroupControl: Component<ArkRatingGroup.ControlProps> = (props) => {
  return <RatingGroupBase.Control {...props} />;
};

const RatingGroupContext: Component<ArkRatingGroup.ContextProps> = (props) => {
  return <RatingGroupBase.Context {...props} />;
};

const RatingGroupHiddenInput: Component<ArkRatingGroup.HiddenInputProps> = (props) => {
  return <RatingGroupBase.HiddenInput {...props} />;
};

export {
  RatingGroup,
  RatingGroupItem,
  RatingGroupLabel,
  RatingGroupControl,
  RatingGroupContext,
  RatingGroupHiddenInput,
  RatingGroupBase,
};

export { ratingGroupVariants, type RatingGroupVariants } from "../recipes/rating-group";

Note: Make sure your project has the Tailwind CSS theme variables set up (--background, --foreground, --ring, --border, etc.) or override the utility classes to match your design system.

Usage

Basic Usage

import { RatingGroup, RatingGroupLabel } from "~/components/rating-group";

export default function RatingGroupBasicDemo() {
  return (
    <RatingGroup count={5} defaultValue={3}>
      <RatingGroupLabel>Rate this</RatingGroupLabel>
    </RatingGroup>
  );
}

Controlled

Use value and onValueChange to control the rating value externally:

Value: 3

import { createSignal } from "solid-js";
import { RatingGroup } from "~/components/rating-group";

export default function RatingGroupControlledDemo() {
  const [value, setValue] = createSignal(3);
  return (
    <div>
      <p class="text-sm text-muted-foreground mb-2">Value: {value()}</p>
      <RatingGroup count={5} value={value()} onValueChange={(e: any) => setValue(e.value)} />
    </div>
  );
}

Half Star

Allow 0.5 value steps by setting the allowHalf prop to true:

import { RatingGroup, RatingGroupLabel } from "~/components/rating-group";

export default function RatingGroupHalfStarDemo() {
  return (
    <RatingGroup count={5} defaultValue={2.5} allowHalf>
      <RatingGroupLabel>Rate this</RatingGroupLabel>
    </RatingGroup>
  );
}

Disabled

To make the rating group disabled, set the disabled prop to true:

import { RatingGroup, RatingGroupLabel } from "~/components/rating-group";

export default function RatingGroupDisabledDemo() {
  return (
    <RatingGroup count={5} defaultValue={4} disabled>
      <RatingGroupLabel>Rate this</RatingGroupLabel>
    </RatingGroup>
  );
}

Advanced Usage

When the composite RatingGroup doesn’t provide enough control, import the raw primitive parts from the base file directly:

import { RatingGroup, useRatingGroupVariant } from "~/components/rating-group/rating-group.base";

Or import RatingGroupBase (the raw parts namespace) from the composite entry point:

import { RatingGroupBase } from "~/components/rating-group";

RootProvider Pattern

For full control over the rating group machine, use useRatingGroup with RatingGroup.RootProvider:

RootProvider: 3

import { useRatingGroup } from "@ark-ui/solid/rating-group";
import { RatingGroupBase } from "~/components/rating-group";
import { Index } from "solid-js";

export default function RatingGroupRootProviderDemo() {
  const ratingGroup = useRatingGroup({ count: 5, defaultValue: 3 });

  return (
    <div>
      <p class="text-sm text-muted-foreground mb-2">RootProvider: {ratingGroup().value}</p>
      <RatingGroupBase.RootProvider value={ratingGroup}>
        <RatingGroupBase.Label>Rate this</RatingGroupBase.Label>
        <RatingGroupBase.Context>
          {(context: any) => (
            <RatingGroupBase.Control>
              <Index each={context().items}>
                {(item) => (
                  <RatingGroupBase.Item index={item()}>
                    <RatingGroupBase.ItemContext>
                      {() => (
                        <span class="relative inline-flex items-center justify-center size-5">
                          <svg
                            xmlns="http://www.w3.org/2000/svg"
                            viewBox="0 0 24 24"
                            fill="none"
                            stroke="currentColor"
                            stroke-width="2"
                            stroke-linecap="round"
                            stroke-linejoin="round"
                            class="size-full"
                          >
                            <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
                          </svg>
                          <svg
                            xmlns="http://www.w3.org/2000/svg"
                            viewBox="0 0 24 24"
                            fill="currentColor"
                            stroke="currentColor"
                            stroke-width="2"
                            stroke-linecap="round"
                            stroke-linejoin="round"
                            class="absolute inset-0 size-full opacity-0 group-data-[highlighted]:opacity-100 group-data-[half]:[clip-path:inset(0_50%_0_0)]"
                          >
                            <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
                          </svg>
                        </span>
                      )}
                    </RatingGroupBase.ItemContext>
                  </RatingGroupBase.Item>
                )}
              </Index>
              <RatingGroupBase.HiddenInput />
            </RatingGroupBase.Control>
          )}
        </RatingGroupBase.Context>
      </RatingGroupBase.RootProvider>
    </div>
  );
}

API Reference

See the Ark UI RatingGroup documentation.