Rating

Pixel hearts or stars for a score, with half steps, hover preview and keys.

zweiundeins <sb-rating> rating stars hearts score form Since 2026-09-22 MIT licence 2.2 kB0 Open in playground Edit on GitHub

Playground

A row of pixel hearts (or stars) for a score. Point to preview, click to pick (on touch, tap or drag along the row), or use the arrow keys. The live value is the value property, so data-bind works, and a new value attribute from the server replaces it, including value="0" to clear it.

Examples

Bound to a signal

Score:
<div data-signals="{_score: 3}" style="display: grid; gap: 12px">
  <sb-rating label="How was the launch?" data-bind:_score__prop.value></sb-rating>
  <span>Score: <b data-text="$_score"></b></span>
</div>

Half steps and stars

<div style="display: grid; gap: 12px">
  <sb-rating icon="star" precision="0.5" value="2.5"></sb-rating>
  <sb-rating icon="star" precision="0.5" value="4" max="10" size="sm"></sb-rating>
</div>

Read only

For showing a score, e.g. an average: readonly keeps the value ("4.5 of 5") for screen readers, and takes it out of the tab order.

<sb-rating readonly value="4.5" precision="0.5" size="sm"></sb-rating>

Clearable

With clearable, picking the current value again resets it to 0. Only picking clears: an arrow key or End at the maximum leaves it there.

<sb-rating clearable value="2"></sb-rating>

With commands

Give it a name, and it emits sb-change with { name, value } when a value is picked: ready to post as a command. With confirm, it sets :state(pending) until the server's re-rendered attribute matches, and revert() goes back to the server's value when a command is rejected. See Commands and components.

Forms

Inside a <form>, sb-rating submits its value under its name (name=3.5, name=0 when unrated), new FormData(form) and Datastar's contentType: 'form' include it, and a form reset brings back the server's value. It is not a form-associated element yet (Rocket can't declare one), so required and validity, <fieldset disabled>, <label for> and the form attribute don't reach it. With commands, sb-change carries { name, value } (see With commands).

Styling

Style it from your page's CSS, without changing the component or importing anything into it. Custom properties, inherited properties and ::part() all reach into its shadow root.

  • Size: size (sm, md, lg) sets the icons to 1rem, 1.5rem or 2.25rem. For another size, set inline-size and block-size on ::part(unit).
  • Fonts: the label uses your page's font.
  • Colours: --sb-rating-color fills the icons (by default --sb-danger for hearts, --sb-warn for stars) and --sb-rating-shine is their highlight; empty icons are --sb-border-strong. The label is --sb-text-2, the focus ring --sb-brand-light.
  • Parts: label, base (the row of icons) and unit (each icon). Your page's ::part() rules win over the component's own, without !important.
<style>
  .my-rating { --sb-rating-color: var(--sb-brand-light); }
  .my-rating::part(unit) { inline-size: 2rem; block-size: 2rem; }
</style>
<sb-rating class="my-rating" icon="star" label="Crew comfort" value="4"></sb-rating>

Accessibility

The row is a slider (Arrow keys change the value by one step, Home clears it, End fills it), named by label or "Rating", with aria-valuetext like "3 of 5". Read-only ratings are a slider with aria-readonly, outside the tab order, with the same value and text.

Right to left (dir="rtl"), the row starts at the right: half units fill from the right, and Left and Right follow the row (Left raises the value), like a native range input.

Installation

Add Datastar with Rocket and the Starbase autoloader once per page, then use the tag: the autoloader imports each component the first time its tag appears, including tags a Datastar morph adds later. The Datastar here is v1.0.4 with Starbase's fixes until they are released; the components also run on the official release.

<script type="importmap">
  { "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" } }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/autoloader.js"></script>

<sb-rating value="3.5" precision="0.5" size="lg"></sb-rating>

No flash of undefined elements: put class="sb-cloak" on <html> and add .sb-cloak :not(:defined) { visibility: hidden } to your CSS. The autoloader removes the class once the page's components are defined.

Load just this component, pinned to this version: all its modules in one minified file with its integrity hash, so the browser refuses it if a single byte changes.

<script type="importmap">
  {
    "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
    "integrity": {
      "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5"
    }
  }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/rating@5d21008de7e4/rating.bundle.min.js" integrity="sha384-OnFkYYE97sZ/0v3qD9boNF0a3Vy3a7vBPzxWN9Hf8oAi1P4jJ2WapaLEdu4XtJWM"></script>

<sb-rating value="3.5" precision="0.5" size="lg"></sb-rating>

Or the minified module files (one file, which the browser requests as it finds their imports), each pinned in the import map:

<script type="importmap">
  {
    "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
    "integrity": {
      "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5",
      "https://starbase.zweiundeins.gmbh/c/rating@5d21008de7e4/rating.min.js": "sha384-Wx1uyR5QnhM8hnqGXyvI0fU6B0Nk2k0ou9khOyZvBp4XuoKEPkElRWrJzeftaa4s"
    }
  }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/rating@5d21008de7e4/rating.min.js" integrity="sha384-Wx1uyR5QnhM8hnqGXyvI0fU6B0Nk2k0ou9khOyZvBp4XuoKEPkElRWrJzeftaa4s"></script>

<sb-rating value="3.5" precision="0.5" size="lg"></sb-rating>

In production, pin today's whole catalog: its autoloader and an import map with integrity hashes, so the browser refuses any file that changed. This map covers Datastar and this component; importmap.json has the hashes of every component, to merge into yours.

<script type="importmap">
  {
    "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
    "integrity": {
      "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5",
      "https://starbase.zweiundeins.gmbh/c/rating@5d21008de7e4/rating.bundle.min.js": "sha384-OnFkYYE97sZ/0v3qD9boNF0a3Vy3a7vBPzxWN9Hf8oAi1P4jJ2WapaLEdu4XtJWM"
    }
  }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/@339249aca48f/autoloader.js" integrity="sha384-ZNpMayI1XShztIEX0iDHyCDDbhT8H+t+zEESUq4BCrN4b+7m931LQEl5x0vbY+Vo"></script>

<sb-rating value="3.5" precision="0.5" size="lg"></sb-rating>

Components import only datastar and their own files, so copying is enough: save each component's one file (and a file it loads on first use, keeping its folder), and point the import map at your own copy of datastar-rocket.js (the /js/ paths stand for yours).

  • <sb-rating>
    • rating.bundle.min.js one file 2.2 kB brotli
<script type="importmap">
  { "imports": { "datastar": "/js/datastar-rocket.js" } }
</script>
<script type="module" src="/js/rating/rating.bundle.min.js"></script>

<sb-rating value="3.5" precision="0.5" size="lg"></sb-rating>

Or copy the module files, keeping each component's folder, and load each component's module:

<script type="importmap">
  { "imports": { "datastar": "/js/datastar-rocket.js" } }
</script>
<script type="module" src="/js/rating/rating.min.js"></script>

<sb-rating value="3.5" precision="0.5" size="lg"></sb-rating>

Size

A page loads this component as one file, its bundle, compressed the way it is served (gzip -9, brotli -11): brotli is what it downloads. Datastar and Rocket are shared by every component and not counted.

FileOriginalgzipbrotli
rating.bundle.min.js 4.9 kB2.4 kB2.2 kB
As module files: one request, 2.1 kB minified with brotli

For a page that loads the module files as they are, requesting each one as it finds its import, or bundles them itself. Each file compressed on its own; the minified one is what the page downloads, and the readable source is always there too.

FileOriginalgzipbrotliminified
rating.js 11.6 kB4.8 kB4.1 kB2.1 kB

API reference

Props

AttributeTypeDefaultDescription
valuenumber0The value. A new value from the server replaces it; the live value is the value property.
maxnumber5Number of hearts (or stars).
precision"1" | "0.5""1"Step: whole or half units.
icon"heart" | "star""heart"Pixel sprite.
size"sm" | "md" | "lg""md"Size.
labelstring""Visible label (also the accessible name).
readonlybooleanfalseShow the value; no interaction.
clearablebooleanfalsePicking the current value again clears it to 0.
disabledbooleanfalseDisable interaction.
confirmbooleanfalseServer-confirmed value: :state(pending) while the local value differs from the server's value attribute (see revert()).
namestring""Name reported in sb-change (e.g. the field of a command) and submitted with a form.

Events

NameDescription
changeWhen the value is committed.
sb-changeSame moment. detail: { name, value }: ready for a command.