# Rating

> Radio inputs drawn as stars: a single choice with native keyboard, form value and labels.

Page: https://svelte-lean.vercel.app/primitives/rating · Markdown: https://svelte-lean.vercel.app/primitives/rating.md · Index: https://svelte-lean.vercel.app/llms.txt

A star rating for one score out of a few, built from native radios in a fieldset and drawn by CSS.

```svelte
<script lang="ts">
	// Nothing to import: the browser owns the radios. The stylesheet is optional.
	import '@svelte-lean/styles/rating.css';

	const STAR = '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';

	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={STAR} /></svg>
			<span>{value} {value === 1 ? 'star' : 'stars'}</span>
		</label>
	{/each}
</fieldset>
```

Hover previews a value, a click or the arrow keys choose it, and the cross before the stars clears it.

## Examples

### Value names

```svelte
<script lang="ts">
	// Nothing to import: the browser owns the radios. The stylesheet is optional.
	import '@svelte-lean/styles/rating.css';

	const STAR = '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';

	const NAMES = ['Poor', 'Fair', 'Good', 'Very good', 'Excellent'];
	let quality = $state(4);
</script>

<div class="named">
	<fieldset data-slean="rating">
		<legend>Sound quality</legend>
		{#each NAMES as name, i (name)}
			<label>
				<input type="radio" name="rating-quality" value={i + 1} bind:group={quality} />
				<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d={STAR} /></svg>
				<span>{name}</span>
			</label>
		{/each}
	</fieldset>
	<span aria-hidden="true">{NAMES[quality - 1]}</span>
</div>

<style>
	.named {
		display: flex;
		align-items: end;
		gap: var(--slean-space-3);
		font-size: var(--slean-text-sm);
	}

	.named > span {
		padding-block-end: var(--slean-space-2);
	}
</style>
```

Each value’s hidden text is its name; the visible word repeats it for sighted readers.

### Custom shape

```svelte
<script lang="ts">
	import '@svelte-lean/styles/rating.css';

	// The shape is the author's svg; the stylesheet sets its fill and stroke.
	const HEART = 'M12 20.5s-7.5-4.6-9.2-9.3C1.6 7.9 3.6 4.5 7 4.5c2.1 0 3.6 1.2 5 3 1.4-1.8 2.9-3 5-3 3.4 0 5.4 3.4 4.2 6.7-1.7 4.7-9.2 9.3-9.2 9.3z';
</script>

<fieldset data-slean="rating">
	<legend>How much did you enjoy it</legend>
	{#each [1, 2, 3, 4, 5] as value (value)}
		<label>
			<input type="radio" name="rating-hearts" {value} checked={value === 4} />
			<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d={HEART} /></svg>
			<span>{value} {value === 1 ? 'heart' : 'hearts'}</span>
		</label>
	{/each}
</fieldset>
```

### In a form

```svelte
<script lang="ts">
	// Nothing to import: the browser owns the radios. The stylesheet is optional.
	import '@svelte-lean/styles/rating.css';

	const STAR = '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';
	import '@svelte-lean/styles/field.css';
	import '@svelte-lean/styles/input.css';
	import '@svelte-lean/styles/button.css';

	let sent = $state('');

	function send(event: SubmitEvent) {
		event.preventDefault();
		const data = new FormData(event.currentTarget as HTMLFormElement);
		sent = `${data.get('stars') ?? 'no'} stars: ${data.get('review')}`;
	}
</script>

<form class="review" onsubmit={send}>
	<fieldset data-slean="rating">
		<legend>Rate this product</legend>
		{#each [1, 2, 3, 4, 5] as value (value)}
			<label>
				<input type="radio" name="stars" {value} required />
				<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d={STAR} /></svg>
				<span>{value} {value === 1 ? 'star' : 'stars'}</span>
			</label>
		{/each}
	</fieldset>
	<div data-slean="field">
		<label for="rating-review" data-slean-part="label">Review</label>
		<textarea id="rating-review" name="review" rows="3" data-slean="input"></textarea>
	</div>
	<button type="submit" data-slean="button">Post review</button>
	{#if sent}<p role="status">Sent: {sent}</p>{/if}
</form>

<style>
	.review {
		display: grid;
		justify-items: start;
		gap: var(--slean-space-4);
		max-inline-size: 24rem;
	}

	.review > [data-slean='field'] {
		inline-size: 100%;
	}

	.review p {
		margin: 0;
	}
</style>
```

The checked radio is submitted under its name; required asks for a value first.

### Disabled

```svelte
<script lang="ts">
	// Nothing to import: the browser owns the radios. The stylesheet is optional.
	import '@svelte-lean/styles/rating.css';

	const STAR = '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';
</script>

<!-- disabled: out of the tab order and the form data, dimmed, no hover preview. -->
<fieldset data-slean="rating" disabled>
	<legend>Rated by the team</legend>
	{#each [1, 2, 3, 4, 5] as value (value)}
		<label>
			<input type="radio" name="rating-team" {value} checked={value === 4} />
			<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d={STAR} /></svg>
			<span>{value} {value === 1 ? 'star' : 'stars'}</span>
		</label>
	{/each}
</fieldset>
```

For an average that is not a whole number, show text or a Meter instead.

## Usage

Plain markup with a stylesheet: install `@svelte-lean/styles`. No JavaScript runs.

```bash
npm install @svelte-lean/styles
```

`stylesheets`

```ts
import '@svelte-lean/styles/rating.css';
```

Put `data-slean="rating"` on a `<fieldset>` with a `<legend>`, and one `<label>` per value in ascending order, each holding a radio, an `<svg aria-hidden="true">` and a `<span>` with the value’s name. A first label without an svg is the “no rating” option, drawn as a cross.

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. |

## 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) |

## 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 current color (`data-color` on the root or an ancestor, primary by default), previews under `(hover: hover)`, draws the cross of the no-rating option as the masked close icon, and dims a disabled rating. The shape is the author’s svg; the stylesheet sets its `fill` and `stroke`. `data-size` (small, medium, large) sets the star and its target. The tokens it reads:

| Token | Default (light) | Applies to |
| --- | --- | --- |
| `--slean-space-1` | `calc(var(--slean-spacing) * 1)` | 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` | `calc(var(--slean-radius) * 0.6)` | 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-color-main` | `var(--slean-primary-main)` | fill, stroke |
| `--slean-control-height-xs` | `1.75rem` | inline-size, block-size |
| `--slean-control-height-md` | `2.25rem` | inline-size, block-size |
| `--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-color-dark` | `var(--slean-primary-dark)` | fill, stroke |
| `--slean-focus-ring` | `var(--slean-focus-ring-width) solid var(--slean-focus-ring-color)` | outline |
| `--slean-focus-ring-width` | `2px` | outline-offset |

State selectors the stylesheet targets, all from the platform or ARIA: `:checked`, `:disabled`, `:focus-visible`, `:hover`, `[type="radio"]`.

Variant attributes: `data-size` (`small`, `large`).

## Under the hood

Runtime and size

- **Tier**: 0 · Native
- **Behavior JS**: 0 B brotli · 0 B gzip · `fixtures/results.json (native-only)` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **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`

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.

Why this implementation

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 mirrors them in `aria-checked`.

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 selectors with `:hover` preview a value, and each radio covers its star, transparent and focusable. The value’s text (“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

Accessibility

- 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 current color (data-color, primary by default), as for a checked checkbox.
- Forced colors: filled stars take the Highlight system color, empty stars an outline in the text color.

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 and hydration

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.

Nothing is attached; the radios work before and after hydration alike.

Compatibility

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.

Tests and source

- `apps/playground/tests/primitives/forms.spec.ts` Playwright: keyboard, form values, :user-invalid, names and descriptions, axe
- `packages/styles/tests` static checks of the stylesheet (layers, tokens, specificity, dark parity)
- `fixtures/native-only` consumer build asserting that no behavior runtime ships (invariant A)

- `packages/primitives/src/rating/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/rating/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/styles/css/rating.css` the optional stylesheet
