Theme
On this page (10)
Component Skill — components/rating/component-skill.md

Native basis

A <fieldset class="rating"> of <label class="rating-item">s, each with a transparent <input type="radio">; read-only: a <div class="rating" data-readonly role="img"> with --rating-value.

Web Platform APIs

<input type="radio"><fieldset disabled>:has()@media (hover)

Classes

.rating.rating-item.rating-input.rating-value

Data attributes

data-readonly, data-size (sm, lg), data-glyph="heart"; custom properties --rating-value, --rating-color, --rating-empty, --rating-size, --rating-glyph / --rating-glyphs.

§Rating

A native radio group: one transparent radio per star, so a click, the arrow keys and form submission are the browser's. :has() fills every star up to the checked one - and up to the hovered one while you point. No JavaScript.

§Sizes

data-size sm · (default) · lg - or any size through --rating-size.

§Read-only

data-readonly with --rating-value: two layers of five stars, the filled one clipped to the value - fractions need no extra markup. role='img' with an aria-label says the score once; a .rating-value beside it shows it.

§Disabled

disabled on the <fieldset> propagates to every radio natively: dimmed, no hover preview, not focusable.

§Colors

--rating-color per instance - amber has no theme token, so the fill is a component-local knob: the primary colour, the destructive one, anything.

§Hearts and other glyphs

data-glyph='heart' draws hearts in the destructive colour, for both forms; --rating-glyph (and --rating-glyphs for read-only) takes any text glyph.

§In a form

The value is the radio group's: FormData / form.elements.score.value - nothing to sync. Submit to see it.

§In a card

A small read-only rating with its count - .card needs width:100% beside max-width inside a flex preview (it is a size container).

§Right to left

In dir='rtl' the stars run from the right - the interactive group and the read-only clip both follow the inline direction.

§CSS view file

/* -- Rating component ------------------------------------------- */
/* Star rating. Interactive form is a native radio group (no JS);   */
/* the read-only form is a display-only fraction of five stars.     */
@layer components {
  .rating {
    /* Local knobs — not theme tokens: there is no amber in the tweakcn
       shape, and a rating's fill colour is a per-instance decision
       (stars amber, hearts destructive, …). Override inline or in a
       wrapper rule; both forms below read them. */
    --rating-color: #f59e0b;
    --rating-empty: var(--border);
    --rating-size: 1.25rem;
    border: none;
    padding: 0;
    margin: 0;
    display: inline-flex;
    align-items: center;
    gap: 0.125rem;
    line-height: 1;
    &[data-size="sm"] {
      --rating-size: 1rem;
    }
    &[data-size="lg"] {
      --rating-size: 1.75rem;
    }
    /* -- Glyph: any text glyph via --rating-glyph (the read-only form reads
       --rating-glyphs, the same glyph five times); data-glyph="heart" is
       the common one, in the destructive colour. U+FE0E keeps ♥ a text
       glyph (an emoji heart would ignore color). */
    &[data-glyph="heart"] {
      --rating-glyph: "\2665\FE0E";
      --rating-glyphs: "\2665\FE0E\2665\FE0E\2665\FE0E\2665\FE0E\2665\FE0E";
      --rating-color: var(--destructive);
    }
  }
  /* -- Interactive form (radio group) ------------------------- */
  .rating-item {
    position: relative;
    display: inline-grid;
    place-items: center;
    cursor: pointer;
    border-radius: var(--radius-sm);
    line-height: 1;
    &::before {
      content: var(--rating-glyph, "★");
      font-size: var(--rating-size);
      color: var(--rating-empty);
      transition: color 150ms, scale 150ms;
    }
  }
  /* The radio stays in the accessibility tree and keeps keyboard support —
     it is only visually replaced by the star drawn above. */
  .rating-input {
    position: absolute;
    inset: 0;
    margin: 0;
    opacity: 0;
    cursor: pointer;
  }
  /* Filled: this star, and every star before the checked one. */
  .rating-item:has(.rating-input:checked)::before,
  .rating-item:has(~ .rating-item .rating-input:checked)::before {
    color: var(--rating-color);
  }
  /* Hover preview wins over the current value while the pointer is on the
     group: reset all, then fill up to the hovered star. Specificity is
     deliberate — each rule must outrank the checked rules above.
     A disabled group is excluded: previewing a value there would promise a
     choice the control cannot accept. */
  @media (hover: hover) {
    .rating:not(:has(.rating-input:disabled)):has(.rating-item:hover) .rating-item::before {
      color: var(--rating-empty);
    }
    .rating:not(:has(.rating-input:disabled)):has(.rating-item:hover) .rating-item:hover::before,
    .rating:not(:has(.rating-input:disabled)):has(.rating-item:hover) .rating-item:has(~ .rating-item:hover)::before {
      color: var(--rating-color);
      scale: 1.1;
    }
  }
  .rating-item:has(.rating-input:focus-visible) {
    outline: 2px solid var(--ring);
    outline-offset: 2px;
  }
  /* Disabled — native <fieldset disabled> propagates to every radio. */
  .rating:has(.rating-input:disabled) {
    opacity: 0.5;
    /* the star and the radio stretched over it must agree — a hand on one and
       "no entry" on the other invites a click the control cannot take */
    & .rating-item,
    & .rating-input {
      cursor: not-allowed;
    }
  }
  .rating[data-readonly] .rating-item {
    cursor: default;
  }
  /* -- Read-only form (fractional display) -------------------- */
  /* Two layers of five stars: empty underneath, filled on top clipped to
     the value — so 3.5 renders a half star with no extra markup. */
  .rating[data-readonly] {
    position: relative;
    display: inline-block;
    font-size: var(--rating-size);
    letter-spacing: 0.125rem;
    &::before,
    &::after {
      content: var(--rating-glyphs, "★★★★★");
      line-height: 1;
    }
    &::before {
      color: var(--rating-empty);
    }
    &::after {
      position: absolute;
      inset-block: 0;
      inset-inline-start: 0;
      width: calc(var(--rating-value, 0) / 5 * 100%);
      overflow: hidden;
      white-space: nowrap;
      color: var(--rating-color);
    }
  }
  /* -- Label beside the stars --------------------------------- */
  .rating-value {
    font-size: 0.875rem;
    color: var(--muted-foreground);
    margin-inline-start: 0.5rem;
  }
}
@media (prefers-reduced-motion: reduce) {
  @layer components {
    .rating,
    .rating *,
    .rating::before,
    .rating::after,
    .rating *::before,
    .rating *::after,
    .rating-item,
    .rating-item *,
    .rating-item::before,
    .rating-item::after {
      transition: none;
      animation: none;
      scale: none;
    }
  }
}
@media (prefers-contrast: more) {
  @layer components {
    .rating {
      --rating-empty: var(--muted-foreground);
    }
    .rating-item:has(.rating-input:focus-visible) {
      outline-width: 3px;
    }
  }
}
@media (forced-colors: active) {
  @layer components {
    .rating {
      --rating-color: Highlight;
      --rating-empty: GrayText;
      &:has(.rating-input:disabled) {
        --rating-color: GrayText;
      }
    }
    .rating-item:has(.rating-input:focus-visible) {
      outline-color: Highlight;
    }
  }
}

Comments, ideas or improvements? Edit this page's source on GitHub