Rating Group#

Radio semantics over a row of symbols — stars, hearts, whatever the design draws. The model is a plain number (0 is no rating), each item is a role="radio" whose data-state is full, half or empty, and the keyboard moves the value rather than focus between elements, which is what lets allowHalf put two values on one symbol.

Import#

TSX
import { RatingGroup } from '@sigx/zero/rating-group';

RatingGroup is a compound: RatingGroup.Root, RatingGroup.Label, RatingGroup.Control, RatingGroup.Item. It is also re-exported from the @sigx/zero root, together with ratingGroupAnatomy, useRatingGroupContext and the RatingItemSlotProps type.

Usage#

TSX
import { component } from 'sigx';
import { RatingGroup } from '@sigx/zero/rating-group';

const Review = component(({ signal }) => {
    const state = signal({ stars: 0 });

    return () => (
        <RatingGroup.Root model={() => state.stars} name="stars" allowHalf>
            <RatingGroup.Label>Rating</RatingGroup.Label>
            <RatingGroup.Control>
                {[1, 2, 3, 4, 5].map((i) => <RatingGroup.Item index={i} key={i} />)}
            </RatingGroup.Control>
        </RatingGroup.Root>
    );
});

model={() => state.stars} binds the value both ways. Leave the model off and pass defaultValue to keep the state inside the component; valueChange fires either way. See Models.

Items are explicit children, index 1 through count — zero owns no iteration. count defaults to 5 and is what End jumps to, so keep it equal to the number of items you render.

Preview and commit#

Each item's data-state is driven by the displayed value: the hover preview while a pointer is over the control, the committed value otherwise. A recipe styles the fill once and never has to distinguish preview from commit. data-highlighted marks the items inside the preview range while a pointer hovers, and the preview clears when the pointer leaves the control. With allowHalf the pointer's horizontal position within an item decides the half (flipped under RTL); a tap commits with the same arithmetic.

Your own symbol#

TSX
<RatingGroup.Item index={i}>
    {({ state, highlighted }) => <HeartIcon fill={state} dim={!highlighted} />}
</RatingGroup.Item>

The default slot receives { state, highlighted } for swapping SVGs. Without a slot each item renders a bare text node: for full and half, for empty. half is deliberately a full star — the half-star codepoint U+2BEA has almost no coverage in the common system sans stacks and renders as a tofu box, which states the value less accurately than a star does. Drawing a distinct half is the design system's job; see below.

Localising item names#

TSX
<RatingGroup.Root model={() => state.stars} itemLabel={(index, count) => `${index} av ${count}`}>

Each item carries an aria-label, defaulting to "<index> of <count>". itemLabel is the localisation seam.

Deselect and read-only#

deselectable makes clicking the current value clear it to 0. readonly keeps the value visible and focusable but rejects pointer and keyboard changes; disabled makes the control inert and takes it out of the tab order.

Anatomy#

PartElementStatesFlagsNotes
rootdivdisabled, invalid, required, readonlyCarries the variant axes.
labeldivdisabled, invalid, requiredNames the control through aria-labelledby. Inside root.
controldivdisabled, readonly, focus-visiblerole="radiogroup", aria-labelledby, aria-describedby. Mirrors the focused item's focus-visible. Inside root.
itemspanfull | half | emptyhighlighted, disabled, readonly, focus-visiblerole="radio", aria-checked, aria-label, roving tabIndex. Inside control.
hidden-inputinputtype="hidden"; rendered when name is set, empty while the value is 0.

Every part carries data-scope="rating-group" and data-part="<part>". item carries the one three-value state set in the library. Inside a Field the label and control adopt the field's ids and the control announces the field's descriptions. See The anatomy contract.

Props#

RatingGroup.Root#

PropTypeDefaultDescription
modelnumberTwo-way binding of the rating; 0 is no rating.
defaultValuenumber0Initial value when uncontrolled.
valueChangeevent (value: number)Fires whenever the rating changes.
countnumber5How many items are rendered; End jumps here.
allowHalfbooleanfalsePointer halves and 0.5 keyboard steps.
deselectablebooleanfalseClicking the current value clears to 0.
namestringForm field name; renders the hidden input.
requiredbooleanfalseRenders data-required.
invalidbooleanfalseRenders data-invalid.
readonlybooleanfalseRejects changes; renders data-readonly.
disabledbooleanfalseInert; renders data-disabled and aria-disabled.
itemLabel(index: number, count: number) => string"<index> of <count>"Per-item accessible name.
color / size / variant / axes / modsdesign-system vocabularyThe variant axes, rendered as data-* on root.
classstringExtra classes on the root element.

Field context (disabled, invalid, required, the label and control ids, aria-describedby) is merged with the props.

RatingGroup.Label, RatingGroup.Control#

Only class.

RatingGroup.Item#

PropTypeDefaultDescription
indexnumberrequiredThe value this item represents, 1 through count.
classstringExtra classes.

The default slot receives { state, highlighted }.

Keyboard#

One tab stop: the item for the ceiling of the value, or item 1 while the value is 0. The keys move the value, and the tab stop follows it.

KeyAction
ArrowRight / ArrowUpIncrease by the step (1, or 0.5 with allowHalf); ArrowRight flips under RTL.
ArrowLeft / ArrowDownDecrease by the step; ArrowLeft flips under RTL.
HomeThe smallest non-zero value (one step).
Endcount.
Click / tapCommit the value under the pointer; the current value again clears it under deselectable.

In the shipped design systems#

Both @sigx/zero-basic and @sigx/zero-daisyui wire color (all eight recommended roles) and size (xsxl) on rating-group, so <RatingGroup.Root color="warning" size="lg"> is styled in both. Neither wires a variant on the scope; under a design system's /register import the prop is therefore absent. See Typed vocabulary.

The default symbol must stay a bare text node, and a design system relies on that: a consumer symbol arrives as an element, so a recipe tells the two apart with &:not(:has(> *)) (draw your own star geometry over the text) versus &:has(*) (leave the consumer's SVG alone and only colour it). A distinct half is the recipe's to draw — either with its own geometry, or by halving the full-width glyph with a mask-size: 50% 100% or a hard-stop gradient under background-clip: text, both of which need a full star in all three states to cut in two. Nothing is lost to assistive technology either way: the value lives on the hidden input and each item carries its own aria-label.

Radio Group for a labelled single choice, Field for labelling.