stars to rate with, by halves, or the average of many beside its count
npx shadcn add @ag/ratingAlready installed? Preview an update
npx shadcn add @ag/rating --dry-runYour installed source stays unchanged until you update it. How updates work
- Fixed: a value that is no number (an average of no ratings, 0/0) or a
countthat is no whole number from 0 throws instead of drawing a full line. Inside aSettingat the root of its chain (nothing to inherit), Delete, 0, Home and a press on the value clear it to no rating, as anywhere else; they did nothing. - Fixed: a rating inside a hidden tab panel (kept mounted) draws its fractions right once the panel shows; it could collapse to too few stars.
- New: right to left. Under
dir="rtl"(on it or anywhere above) the line fills from its right, a half is a star's right half to the hand, and ArrowLeft gives more, ArrowRight less; Up and Down are unchanged. An average's fraction is cut from its right end too. Nothing changes left to right.
also called star rating, stars, review score
shelf ratings & feedback
uses field-context, key-router, material, setting-context
used by components
the demo above, verbatim
"use client"
import { Heart } from "lucide-react"
import * as React from "react"
import { Sample } from "@/app/samples"
import { Button } from "@/registry/base-nova/ui/button"
import { Field } from "@/registry/base-nova/ui/field"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { Rating } from "@/registry/base-nova/ui/rating"
import { Setting } from "@/registry/base-nova/ui/setting"
import { Switch } from "@/registry/base-nova/ui/switch"
// #region halves
/** Halves: the hand previews the half it is on, a press sets it, a press on the
* value it already has clears it. */
function Halves() {
const [stars, setStars] = React.useState(3.5)
return (
<div className="flex items-center gap-3">
<Rating
aria-label="the film"
step={0.5}
value={stars}
onValueChange={setStars}
/>
<span className="w-6 font-mono text-[12px] text-current/60 tabular-nums">
{stars || "none"}
</span>
</div>
)
}
// #endregion
// #region review
/** Your rating and everyone's, as a page shows them together. */
function Review() {
const [stars, setStars] = React.useState(4)
return (
<div className="flex flex-col items-start gap-3">
<Rating
aria-label="your rating"
step={0.5}
value={stars}
onValueChange={setStars}
/>
<Rating value={4.3} count={120} aria-label="everyone" />
</div>
)
}
// #endregion
// #region stay
/** In a field and a form: named by the label, wrong while it has no star, held
* still while the form sends; `name` posts it with the form. */
function Stay() {
const [stars, setStars] = React.useState(0)
const [held, setHeld] = React.useState(false)
const [posted, setPosted] = React.useState<string | null>(null)
return (
<form
className="flex flex-col items-start gap-4"
onSubmit={(e) => {
e.preventDefault()
const sent = new FormData(e.currentTarget).get("stars")
setPosted(JSON.stringify(sent))
}}
>
<Field
label="how was the stay"
description="half stars allowed"
readOnly={held}
error={stars === 0 ? "a star at least" : undefined}
>
<Rating
name="stars"
step={0.5}
value={stars}
onValueChange={setStars}
/>
</Field>
<Switch checked={held} onCheckedChange={setHeld}>
read-only (a form sending)
</Switch>
<div className="flex items-center gap-3">
<Button type="submit" size="sm">
send
</Button>
{posted !== null && (
<span className="font-mono text-[12px] text-current/60">
stars = {posted}
</span>
)}
</div>
</form>
)
}
// #endregion
// #region inherited
/** The lowest rating a feed shows, falling back to the workspace's: a ghost of
* what applies until one is set here. */
function Lowest() {
const [lowest, setLowest] = React.useState<number | undefined>(undefined)
return (
<Setting
label="lowest rating shown"
value={lowest}
fallback={{ value: 3, from: "the workspace" }}
onValueChange={setLowest}
format={(v) => `${v} stars`}
>
{(s) => (
<Rating
aria-label="lowest rating shown"
step={0.5}
value={s.value}
onValueChange={s.pin}
/>
)}
</Setting>
)
}
// #endregion
export default function RatingDemo() {
const [hearts, setHearts] = React.useState(3)
const [heat, setHeat] = React.useState(2)
const [effort, setEffort] = React.useState(6)
return (
<div className="space-y-8">
<Sample name="halves" glance title="half stars" with="halves">
<Halves />
</Sample>
<Sample
name="materials"
title="plain and glass"
description="yours, set by the hand or the keys (arrows by the step, Home, End, a digit, 0 or Delete to clear), and an average of many beside its count."
with="review"
>
<div className="grid gap-4 sm:grid-cols-2">
<div className="p-6">
<Review />
</div>
<MaterialProvider material="glass">
<div className="rounded-xl bg-[url(/lightbox/river.jpg)] bg-cover bg-center p-6 text-white">
<Review />
</div>
</MaterialProvider>
</div>
</Sample>
<Sample
name="average"
title="an average of many"
description="given a count it is a readout: one image to a screen reader (4.3 of 5 stars, 120 ratings), never a Tab stop, the fraction filled."
>
<div className="flex flex-col items-start gap-2">
<Rating value={4.3} count={120} />
<Rating value={3.5} count={8} tone="var(--tone-warn)" />
<Rating value={4.82} count={2431} tone="var(--tone-warn)" />
<Rating value={0} count={0} />
</div>
</Sample>
<Sample name="field" title="in a field and a form" with="stay">
<Stay />
</Sample>
<Sample name="inherited" title="following a default" with="inherited">
<div className="grid gap-4 sm:grid-cols-2">
<div className="p-6">
<Lowest />
</div>
<MaterialProvider material="glass" scheme="dark">
<div className="rounded-xl bg-[url(/lightbox/shore.jpg)] bg-cover bg-center p-6">
<Lowest />
</div>
</MaterialProvider>
</div>
</Sample>
<Sample
name="glyphs"
title="any glyph, any count"
description="an svg is filled in the tone; a character is greyed where it is empty."
>
<div className="flex flex-col items-start gap-2">
<Rating
aria-label="how much you like it"
glyph={<Heart />}
tone="var(--tone-bad)"
format={(v, max) => (v ? `${v} of ${max} hearts` : "no hearts")}
value={hearts}
onValueChange={setHearts}
/>
<Rating
aria-label="heat"
glyph="🌶️"
max={3}
format={(v, max) => `${v} of ${max} chillies`}
value={heat}
onValueChange={setHeat}
/>
<Rating
aria-label="effort"
size="sm"
max={10}
format={(v, max) => `${v} of ${max}`}
value={effort}
onValueChange={setEffort}
/>
</div>
</Sample>
<Sample name="sizes" title="the input's heights">
<div className="flex flex-col items-start gap-2">
{(["sm", "md", "lg"] as const).map((size) => (
<Rating
key={size}
size={size}
aria-label={`${size} rating`}
defaultValue={3}
/>
))}
</div>
</Sample>
<Sample
name="right-to-left"
title="right to left"
description="under dir=rtl the line fills from its right, a half is a star's right half, and the arrows follow the line: left gives more."
>
<div dir="rtl" className="flex flex-col items-start gap-2">
<Halves />
<Rating value={4.3} count={120} aria-label="everyone" />
</div>
</Sample>
<Sample
name="read-only"
title="read-only"
description="a person's rating held still: kept in the Tab order and read out (3.5 of 5 stars, read-only), the hand and the keys change nothing. never dimmed, which is disabled."
>
<div className="flex flex-col items-start gap-2">
<Rating aria-label="your rating" value={3.5} readOnly />
<Rating aria-label="your rating" value={2} disabled />
</div>
</Sample>
</div>
)
}the source, every file an install writes
"use client"
import { Star } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import { useField } from "@/registry/base-nova/ui/field-context"
import { ownedHere } from "@/registry/base-nova/ui/key-router"
import {
type Material,
type Scheme,
useMaterial,
useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import { glassVariants, schemeOf } from "@/registry/base-nova/ui/material-glass"
import {
GLASS_GHOST,
useSetting,
} from "@/registry/base-nova/ui/setting-context"
// A JUDGEMENT ON A LINE OF STARS (or any glyph handed in): a person's own rating, or
// the average of many. The value is a number from 0 (no rating) to `max`, drawn as
// that much of the line filled, so a 3.5 is three stars and a half and an average of
// 4.3 fills 4.3.
//
// own a slider (`role="slider"`, "3.5 of 5 stars"): one Tab stop, the arrows
// by `step` (1, or 0.5 for halves), PageUp and PageDown to the next whole
// star, Home (0) and End, a digit to its star, 0, Delete or Backspace to
// clear.
// The hand previews before it sets: the line fills to where the pointer
// is, a half when it is on a star's left half (step 0.5); a press sets it,
// a drag along the line sets where it lets go, and a press on the value
// it already has clears it. `name` submits it with a form ("" when none).
// Right to left (`dir="rtl"` on it or above) the line fills from its
// right, halves are a glyph's right half, and the arrows follow the line.
// average given `count` (how many rated), a readout: one image to a screen
// reader ("4.3 of 5 stars, 120 ratings"), never focused, the number and
// the count beside the line.
//
// Plain: bare glyphs, the empty ones the text's colour at a fifth, the filled ones
// `tone` (the text's colour unless given). `material="glass"`: the line on a pill of
// glass at the input's heights. `readOnly` holds it still (focus and reading kept,
// never dimmed). Inside a field it is named and described by it, red when invalid,
// held still while the field is read-only. Inside a `Setting` whose value is
// inherited, the filled part is a ghost, hollow; pressing the value that applies sets
// it here, and clearing goes back to what it inherits.
export type RatingSize = "sm" | "md" | "lg"
export type RatingStep = 0.5 | 1
/** The line at the input's heights, so it sits in a form's row as a field does. */
const ROW: Record<RatingSize, string> = {
sm: "h-7 text-[12px]",
md: "h-8 text-[13px]",
lg: "h-10 text-sm",
}
/** One glyph's box; an svg fills it, a character is set at its size. */
const CELL: Record<RatingSize, string> = {
sm: "size-4 text-[14px]",
md: "size-5 text-[17px]",
lg: "size-6 text-[21px]",
}
/** The glass pill: its radius half of `ROW`'s height. */
const GLASS_PILL: Record<RatingSize, string> = {
sm: "px-2 [--glass-radius:14px]",
md: "px-2.5 [--glass-radius:16px]",
lg: "px-3 [--glass-radius:20px]",
}
/** Every glyph, both layers: an svg fills its box, drawn solid in its colour. */
const GLYPH =
"col-start-1 row-start-1 grid place-items-center leading-none [&_svg]:size-full [&_svg]:fill-current [&_svg]:stroke-current [&_svg]:stroke-[1.5]"
/** The line under the value: the text's colour at a fifth (a character, a picture
* of its own colours, greyed). Red, faintly, when the field says it is wrong. */
const EMPTY = "text-current opacity-20 [&:not(:has(svg))]:grayscale"
const EMPTY_INVALID = "text-tone-bad opacity-45"
/** What the value fills, cut at its fraction (`--rating-clip`, worked out per glyph). */
const FULL = "text-(--rating-tone,currentColor) [clip-path:var(--rating-clip)]"
/** Inherited (inside a `Setting`, nothing set here): the value that applies drawn
* hollow, its outline in the tone (a character at a little over half). */
const FULL_GHOST = "[&_svg]:fill-transparent [&:not(:has(svg))]:opacity-55"
export interface RatingProps {
/** 0 (no rating) to `max`; an average may be any fraction. Controlled. */
value?: number
defaultValue?: number
onValueChange?: (value: number) => void
/** How many glyphs; 5 by default. */
max?: number
/** What a press, the hand and the arrows move by: 1 (whole stars), or 0.5. */
step?: RatingStep
/** The glyph, drawn empty and filled: a star unless given (an svg is filled in its
* colour; a character is greyed where empty). */
glyph?: React.ReactNode
/** The filled glyphs' colour; the text's colour otherwise. */
tone?: string
/** How many rated: makes it the average of their ratings, a readout. */
count?: number
/** The value as words, for the reader. Default "3.5 of 5 stars", "no rating". */
format?: (value: number, max: number) => string
/** Submitted with a form under this name: the value, or "" when there is none. */
name?: string
size?: RatingSize
/** Shown, never changed here: the hand and the keys change nothing, the Tab stop
* and the reading stay. Never dimmed (that is `disabled`). A field's read-only
* and a locked setting's hold it the same way. */
readOnly?: boolean
disabled?: boolean
material?: Material
/** The colour scheme it stands in (material): default the nearest provider's. */
scheme?: Scheme
/** What is rated, for assistive tech. */
"aria-label"?: string
/** Or the label that names it (a field's is used unless this or a label is given). */
"aria-labelledby"?: string
/** What else says something about it; a field's and a setting's words join it. */
"aria-describedby"?: string
className?: string
ref?: React.Ref<HTMLDivElement>
}
/** A value as a person reads it: one decimal at most. */
const tenths = (v: number) => String(Math.round(v * 10) / 10)
const starWords = (value: number, max: number) =>
value === 0 ? "no rating" : `${tenths(value)} of ${max} stars`
/** Where a glyph's ink lies across its box, as shares of its width. */
interface Ink {
from: number
to: number
}
/** A character's: its whole box. */
const WHOLE: Ink = { from: 0, to: 1 }
/** An svg's ink, read off the drawing itself: its shapes' extent in its viewBox,
* widened by half its stroke. */
function inkOf(svg: SVGSVGElement): Ink {
const view = svg.viewBox.baseVal
if (!view || view.width === 0) return WHOLE
const shapes = svg.getBBox()
const half = (Number.parseFloat(getComputedStyle(svg).strokeWidth) || 0) / 2
return {
from: Math.max(0, (shapes.x - half - view.x) / view.width),
to: Math.min(1, (shapes.x + shapes.width + half - view.x) / view.width),
}
}
/** The clip that shows `fill` (0 to 1) of a glyph's ink from the line's start, its
* left, or its right when the line runs right to left: a fraction is a share of what
* is drawn, not of the box it is drawn in (lucide's star leaves a twelfth of its box
* empty each side, and 4.3 cut on the box showed a sliver of the fifth star's arm
* where three tenths of a star belonged). */
const clipOf = (fill: number, ink: Ink, rtl: boolean) => {
if (fill >= 1) return "none"
if (fill <= 0) return "inset(0 100% 0 0)"
const reach = fill * (ink.to - ink.from)
return rtl
? `inset(0 0 0 ${(ink.to - reach) * 100}%)`
: `inset(0 ${(1 - (ink.from + reach)) * 100}% 0 0)`
}
/** The line runs right to left: the page's `dir`, or any ancestor's, as CSS resolved
* it on the element. */
const rightToLeft = (el: Element) => getComputedStyle(el).direction === "rtl"
export function Rating({
value: given,
defaultValue = 0,
onValueChange,
max = 5,
step = 1,
glyph = <Star />,
tone,
count,
format = starWords,
name,
size = "md",
readOnly,
disabled,
material,
scheme,
"aria-label": label,
"aria-labelledby": labelledBy,
"aria-describedby": describedBy,
className,
ref,
}: RatingProps) {
if (!Number.isInteger(max) || max < 1)
throw new Error(`Rating: max is a whole number of glyphs, got ${max}`)
if (step !== 1 && step !== 0.5)
throw new Error(`Rating: step is 1 or 0.5, got ${step}`)
const average = count !== undefined
if (average && onValueChange)
throw new Error(
"Rating: with a count it is an average of many ratings, a readout; a person's own rating takes no count",
)
const m = useMaterial(material)
// Over a picture it is read by the part of it under the line.
const [s, here] = useSurfaceScheme<HTMLDivElement>(scheme, ref)
const field = useField()
const setting = useSetting()
const ghost = setting?.inherited ?? false
const held =
(readOnly ?? false) ||
(field?.readOnly ?? false) ||
(setting?.readOnly ?? false)
const off = disabled ?? field?.wiring.disabled ?? false
const live = !average && !held && !off
const [own, setOwn] = React.useState(defaultValue)
const value = given ?? own
if (!(value >= 0 && value <= max))
throw new Error(`Rating: value ${value} is outside 0 to ${max}`)
if (average && !(Number.isInteger(count) && count >= 0))
throw new Error(`Rating: count is how many rated, got ${count}`)
// What the hand would set, drawn until it leaves or sets it. `quiet` is the value a
// press just set (or cleared from): the hand resting where it pressed previews
// nothing until it moves to another, so a clear shows the empty line at once.
const [preview, setPreview] = React.useState<number | null>(null)
const quiet = React.useRef<number | null>(null)
const press = React.useRef<{ at: number; moved: boolean } | null>(null)
const set = (next: number) => {
if (!live) return
if (given === undefined) setOwn(next)
onValueChange?.(next)
}
// No rating. Inside a setting with something to inherit: back to it when one is set
// here (as an emptied number goes back), nothing while it is inherited; anywhere
// else (a setting at the root of its chain included), 0.
const clear = () => {
if (!live) return
if (setting?.fallback) return setting.clear?.()
set(0)
}
/** The value under the pointer: the glyph it is over, a half on its first half
* (its left, or its right right to left) when halves are allowed; before the first
* glyph the lowest step, past the last the whole line. */
const under = (root: HTMLElement, x: number): number => {
const rtl = rightToLeft(root)
const cells = root.querySelectorAll<HTMLElement>("[data-slot=rating-item]")
let i = 0
while (i < cells.length - 1) {
const box = (cells[i] as HTMLElement).getBoundingClientRect()
if (rtl ? x > box.left : x < box.right) break
i++
}
const box = (cells[i] as HTMLElement).getBoundingClientRect()
const into = (rtl ? box.right - x : x - box.left) / box.width
return step === 0.5 && into <= 0.5 ? i + 0.5 : i + 1
}
const hover = (at: number) => {
if (quiet.current === at) return
quiet.current = null
setPreview(at)
}
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (!live || ownedHere(e.nativeEvent, e.currentTarget)) return
const up = Math.floor(value / step + 1e-9) * step + step
const down = Math.ceil(value / step - 1e-9) * step - step
const more = () => set(Math.min(max, up))
const less = () => (down <= 0 ? clear() : set(down))
// Right to left the line grows leftwards, and the arrows follow the line.
const rtl = rightToLeft(e.currentTarget)
const keyed: Record<string, () => void> = {
ArrowRight: rtl ? less : more,
ArrowUp: more,
ArrowLeft: rtl ? more : less,
ArrowDown: less,
PageUp: () => set(Math.min(max, Math.floor(value + 1e-9) + 1)),
PageDown: () => {
const next = Math.ceil(value - 1e-9) - 1
return next <= 0 ? clear() : set(next)
},
Home: clear,
End: () => set(max),
Delete: clear,
Backspace: clear,
}
const digit = /^[0-9]$/.test(e.key) ? Number(e.key) : null
const act =
keyed[e.key] ??
(digit === 0
? clear
: digit !== null && digit <= max
? () => set(digit)
: undefined)
if (!act || e.metaKey || e.ctrlKey || e.altKey) return
e.preventDefault()
// The keys speak now: a preview the hand left standing gives way to the value
// until the hand moves on.
quiet.current = preview
setPreview(null)
act()
}
// The glyph's ink, measured once it is drawn: what a fraction is a share of; and
// which way the line runs, which end a fraction fills from. Read after every render
// (a new glyph) and whenever the line is laid out anew: one mounted inside a hidden
// tab panel has no box to read until the panel shows, and its ink stays what it was
// until then, never a zero-width ink that draws no star.
const drawn = React.useRef<HTMLSpanElement>(null)
const [ink, setInk] = React.useState<Ink>(WHOLE)
const [rtl, setRtl] = React.useState(false)
const measure = React.useCallback(() => {
const el = drawn.current
if (!el) throw new Error("rating drew no line")
const svg = el.querySelector("svg")
if (!svg) setInk(WHOLE)
else if (svg.getBBox().width > 0) {
const next = inkOf(svg)
setInk((was) =>
was.from === next.from && was.to === next.to ? was : next,
)
}
setRtl(rightToLeft(el))
}, [])
React.useLayoutEffect(measure)
React.useLayoutEffect(() => {
const el = drawn.current
if (!el) throw new Error("rating drew no line")
const laid = new ResizeObserver(measure)
laid.observe(el)
return () => laid.disconnect()
}, [measure])
const shown = preview ?? value
const words = format(value, max)
const described =
[describedBy ?? field?.wiring["aria-describedby"], setting?.says]
.filter(Boolean)
.join(" ") || undefined
const invalid = field?.invalid ?? false
const glass = m === "glass"
const line = Array.from({ length: max }, (_, i) => {
const fill = Math.min(1, Math.max(0, shown - i))
return (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: a glyph is its place on the line
key={i}
data-slot="rating-item"
data-fill={fill === 1 ? "full" : fill === 0 ? "empty" : "part"}
className="grid px-px"
>
<span aria-hidden className={cn("grid", CELL[size])}>
<span className={cn(GLYPH, invalid ? EMPTY_INVALID : EMPTY)}>
{glyph}
</span>
<span
className={cn(
GLYPH,
FULL,
ghost && FULL_GHOST,
"transition-opacity duration-150 group-data-preview/rating:opacity-75 motion-reduce:transition-none",
)}
style={
{ "--rating-clip": clipOf(fill, ink, rtl) } as React.CSSProperties
}
>
{glyph}
</span>
</span>
</span>
)
})
const face = cn(
"group/rating inline-flex w-fit shrink-0 items-center gap-2 select-none",
ROW[size],
schemeOf(s),
glass
? [
glassVariants({ shape: "surface" }),
GLASS_PILL[size],
live && "hover:[--glass-rim:1.4]!",
invalid &&
"[--glass-stain:var(--tone-bad)]! [--glass-rim-color:var(--tone-bad)]! [--glass-rim:2.2]!",
ghost && GLASS_GHOST,
]
: "rounded-md",
off && "cursor-not-allowed opacity-50",
live ? "cursor-pointer touch-pan-y" : "cursor-default",
className,
)
const style = tone
? ({ "--rating-tone": tone } as React.CSSProperties)
: undefined
if (average) {
const said =
count === 0
? "no ratings yet"
: `${words}, ${count} rating${count === 1 ? "" : "s"}`
return (
<div
ref={here}
role="img"
aria-label={label ? `${label}: ${said}` : said}
data-slot="rating"
data-size={size}
data-material={m}
data-average=""
className={face}
style={style}
>
<span ref={drawn} className="flex">
{line}
</span>
<span
data-slot="rating-count"
className="flex items-baseline gap-1 tabular-nums"
>
{count > 0 && (
<span className="font-medium text-current/85">{tenths(value)}</span>
)}
<span className="text-current/50">
({count.toLocaleString("en-US")})
</span>
</span>
</div>
)
}
return (
<div
ref={here}
role="slider"
id={field?.wiring.id}
tabIndex={off ? -1 : 0}
aria-label={label}
aria-labelledby={labelledBy ?? (label ? undefined : field?.labelId)}
aria-describedby={described}
aria-invalid={invalid || undefined}
aria-readonly={held || undefined}
aria-disabled={off || undefined}
aria-orientation="horizontal"
aria-valuemin={0}
aria-valuemax={max}
aria-valuenow={value}
aria-valuetext={words}
data-slot="rating"
data-size={size}
data-material={m}
data-inherited={ghost ? "" : undefined}
data-readonly={held ? "" : undefined}
data-disabled={off ? "" : undefined}
data-preview={preview !== null ? "" : undefined}
className={face}
style={style}
onKeyDown={onKeyDown}
onPointerMove={(e) => {
if (!live) return
if (e.pointerType === "mouse" && !press.current)
return hover(under(e.currentTarget, e.clientX))
if (!press.current) return
const at = under(e.currentTarget, e.clientX)
if (at !== press.current.at) press.current.moved = true
setPreview(at)
}}
onPointerLeave={() => {
if (press.current) return
quiet.current = null
setPreview(null)
}}
onPointerDown={(e) => {
if (!live || e.button !== 0) return
e.currentTarget.setPointerCapture(e.pointerId)
const at = under(e.currentTarget, e.clientX)
press.current = { at, moved: false }
quiet.current = null
setPreview(at)
}}
onPointerUp={(e) => {
const p = press.current
if (!p) return
press.current = null
const at = under(e.currentTarget, e.clientX)
// A press on the value it already has clears it; inherited, that press says
// "this one, set here". A drag sets where it lets go.
if (!p.moved && at === value && !ghost) clear()
else set(at)
quiet.current = at
setPreview(null)
}}
onPointerCancel={() => {
press.current = null
setPreview(null)
}}
>
<span ref={drawn} className="flex">
{line}
</span>
{name && (
<input
type="hidden"
name={name}
value={value === 0 ? "" : String(value)}
/>
)}
</div>
)
}