Primitives Forms
Rating
Radio inputs in a fieldset, drawn as stars. A rating is a single choice: the browser owns it, the arrow keys, the single tab stop and the form value; the stylesheet fills the stars up to the checked one. Tier 0.
- Tier
- 0 · Native
- Behavior JS
- 0 B brotli · 0 B gzip ·
fixtures/· methodresults.json (native-only) - Platform features
<input type="radio">,<fieldset>,<legend>,:has(),inline SVG- Shared listeners
- none
- Per-instance listeners
- none
- Lazy state
- none
- Native base
<fieldset> of <input type="radio"> sharing a name
On this page
Example
<fieldset data-slean="rating">
<legend>Your rating</legend>
<label>
<input type="radio" name="rating-score" value="0" />
<span>No rating</span>
</label>
<label>
<input type="radio" name="rating-score" value="1" />
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 2.8l2.83 5.73 6.33.92-4.58 4.46 1.08 6.3L12 17.24l-5.66 2.97 1.08-6.3-4.58-4.46 6.33-.92z" /></svg>
<span>1 star</span>
</label>
<label>
<input type="radio" name="rating-score" value="2" />
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 2.8l2.83 5.73 6.33.92-4.58 4.46 1.08 6.3L12 17.24l-5.66 2.97 1.08-6.3-4.58-4.46 6.33-.92z" /></svg>
<span>2 stars</span>
</label>
<label>
<input type="radio" name="rating-score" value="3" checked />
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 2.8l2.83 5.73 6.33.92-4.58 4.46 1.08 6.3L12 17.24l-5.66 2.97 1.08-6.3-4.58-4.46 6.33-.92z" /></svg>
<span>3 stars</span>
</label>
<label>
<input type="radio" name="rating-score" value="4" />
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 2.8l2.83 5.73 6.33.92-4.58 4.46 1.08 6.3L12 17.24l-5.66 2.97 1.08-6.3-4.58-4.46 6.33-.92z" /></svg>
<span>4 stars</span>
</label>
<label>
<input type="radio" name="rating-score" value="5" />
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 2.8l2.83 5.73 6.33.92-4.58 4.46 1.08 6.3L12 17.24l-5.66 2.97 1.08-6.3-4.58-4.46 6.33-.92z" /></svg>
<span>5 stars</span>
</label>
</fieldset><script lang="ts">
// Nothing to import: the browser owns the radios. The stylesheet is optional.
import '@svelte-lean/styles/rating.css';
let score = $state(3);
</script>
<fieldset data-slean="rating">
<legend>Your rating</legend>
<label>
<input type="radio" name="rating-score" value={0} bind:group={score} />
<span>No rating</span>
</label>
{#each [1, 2, 3, 4, 5] as value (value)}
<label>
<input type="radio" name="rating-score" {value} bind:group={score} />
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 2.8l2.83 5.73 6.33.92-4.58 4.46 1.08 6.3L12 17.24l-5.66 2.97 1.08-6.3-4.58-4.46 6.33-.92z" /></svg>
<span>{value} {value === 1 ? 'star' : 'stars'}</span>
</label>
{/each}
</fieldset>Why this implementation exists
A star rating is one choice among five values, which is what radios sharing a name are. The browser keeps one checked, moves it with the arrow keys, makes the group a single tab stop, submits the value, and names each radio from its label; a rating component rebuilds those in script and adds aria-checked to mirror them.
The stylesheet draws the stars. label:has(input:checked) and label:has(~ label input:checked) fill the checked star and every star before it, the same two selectors with :hover preview a value under a hovering pointer, and each radio covers its star, transparent and focusable. The text of each value (“3 stars”) is in the label and hidden visually, so screen readers hear the value rather than a picture.
The browser owns
- the single choice among radios with one name
- the arrow keys, wrapping, and the single tab stop
- each value’s name from its label text, the group name from <legend>
- the form value and the change event
Svelte Lean owns
- rating.css: the filled run up to the checked star, the hover preview, the focus ring on the star, the cross of the no-rating option, the visually hidden text
- the contract
- documentation
Usage
The markup needs no package. Install @svelte-lean/styles for the stylesheet; @svelte-lean/primitives adds the typed contract and nothing at runtime.
npm install @svelte-lean/stylespnpm add @svelte-lean/stylesyarn add @svelte-lean/stylesbun add @svelte-lean/stylesimport '@svelte-lean/styles/rating.css';Put data-slean="rating" on a <fieldset> with a <legend>, and one <label> per value in ascending order. Each label holds a radio, an inline <svg aria-hidden="true"> star and a <span> with the value’s name.
An optional first label without an svg is the “no rating” option; it is drawn as a cross and clears the rating.
Bind the value with bind:group, read it from FormData, or listen to the native change event.
Anatomy
| Part | Element | data-slean-part | Required | Notes |
|---|---|---|---|---|
| root | <fieldset data-slean="rating"> | – | yes | Named by its <legend>. One radio per value, in ascending order. |
| star | <label><input type="radio" value="3"> <svg aria-hidden="true">…</svg> <span>3 stars</span></label> | – | yes | The span names the radio and is hidden visually; the svg is decoration colored by the stylesheet. |
| no rating | <label><input type="radio" value="0"> <span>No rating</span></label> | – | no | First, without an svg; drawn as a cross. Clears the rating. |
Runtime profile
Tier 0: the rating has no behavior module, the Vite plugin maps rating to no module, and the native-only fixture proves the path ships no Svelte Lean JavaScript.
Accessibility contract
- The fieldset is a group named by its legend; each radio is named by its visually hidden text (“1 star”, “No rating”), because the star is
aria-hidden. - The group is one tab stop; the arrow keys move the value and wrap. With the “no rating” option, the keyboard reaches “no value” as well.
- The focus ring is drawn around the focused star, only for keyboard focus.
- A filled star and an empty one differ in fill and outline, not only in hue; the fill is the accent token, as for a checked checkbox.
- Forced colors: filled stars take the Highlight system color, empty stars an outline in the text color.
Keyboard
| Key | When | Result |
|---|---|---|
| Tab | on the page | Enters the group at the checked value; the next Tab leaves it (native) |
| ArrowRight/ArrowUp | focus in the group | Checks the next value, wrapping (native; ArrowRight mirrored under RTL in Chrome) |
| ArrowLeft/ArrowDown | focus in the group | Checks the previous value, wrapping (native) |
Platform features
| Feature | Baseline | Outside the target |
|---|---|---|
Radios, <fieldset>, inline SVG styled by CSS | Widely available | Not applicable |
:has() | Baseline 2023 (Chrome 105, Safari 15.4, Firefox 121) | No star is filled; the radios keep working |
Without JavaScript
Fully functional, hover preview included.
Server rendering
The checked attribute renders the initial value, and the filled stars are drawn by CSS from it, so the server’s markup shows the rating before any script runs.
Before hydration
Nothing is attached; the radios work before and after hydration alike.
Styling
rating.css places the labels in a row, covers each star with its transparent radio, hides the value text visually, fills the stars up to the checked one from the accent token, previews under (hover: hover), draws the cross of the no-rating option with two pseudo-elements, and dims a disabled rating. The star shape is the author’s svg; the stylesheet sets its fill and stroke. The tokens it reads:
| Token | Default (light) | Applies to |
|---|---|---|
--slean-space-1 | 0.25rem | margin-block-end |
--slean-fg | var(--slean-neutral-12) | color |
--slean-text-sm | 0.875rem | font-size |
--slean-font-weight-medium | 500 | font-weight |
--slean-control-height-sm | 2rem | inline-size, block-size |
--slean-radius-sm | 0.375rem | border-radius |
--slean-fg-muted | var(--slean-neutral-11) | color |
--slean-muted | var(--slean-neutral-3) | fill |
--slean-border-strong | var(--slean-neutral-8) | stroke |
--slean-duration-fast | 100ms | transition |
--slean-ease | cubic-bezier(0.2, 0, 0, 1) | transition |
--slean-accent | oklch(54% 0.19 258) | fill, stroke |
--slean-icon-close | url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4.5 4.5l7 7m0-7l-7 7' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E") | mask |
--slean-accent-hover | oklch(49% 0.19 258) | fill, stroke |
--slean-focus-ring | var(--slean-focus-ring-width) solid var(--slean-focus-ring-color) | outline |
--slean-focus-ring-width | 2px | outline-offset |
--slean-control-height-md | 2.25rem | inline-size, block-size |
State selectors the stylesheet targets, all from the platform or ARIA: :checked, :disabled, :focus-visible, :hover, [type="radio"].
Compatibility notes
Radios, fieldsets and inline SVG are widely available. The filled stars and the preview are selected with :has(), Baseline 2023; where it is missing no star is filled and the radios keep working.
Examples
Disabled
disabled on the fieldset takes every radio out of the tab order and the form data, dims
the stars and turns the hover preview off. The checked value stays visible. For an average that is
not a whole number, show text or a Meter instead.
<fieldset data-slean="rating" disabled>
<legend>Rated by the team</legend>
<label>
<input type="radio" name="rating-average" value="1" />
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 2.8l2.83 5.73 6.33.92-4.58 4.46 1.08 6.3L12 17.24l-5.66 2.97 1.08-6.3-4.58-4.46 6.33-.92z" /></svg>
<span>1 star</span>
</label>
<label>
<input type="radio" name="rating-average" value="2" />
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 2.8l2.83 5.73 6.33.92-4.58 4.46 1.08 6.3L12 17.24l-5.66 2.97 1.08-6.3-4.58-4.46 6.33-.92z" /></svg>
<span>2 stars</span>
</label>
<label>
<input type="radio" name="rating-average" value="3" />
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 2.8l2.83 5.73 6.33.92-4.58 4.46 1.08 6.3L12 17.24l-5.66 2.97 1.08-6.3-4.58-4.46 6.33-.92z" /></svg>
<span>3 stars</span>
</label>
<label>
<input type="radio" name="rating-average" value="4" checked />
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 2.8l2.83 5.73 6.33.92-4.58 4.46 1.08 6.3L12 17.24l-5.66 2.97 1.08-6.3-4.58-4.46 6.33-.92z" /></svg>
<span>4 stars</span>
</label>
<label>
<input type="radio" name="rating-average" value="5" />
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 2.8l2.83 5.73 6.33.92-4.58 4.46 1.08 6.3L12 17.24l-5.66 2.97 1.08-6.3-4.58-4.46 6.33-.92z" /></svg>
<span>5 stars</span>
</label>
</fieldset>Testing
apps/playground/tests/primitives/forms.spec.tsPlaywright: keyboard, form values, :user-invalid, names and descriptions, axepackages/styles/testsstatic checks of the stylesheet (layers, tokens, specificity, dark parity)fixtures/native-onlyconsumer build asserting that no behavior runtime ships (invariant A)
Source
packages/primitives/src/rating/contract.mdthe contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/rating/contract.tstyped constants: name, tier, base, parts, options, eventspackages/styles/css/rating.cssthe optional stylesheet