ui
ui copied to clipboard
Rating Component Addition
A rating component would be a great addition to the library. Apart from MUI rating component, there are not many good rating components available.
@shadcn I would like to work on one. Is there a place where we can coordinate work on this and other components?
@yanisneverlies Are you already working on it? Otherwise I can spend some spare time developing it
Hey, How to create component and make it work with form? For example this rating component. Something like this:
<FormField
control={form.control}
name="rating"
render={({ field }) => (
<FormItem>
<FormLabel>Rate the product</FormLabel>
<FormControl>
<Rating {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
@iamshubhamjangle you can create a Rating
component like this:
import { FieldPath, useFormContext } from 'react-hook-form'
import { FormType } from '.'
import { forwardRef } from 'react'
type RatingProps = React.InputHTMLAttributes<HTMLInputElement>
export const Rating = forwardRef<HTMLInputElement, RatingProps>(
(props, ref) => {
const name = props.name as FieldPath<FormType>
const { register, setValue, getValues } = useFormContext<FormType>()
return (
<div className="flex">
<input {...props} className="hidden" {...register(name)} ref={ref} />
{Array(5)
.fill(0)
.map((_, i) => (
<svg
key={i}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={`size-5 ${i < +getValues(name) ? 'fill-primary' : 'fill-primary-foreground'}`}
onClick={() =>
setValue(name, i === +getValues(name) - 1 ? 0 : i + 1)
}
>
<circle cx="12" cy="12" r="10" />
</svg>
))}
</div>
)
},
)
Rating.displayName = 'Rating'
Here's Ratings component. Currenty working on the onHover,onChange ..
import React from "react"
import { Star } from "lucide-react"
import { cn } from "@/lib/utils"
const ratingVariants = {
default: {
star: "text-foreground",
emptyStar: "text-muted-foreground",
},
destructive: {
star: "text-red-500",
emptyStar: "text-red-200",
},
yellow: {
star: "text-yellow-500",
emptyStar: "text-yellow-200",
},
}
interface RatingsProps extends React.HTMLAttributes<HTMLDivElement> {
rating: number
totalStars?: number
size?: number
fill?: boolean
Icon?: React.ReactElement
variant?: keyof typeof ratingVariants
}
const Ratings = ({ ...props }: RatingsProps) => {
const {
rating,
totalStars = 5,
size = 20,
fill = true,
Icon = <Star />,
variant = "default",
} = props
const fullStars = Math.floor(rating)
const partialStar =
rating % 1 > 0 ? (
<PartialStar
fillPercentage={rating % 1}
size={size}
className={cn(ratingVariants[variant].star)}
Icon={Icon}
/>
) : null
return (
<div className={cn("flex items-center gap-2")} {...props}>
{[...Array(fullStars)].map((_, i) =>
React.cloneElement(Icon, {
key: i,
size,
className: cn(
fill ? "fill-current" : "fill-transparent",
ratingVariants[variant].star
),
})
)}
{partialStar}
{[...Array(totalStars - fullStars - (partialStar ? 1 : 0))].map((_, i) =>
React.cloneElement(Icon, {
key: i + fullStars + 1,
size,
className: cn(ratingVariants[variant].emptyStar),
})
)}
</div>
)
}
interface PartialStarProps {
fillPercentage: number
size: number
className?: string
Icon: React.ReactElement
}
const PartialStar = ({ ...props }: PartialStarProps) => {
const { fillPercentage, size, className, Icon } = props
return (
<div style={{ position: "relative", display: "inline-block" }}>
{React.cloneElement(Icon, {
size,
className: cn("fill-transparent", className),
})}
<div
style={{
position: "absolute",
top: 0,
overflow: "hidden",
width: `${fillPercentage * 100}%`,
}}
>
{React.cloneElement(Icon, {
size,
className: cn("fill-current", className),
})}
</div>
</div>
)
}
export { Ratings }
And you can implement it as such :
<Ratings rating={1.5} />
<Ratings rating={2} variant="destructive" Icon={<Heart/>} />
<Ratings rating={2.5} variant="yellow" totalStars={8} />
Output :
import React, { useState } from "react"
import { Star } from "lucide-react"
import { cn } from "@/lib/utils"
const ratingVariants = {
default: {
star: "text-foreground",
emptyStar: "text-muted-foreground",
},
destructive: {
star: "text-red-500",
emptyStar: "text-red-200",
},
yellow: {
star: "text-yellow-500",
emptyStar: "text-yellow-200",
},
}
interface RatingsProps extends React.HTMLAttributes<HTMLDivElement> {
rating: number
totalStars?: number
size?: number
fill?: boolean
Icon?: React.ReactElement
variant?: keyof typeof ratingVariants
onRatingChange?: (rating: number) => void
}
export const CommentRatings = ({
rating: initialRating,
totalStars = 5,
size = 20,
fill = true,
Icon = <Star />,
variant = "default",
onRatingChange,
...props
}: RatingsProps) => {
const [hoverRating, setHoverRating] = useState<number | null>(null)
const [currentRating, setCurrentRating] = useState(initialRating)
const [isHovering, setIsHovering] = useState(false)
const handleMouseEnter = (event: React.MouseEvent<HTMLDivElement>) => {
setIsHovering(true)
const starIndex = parseInt(
(event.currentTarget as HTMLDivElement).dataset.starIndex || "0"
)
setHoverRating(starIndex)
}
const handleMouseLeave = () => {
setIsHovering(false)
setHoverRating(null)
}
const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
const starIndex = parseInt(
(event.currentTarget as HTMLDivElement).dataset.starIndex || "0"
)
setCurrentRating(starIndex)
setHoverRating(null)
onRatingChange?.(starIndex)
}
const displayRating = hoverRating ?? currentRating
const fullStars = Math.floor(displayRating)
const partialStar =
displayRating % 1 > 0 ? (
<PartialStar
fillPercentage={displayRating % 1}
size={size}
className={cn(ratingVariants[variant].star)}
Icon={Icon}
/>
) : null
return (
<div
className={cn("flex w-fit items-center gap-2 ")}
onMouseLeave={handleMouseLeave}
{...props}
>
<div className="flex items-center" onMouseEnter={handleMouseEnter}>
{[...Array(fullStars)].map((_, i) =>
React.cloneElement(Icon, {
key: i,
size,
className: cn(
fill ? "fill-current stroke-1" : "fill-transparent",
ratingVariants[variant].star
),
onClick: handleClick,
onMouseEnter: handleMouseEnter,
"data-star-index": i + 1,
})
)}
{partialStar}
{[
...Array(Math.max(0, totalStars - fullStars - (partialStar ? 1 : 0))),
].map((_, i) =>
React.cloneElement(Icon, {
key: i + fullStars + 1,
size,
className: cn("stroke-1", ratingVariants[variant].emptyStar),
onClick: handleClick,
onMouseEnter: handleMouseEnter,
"data-star-index": i + fullStars + 1,
})
)}
</div>
<span className="text-muted-foreground">
Current Rating: {`${currentRating}`}
</span>
</div>
)
}
interface PartialStarProps {
fillPercentage: number
size: number
className?: string
Icon: React.ReactElement
}
const PartialStar = ({ ...props }: PartialStarProps) => {
const { fillPercentage, size, className, Icon } = props
return (
<div style={{ position: "relative", display: "inline-block" }}>
{React.cloneElement(Icon, {
size,
className: cn("fill-transparent", className),
})}
<div
style={{
position: "absolute",
top: 0,
overflow: "hidden",
width: `${fillPercentage * 100}%`,
}}
>
{React.cloneElement(Icon, {
size,
className: cn("fill-current", className),
})}
</div>
</div>
)
}
Here is a way to rendere the rating stars component with a disabled state, preventing any interactions with the stars (or any).
<CommentRatings
rating={3}
totalStars={5}
size={24}
variant="default"
disabled={true}
/>
import React, { useState } from "react";
import { Star } from "lucide-react";
import { cn } from "@/lib/utils"
const ratingVariants = {
default: {
star: "text-foreground",
emptyStar: "text-muted-foreground",
},
destructive: {
star: "text-red-500",
emptyStar: "text-red-200",
},
yellow: {
star: "text-yellow-500",
emptyStar: "text-yellow-200",
},
};
interface RatingsProps extends React.HTMLAttributes<HTMLDivElement> {
rating: number;
totalStars?: number;
size?: number;
fill?: boolean;
Icon?: React.ReactElement;
variant?: keyof typeof ratingVariants;
onRatingChange?: (rating: number) => void;
disabled?: boolean; // Add disabled prop
}
export const CommentRatings = ({
rating: initialRating,
totalStars = 5,
size = 20,
fill = true,
Icon = <Star />,
variant = "default",
onRatingChange,
disabled = false, // Default to false if disabled prop is not provided
...props
}: RatingsProps) => {
const [hoverRating, setHoverRating] = useState<number | null>(null);
const [currentRating, setCurrentRating] = useState(initialRating);
const [isHovering, setIsHovering] = useState(false);
const handleMouseEnter = (event: React.MouseEvent<HTMLDivElement>) => {
if (!disabled) {
setIsHovering(true);
const starIndex = parseInt(
(event.currentTarget as HTMLDivElement).dataset.starIndex || "0"
);
setHoverRating(starIndex);
}
};
const handleMouseLeave = () => {
setIsHovering(false);
setHoverRating(null);
};
const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
if (!disabled) {
const starIndex = parseInt(
(event.currentTarget as HTMLDivElement).dataset.starIndex || "0"
);
setCurrentRating(starIndex);
setHoverRating(null);
if (onRatingChange) {
onRatingChange(starIndex);
}
}
};
const displayRating = disabled ? initialRating : hoverRating ?? currentRating;
const fullStars = Math.floor(displayRating);
const partialStar =
displayRating % 1 > 0 ? (
<PartialStar
fillPercentage={displayRating % 1}
size={size}
className={cn(ratingVariants[variant].star)}
Icon={Icon}
/>
) : null;
return (
<div
className={cn("flex w-fit flex-col gap-2", { 'pointer-events-none': disabled })}
onMouseLeave={handleMouseLeave}
{...props}
>
<div className="flex items-center" onMouseEnter={handleMouseEnter}>
{[...Array(fullStars)].map((_, i) =>
React.cloneElement(Icon, {
key: i,
size,
className: cn(
fill ? "fill-current stroke-1" : "fill-transparent",
ratingVariants[variant].star
),
onClick: handleClick,
onMouseEnter: handleMouseEnter,
"data-star-index": i + 1,
})
)}
{partialStar}
{[
...Array(Math.max(0, totalStars - fullStars - (partialStar ? 1 : 0))),
].map((_, i) =>
React.cloneElement(Icon, {
key: i + fullStars + 1,
size,
className: cn("stroke-1", ratingVariants[variant].emptyStar),
onClick: handleClick,
onMouseEnter: handleMouseEnter,
"data-star-index": i + fullStars + 1,
})
)}
</div>
<span className="text-xs text-muted-foreground font-semibold">
Current Rating: {`${currentRating}`}
</span>
</div>
);
};
interface PartialStarProps {
fillPercentage: number;
size: number;
className?: string;
Icon: React.ReactElement;
}
const PartialStar = ({ fillPercentage, size, className, Icon }: PartialStarProps) => {
return (
<div style={{ position: "relative", display: "inline-block" }}>
{React.cloneElement(Icon, {
size,
className: cn("fill-transparent", className),
})}
<div
style={{
position: "absolute",
top: 0,
overflow: "hidden",
width: `${fillPercentage * 100}%`,
}}
>
{React.cloneElement(Icon, {
size,
className: cn("fill-current", className),
})}
</div>
</div>
);
};
The "showText" state added with initial value of "true" to have the ability to hide rating value text below the stars (icons):
Usage:
<Rating
rating={3.5}
totalStars={5}
size={24}
variant="yellow"
className="h-1"
showText={false}
disabled={true}
/>
Component code (for NextJS):
'use client';
import React, { useState } from 'react';
import { Star } from 'lucide-react';
import { cn } from '@/lib/utils';
const ratingVariants = {
default: {
star: 'text-foreground',
emptyStar: 'text-muted-foreground',
},
destructive: {
star: 'text-red-500',
emptyStar: 'text-red-200',
},
yellow: {
star: 'text-yellow-500',
emptyStar: 'text-yellow-200',
},
};
interface RatingProps extends React.HTMLAttributes<HTMLDivElement> {
rating: number;
totalStars?: number;
size?: number;
fill?: boolean;
Icon?: React.ReactElement;
variant?: keyof typeof ratingVariants;
onRatingChange?: (rating: number) => void;
showText?: boolean; // Add showText prop
disabled?: boolean;
}
export const Rating = ({
rating: initialRating,
totalStars = 5,
size = 20,
fill = true,
Icon = <Star />,
variant = 'default',
onRatingChange,
showText = true, // Default to true if disabled prop is not provided
disabled = false, // Default to false if disabled prop is not provided
...props
}: RatingProps) => {
const [hoverRating, setHoverRating] = useState<number | null>(null);
const [currentRating, setCurrentRating] = useState(initialRating);
const [isHovering, setIsHovering] = useState(false);
const handleMouseEnter = (event: React.MouseEvent<HTMLDivElement>) => {
if (!disabled) {
setIsHovering(true);
const starIndex = parseInt(
(event.currentTarget as HTMLDivElement).dataset.starIndex || '0'
);
setHoverRating(starIndex);
}
};
const handleMouseLeave = () => {
setIsHovering(false);
setHoverRating(null);
};
const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
if (!disabled) {
const starIndex = parseInt(
(event.currentTarget as HTMLDivElement).dataset.starIndex || '0'
);
setCurrentRating(starIndex);
setHoverRating(null);
if (onRatingChange) {
onRatingChange(starIndex);
}
}
};
const displayRating = disabled ? initialRating : hoverRating ?? currentRating;
const fullStars = Math.floor(displayRating);
const partialStar =
displayRating % 1 > 0 ? (
<PartialStar
fillPercentage={displayRating % 1}
size={size}
className={cn(ratingVariants[variant].star)}
Icon={Icon}
/>
) : null;
return (
<div
className={cn('flex w-fit flex-col gap-2', {
'pointer-events-none': disabled,
})}
onMouseLeave={handleMouseLeave}
{...props}
>
<div className="flex items-center" onMouseEnter={handleMouseEnter}>
{[...Array(fullStars)].map((_, i) =>
React.cloneElement(Icon, {
key: i,
size,
className: cn(
fill ? 'fill-current stroke-1' : 'fill-transparent',
ratingVariants[variant].star
),
onClick: handleClick,
onMouseEnter: handleMouseEnter,
'data-star-index': i + 1,
})
)}
{partialStar}
{[
...Array(Math.max(0, totalStars - fullStars - (partialStar ? 1 : 0))),
].map((_, i) =>
React.cloneElement(Icon, {
key: i + fullStars + 1,
size,
className: cn('stroke-1', ratingVariants[variant].emptyStar),
onClick: handleClick,
onMouseEnter: handleMouseEnter,
'data-star-index': i + fullStars + 1,
})
)}
</div>
{showText && (
<span className="text-xs text-muted-foreground font-semibold">
Current Rating: {`${currentRating}`}
</span>
)}
</div>
);
};
interface PartialStarProps {
fillPercentage: number;
size: number;
className?: string;
Icon: React.ReactElement;
}
const PartialStar = ({
fillPercentage,
size,
className,
Icon,
}: PartialStarProps) => {
return (
<div style={{ position: 'relative', display: 'inline-block' }}>
{React.cloneElement(Icon, {
size,
className: cn('fill-transparent', className),
})}
<div
style={{
position: 'absolute',
top: 0,
overflow: 'hidden',
width: `${fillPercentage * 100}%`,
}}
>
{React.cloneElement(Icon, {
size,
className: cn('fill-current', className),
})}
</div>
</div>
);
};
Here is a way to rendere the rating stars component with a disabled state, preventing any interactions with the stars (or any).
<CommentRatings rating={3} totalStars={5} size={24} variant="default" disabled={true} />
import React, { useState } from "react"; import { Star } from "lucide-react"; import { cn } from "@/lib/utils" const ratingVariants = { default: { star: "text-foreground", emptyStar: "text-muted-foreground", }, destructive: { star: "text-red-500", emptyStar: "text-red-200", }, yellow: { star: "text-yellow-500", emptyStar: "text-yellow-200", }, }; interface RatingsProps extends React.HTMLAttributes<HTMLDivElement> { rating: number; totalStars?: number; size?: number; fill?: boolean; Icon?: React.ReactElement; variant?: keyof typeof ratingVariants; onRatingChange?: (rating: number) => void; disabled?: boolean; // Add disabled prop } export const CommentRatings = ({ rating: initialRating, totalStars = 5, size = 20, fill = true, Icon = <Star />, variant = "default", onRatingChange, disabled = false, // Default to false if disabled prop is not provided ...props }: RatingsProps) => { const [hoverRating, setHoverRating] = useState<number | null>(null); const [currentRating, setCurrentRating] = useState(initialRating); const [isHovering, setIsHovering] = useState(false); const handleMouseEnter = (event: React.MouseEvent<HTMLDivElement>) => { if (!disabled) { setIsHovering(true); const starIndex = parseInt( (event.currentTarget as HTMLDivElement).dataset.starIndex || "0" ); setHoverRating(starIndex); } }; const handleMouseLeave = () => { setIsHovering(false); setHoverRating(null); }; const handleClick = (event: React.MouseEvent<HTMLDivElement>) => { if (!disabled) { const starIndex = parseInt( (event.currentTarget as HTMLDivElement).dataset.starIndex || "0" ); setCurrentRating(starIndex); setHoverRating(null); if (onRatingChange) { onRatingChange(starIndex); } } }; const displayRating = disabled ? initialRating : hoverRating ?? currentRating; const fullStars = Math.floor(displayRating); const partialStar = displayRating % 1 > 0 ? ( <PartialStar fillPercentage={displayRating % 1} size={size} className={cn(ratingVariants[variant].star)} Icon={Icon} /> ) : null; return ( <div className={cn("flex w-fit flex-col gap-2", { 'pointer-events-none': disabled })} onMouseLeave={handleMouseLeave} {...props} > <div className="flex items-center" onMouseEnter={handleMouseEnter}> {[...Array(fullStars)].map((_, i) => React.cloneElement(Icon, { key: i, size, className: cn( fill ? "fill-current stroke-1" : "fill-transparent", ratingVariants[variant].star ), onClick: handleClick, onMouseEnter: handleMouseEnter, "data-star-index": i + 1, }) )} {partialStar} {[ ...Array(Math.max(0, totalStars - fullStars - (partialStar ? 1 : 0))), ].map((_, i) => React.cloneElement(Icon, { key: i + fullStars + 1, size, className: cn("stroke-1", ratingVariants[variant].emptyStar), onClick: handleClick, onMouseEnter: handleMouseEnter, "data-star-index": i + fullStars + 1, }) )} </div> <span className="text-xs text-muted-foreground font-semibold"> Current Rating: {`${currentRating}`} </span> </div> ); }; interface PartialStarProps { fillPercentage: number; size: number; className?: string; Icon: React.ReactElement; } const PartialStar = ({ fillPercentage, size, className, Icon }: PartialStarProps) => { return ( <div style={{ position: "relative", display: "inline-block" }}> {React.cloneElement(Icon, { size, className: cn("fill-transparent", className), })} <div style={{ position: "absolute", top: 0, overflow: "hidden", width: `${fillPercentage * 100}%`, }} > {React.cloneElement(Icon, { size, className: cn("fill-current", className), })} </div> </div> ); };
Thank you. When trying to change the Icon with the props, the size changes entirely and cannot be changed (very small).
import React, { useState } from "react" import { Star } from "lucide-react" import { cn } from "@/lib/utils" const ratingVariants = { default: { star: "text-foreground", emptyStar: "text-muted-foreground", }, destructive: { star: "text-red-500", emptyStar: "text-red-200", }, yellow: { star: "text-yellow-500", emptyStar: "text-yellow-200", }, } interface RatingsProps extends React.HTMLAttributes<HTMLDivElement> { rating: number totalStars?: number size?: number fill?: boolean Icon?: React.ReactElement variant?: keyof typeof ratingVariants onRatingChange?: (rating: number) => void } export const CommentRatings = ({ rating: initialRating, totalStars = 5, size = 20, fill = true, Icon = <Star />, variant = "default", onRatingChange, ...props }: RatingsProps) => { const [hoverRating, setHoverRating] = useState<number | null>(null) const [currentRating, setCurrentRating] = useState(initialRating) const [isHovering, setIsHovering] = useState(false) const handleMouseEnter = (event: React.MouseEvent<HTMLDivElement>) => { setIsHovering(true) const starIndex = parseInt( (event.currentTarget as HTMLDivElement).dataset.starIndex || "0" ) setHoverRating(starIndex) } const handleMouseLeave = () => { setIsHovering(false) setHoverRating(null) } const handleClick = (event: React.MouseEvent<HTMLDivElement>) => { const starIndex = parseInt( (event.currentTarget as HTMLDivElement).dataset.starIndex || "0" ) setCurrentRating(starIndex) setHoverRating(null) onRatingChange?.(starIndex) } const displayRating = hoverRating ?? currentRating const fullStars = Math.floor(displayRating) const partialStar = displayRating % 1 > 0 ? ( <PartialStar fillPercentage={displayRating % 1} size={size} className={cn(ratingVariants[variant].star)} Icon={Icon} /> ) : null return ( <div className={cn("flex w-fit items-center gap-2 ")} onMouseLeave={handleMouseLeave} {...props} > <div className="flex items-center" onMouseEnter={handleMouseEnter}> {[...Array(fullStars)].map((_, i) => React.cloneElement(Icon, { key: i, size, className: cn( fill ? "fill-current stroke-1" : "fill-transparent", ratingVariants[variant].star ), onClick: handleClick, onMouseEnter: handleMouseEnter, "data-star-index": i + 1, }) )} {partialStar} {[ ...Array(Math.max(0, totalStars - fullStars - (partialStar ? 1 : 0))), ].map((_, i) => React.cloneElement(Icon, { key: i + fullStars + 1, size, className: cn("stroke-1", ratingVariants[variant].emptyStar), onClick: handleClick, onMouseEnter: handleMouseEnter, "data-star-index": i + fullStars + 1, }) )} </div> <span className="text-muted-foreground"> Current Rating: {`${currentRating}`} </span> </div> ) } interface PartialStarProps { fillPercentage: number size: number className?: string Icon: React.ReactElement } const PartialStar = ({ ...props }: PartialStarProps) => { const { fillPercentage, size, className, Icon } = props return ( <div style={{ position: "relative", display: "inline-block" }}> {React.cloneElement(Icon, { size, className: cn("fill-transparent", className), })} <div style={{ position: "absolute", top: 0, overflow: "hidden", width: `${fillPercentage * 100}%`, }} > {React.cloneElement(Icon, { size, className: cn("fill-current", className), })} </div> </div> ) }
Hi @lumpinif, I tried your rating component but it giving me error of invalid array. And also please provide an example of code that how can we use it with the FormField element with react hook form and shadcn form if possible
@shadcn why not add this one ^ to the library?
This issue has been automatically closed because it received no activity for a while. If you think it was closed by accident, please leave a comment. Thank you.
Here's a minimal rating component (no-dependencies needed):
https://github.com/user-attachments/assets/aa0492cf-6769-4f52-ab7f-f6590adbc1e2
import { useState } from 'react';
export default function Component() {
const [rating, setRating] = useState(0);
const handleRatingChange = (newRating: number) => {
setRating(newRating);
};
return (
<div className="flex items-center gap-1 cursor-pointer">
{[1, 2, 3, 4, 5].map((star) => (
<StarIcon
key={star}
className={`w-6 h-6 ${star <= rating ? 'fill-primary' : 'fill-muted stroke-muted-foreground'}`}
onClick={() => handleRatingChange(star)}
/>
))}
</div>
);
}
function StarIcon(props) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
);
}