defuss-shadcn / Forms & Inputs / rating
RatingMOL
A score out of five. The interactive form is a native radio group - one transparent radio per star - so keyboard navigation, one-of-many exclusivity and form submission are the browser's, and :has() fills every star up to the checked (or hovered) one. The read-only form clips a filled layer of stars to the value, so 3.5 draws a half star with no extra markup. CSS only.
On this page (10)
§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