sveltelean Primitives
Versionv0.2.0 GitHub

Example

Your rating
Your rating
<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>
Tier 0: hover previews a value, a click or the arrow keys choose it, and the cross before the stars clears it. Everything here works with page JavaScript disabled.

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/styles
stylesheets
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 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

PartElementdata-slean-partRequiredNotes
root<fieldset data-slean="rating">–yesNamed 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>–yesThe 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>–noFirst, 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

KeyWhenResult
Tabon the pageEnters the group at the checked value; the next Tab leaves it (native)
ArrowRight/ArrowUpfocus in the groupChecks the next value, wrapping (native; ArrowRight mirrored under RTL in Chrome)
ArrowLeft/ArrowDownfocus in the groupChecks the previous value, wrapping (native)

Platform features

FeatureBaselineOutside the target
Radios, <fieldset>, inline SVG styled by CSSWidely availableNot 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:

TokenDefault (light)Applies to
--slean-space-10.25remmargin-block-end
--slean-fgvar(--slean-neutral-12)color
--slean-text-sm0.875remfont-size
--slean-font-weight-medium500font-weight
--slean-control-height-sm2reminline-size, block-size
--slean-radius-sm0.375remborder-radius
--slean-fg-mutedvar(--slean-neutral-11)color
--slean-mutedvar(--slean-neutral-3)fill
--slean-border-strongvar(--slean-neutral-8)stroke
--slean-duration-fast100mstransition
--slean-easecubic-bezier(0.2, 0, 0, 1)transition
--slean-accentoklch(54% 0.19 258)fill, stroke
--slean-icon-closeurl("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-hoveroklch(49% 0.19 258)fill, stroke
--slean-focus-ringvar(--slean-focus-ring-width) solid var(--slean-focus-ring-color)outline
--slean-focus-ring-width2pxoutline-offset
--slean-control-height-md2.25reminline-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.

Rated by the team
disabled
<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

Source