# Product Review & Ratings 2

> A rating summary with a per-star distribution above a review carousel

A shadcn/ui block from the [@shadcnstore](https://www.shadcnstore.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/shadcnstore/review-rating-2).

- Page, with a live preview: https://shadcn.exchange/r/shadcnstore/review-rating-2
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/shadcnstore/review-rating-2-light-f21c02f911e84faf.png
- Registry item: https://shadcnstore.com/r/review-rating-2.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://shadcnstore.com/r/review-rating-2.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `avatar`, `button`, `carousel`, `progress`

## Files

### `storage/app/registry-sources/e-commerce/reviews-ratings/review-rating-2/page.tsx`

```tsx
import { ReviewRating2 } from "@/components/blocks/e-commerce/reviews-ratings/review-rating-2/components/review-rating-2"

export default function Page() {
  return (
    <div className="flex min-h-svh w-full items-center justify-center">
      <div className="w-full">
        <ReviewRating2 />
      </div>
    </div>
  )
}
```

### `storage/app/registry-sources/e-commerce/reviews-ratings/review-rating-2/review-rating-2.tsx`

```tsx
'use client'

import { useState, useEffect } from 'react'
import { Star, ThumbsUp, MessageCircle, Calendar } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { ReviewRating2ReactionToggle } from './review-rating-2-reaction-toggle'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Progress } from '@/components/ui/progress'
import { cn } from '@/lib/utils'
import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselPrevious,
  CarouselNext,
  type CarouselApi,
} from '@/components/ui/carousel'
import { reviews, storeData } from "@/components/data/review-rating-2-data"

export function ReviewRating2() {
  const [carouselApi, setCarouselApi] = useState<CarouselApi>()
  const [current, setCurrent] = useState<number>(0)
  const [isLiked, setIsLiked] = useState<Record<number, boolean>>({})

  useEffect(() => {
    if (!carouselApi) return

    const updateCurrent = () => setCurrent(carouselApi.selectedScrollSnap())

    carouselApi.on('select', updateCurrent)
    return () => {
      carouselApi.off('select', updateCurrent)
    }
  }, [carouselApi])

  const scrollTo = (index: number) => {
    if (carouselApi) {
      carouselApi.scrollTo(index)
    }
  }

  const handleLike = (reviewId: number) => {
    setIsLiked(prev => ({
      ...prev,
      [reviewId]: !prev[reviewId],
    }))
  }

  const formatNumber = (num: number): string => {
    return num >= 1000 ? `${(num / 1000).toFixed(1)}k` : num.toString()
  }

  return (
    <div className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8 py-12'>
      <div className='mb-12 text-center'>
        <h2 className='mb-3 text-4xl font-bold'>{storeData.pageTitle}</h2>
        <p className='text-muted-foreground mx-auto max-w-2xl'>{storeData.pageDescription}</p>
      </div>

      <div className='mt-8 mb-12 flex flex-col items-center justify-center gap-8 md:flex-row'>
        <div className='text-center'>
          <div className='mb-2 text-6xl font-bold'>{storeData.averageRating}</div>
          <div className='flex justify-center gap-1'>
            {Array.from({ length: 5 }).map((_, i) => (
              <Star key={i} className={cn('size-5', 'fill-current')} />
            ))}
          </div>
          <p className='text-muted-foreground mt-1 text-sm'>Based on {formatNumber(storeData.totalReviews)} reviews</p>
        </div>

        <div className='bg-border mx-4 hidden h-16 w-px md:block' />

        <div className='flex flex-col gap-1'>
          {Object.entries(storeData.ratingDistribution).map(([rating, percentage]) => (
            <div key={rating} className='flex items-center gap-2'>
              <div className='flex flex-1 justify-end gap-1'>
                {Array.from({ length: Number(rating) }).map((_, i) => (
                  <Star key={i} className='size-3 fill-current' />
                ))}
              </div>
              <Progress value={percentage} className='h-2 w-32' />
              <span className='text-muted-foreground w-8 text-sm'>{percentage}%</span>
            </div>
          ))}
        </div>
      </div>

      {/* Carousel Section */}
      <div className='relative mx-auto max-w-4xl'>
        <Carousel
          setApi={setCarouselApi}
          className='w-full'
          opts={{
            align: 'start',
            loop: true,
          }}
        >
          <CarouselContent>
            {reviews.map(review => (
              <CarouselItem key={review.id}>
                <div className='p-6 sm:p-8'>
                  <div className='flex flex-col gap-4'>
                    {/* First Row: User Info */}
                    <div className='flex items-start gap-4'>
                      <Avatar className='bg-muted size-12'>
                        <AvatarImage src={review.userAvatar} alt={review.userName} />
                        <AvatarFallback>{review.userName.substring(0, 2)}</AvatarFallback>
                      </Avatar>
                      <div className='flex-1'>
                        <h3 className='text-lg font-semibold'>{review.userName}</h3>
                        <div className='mt-1 flex items-center gap-2'>
                          <div className='flex gap-0.5'>
                            {Array.from({ length: review.rating }).map((_, i) => (
                              <Star key={i} className='size-4 fill-current' />
                            ))}
                          </div>
                          <span className='text-muted-foreground text-sm'>•</span>
                          <time className='text-muted-foreground flex items-center text-sm'>
                            <Calendar className='me-1 size-3.5' />
                            {review.date}
                          </time>
                        </div>
                      </div>
                    </div>

                    {/* Second Row: Review Content and Actions */}
                    <div>
                      <p className='text-muted-foreground'>{review.content}</p>
                      <div className='mt-4 flex gap-4'>
                        <Button
                          variant='ghost'
                          size='sm'
                          onClick={() => handleLike(review.id)}
                          className="h-8 px-3 text-xs text-muted-foreground hover:bg-primary/10 hover:text-primary cursor-pointer"
                        >
                          <ReviewRating2ReactionToggle active={!!isLiked[review.id]} icon={ThumbsUp} iconClassName='size-4' activeColorClassName='text-primary' />
                          {formatNumber((review.likes ?? 0) + (isLiked[review.id] ? 1 : 0))}
                        </Button>
                        <Button
                          variant='ghost'
                          size='sm'
                          className="h-8 px-3 text-xs text-muted-foreground hover:bg-primary/10 hover:text-primary cursor-pointer"
                        >
                          <MessageCircle className='size-4' />
                          {formatNumber(review.comments ?? 0)}
                        </Button>
                      </div>
                    </div>
                  </div>
                </div>
              </CarouselItem>
            ))}
          </CarouselContent>
          <CarouselPrevious />
          <CarouselNext />
        </Carousel>

        {/* Carousel Indicators */}
        <div className='flex justify-center gap-2'>
          {reviews.map((_, i) => (
            <Button
              key={i}
              onClick={() => scrollTo(i)}
              className={`mx-1 size-2 cursor-pointer rounded-full p-1 transition-all ${current === i ? 'bg-foreground w-6' : 'bg-muted'}`}
              aria-label={`Go to review ${i + 1}`}
              aria-current={current === i ? 'step' : undefined}
            />
          ))}
        </div>
      </div>
    </div>
  )
}

export default ReviewRating2
```

### `storage/app/registry-sources/e-commerce/reviews-ratings/review-rating-2/review-rating-2-reaction-toggle.tsx`

```tsx
'use client'

import { useEffect, useRef, useState, type ComponentType } from 'react'

import { cn } from '@/lib/utils'

const PARTICLE_COUNT = 8

/**
 * The pop/burst keyframes travel with the component instead of living in the
 * app's stylesheet, so this file is self-contained: drop it in and the
 * animation works with no Tailwind config or global CSS to copy over.
 * Injected once per page, guarded by the marker attribute.
 */
const STYLE_MARKER = 'data-reaction-toggle-keyframes'
const STYLES = `
@keyframes reaction-toggle-pop {
  0% { transform: scale(1); }
  30% { transform: scale(0.82); }
  100% { transform: scale(1); }
}
@keyframes reaction-toggle-burst {
  0% { opacity: 0; transform: translate(0, 0) scale(0.4); }
  20% { opacity: 1; transform: translate(calc(var(--rx) * 0.25), calc(var(--ry) * 0.25)) scale(1); }
  100% { opacity: 0; transform: translate(var(--rx), var(--ry)) scale(0.6); }
}
.reaction-toggle-pop {
  animation: reaction-toggle-pop 350ms cubic-bezier(0.34, 1.96, 0.64, 1);
}
.reaction-toggle-burst {
  animation-name: reaction-toggle-burst;
  animation-timing-function: ease-out;
  animation-fill-mode: forwards;
}
@media (prefers-reduced-motion: reduce) {
  .reaction-toggle-pop,
  .reaction-toggle-burst { animation: none !important; }
}
`

function useReactionToggleStyles() {
  useEffect(() => {
    if (document.head.querySelector(`[${STYLE_MARKER}]`)) return

    const style = document.createElement('style')
    style.setAttribute(STYLE_MARKER, '')
    style.textContent = STYLES
    document.head.append(style)
  }, [])
}

type ReactionIconProps = {
  className?: string
}

type Particle = {
  rx: number
  ry: number
  duration: number
  delay: number
  size: number
}

function buildParticles(): Particle[] {
  return Array.from({ length: PARTICLE_COUNT }, (_, i) => {
    const angle = (Math.PI * 2 * i) / PARTICLE_COUNT + (Math.random() - 0.5) * 0.4
    const dist = 16 + Math.random() * 10

    return {
      rx: Math.round(Math.cos(angle) * dist * 10) / 10,
      ry: Math.round(Math.sin(angle) * dist * 10) / 10,
      duration: Math.round(500 + Math.random() * 200),
      delay: Math.round(Math.random() * 60),
      size: Math.round((0.7 + Math.random() * 0.8) * 100) / 100,
    }
  })
}

type ReactionToggleProps = {
  /** Whether the reaction is currently active (liked/helpful/etc). */
  active: boolean
  /** Icon component to render, e.g. `Heart` or `ThumbsUp` from lucide-react. */
  icon: ComponentType<ReactionIconProps>
  /** Wrapper class - sizing/positioning of the whole control. */
  className?: string
  /** Class applied to the icon itself (e.g. `size-5`). */
  iconClassName?: string
  /**
   * Tailwind color class (e.g. `text-primary`, `text-destructive`) applied to the
   * icon and particles while active. Drives `fill-current`/`bg-current`, so it
   * stays in sync with the design system instead of a hardcoded brand color.
   */
  activeColorClassName?: string
}

/**
 * Heart/thumbs-up style reaction control: the icon pops with an overshoot scale
 * and sprinkles 8 particles outward the moment it becomes active.
 */
export function ReviewRating2ReactionToggle({
  active,
  icon: Icon,
  className,
  iconClassName,
  activeColorClassName = 'text-primary',
}: ReactionToggleProps) {
  const [isBursting, setIsBursting] = useState(false)
  const wasActive = useRef(active)
  const particlesRef = useRef<Particle[]>(buildParticles())

  useReactionToggleStyles()

  useEffect(() => {
    if (active && !wasActive.current) {
      particlesRef.current = buildParticles()
      setIsBursting(true)
      const timeout = setTimeout(() => setIsBursting(false), 700)
      wasActive.current = active
      return () => clearTimeout(timeout)
    }

    wasActive.current = active
  }, [active])

  return (
    <span className={cn('relative inline-flex', className)}>
      <span className={cn('inline-flex', active && 'reaction-toggle-pop')}>
        <Icon
          className={cn(
            'transition-[fill,stroke,color] duration-150 ease-[cubic-bezier(0.22,1,0.36,1)]',
            active ? cn('fill-current', activeColorClassName) : 'fill-transparent',
            iconClassName
          )}
        />
      </span>

      {isBursting && (
        <span
          aria-hidden='true'
          className={cn('pointer-events-none absolute top-1/2 left-1/2 size-0', activeColorClassName)}
        >
          {particlesRef.current.map((particle, index) => (
            <i
              key={index}
              className='reaction-toggle-burst absolute rounded-full bg-current opacity-0'
              style={
                {
                  '--rx': `${particle.rx}px`,
                  '--ry': `${particle.ry}px`,
                  animationDuration: `${particle.duration}ms`,
                  animationDelay: `${particle.delay}ms`,
                  width: `${2.5 * particle.size}px`,
                  height: `${2.5 * particle.size}px`,
                  left: `${-1.25 * particle.size}px`,
                  top: `${-1.25 * particle.size}px`,
                } as React.CSSProperties
              }
            />
          ))}
        </span>
      )}
    </span>
  )
}

export default ReviewRating2ReactionToggle
```

### `storage/app/registry-sources/e-commerce/reviews-ratings/review-rating-2/review-rating-2-data.ts`

```ts
export interface Review {
  id: number
  userName: string
  userAvatar: string
  date: string
  rating: number
  content: string
  likes?: number
  comments?: number
}

export interface StoreData {
  pageTitle: string
  pageDescription: string
  averageRating: number
  totalReviews: number
  ratingDistribution: Record<string, number>
}

export const reviews: Review[] = [
  {
    id: 1,
    userName: 'Emma Davis',
    userAvatar: 'https://notion-avatars.netlify.app/api/avatar?preset=female-1',
    date: '20 Mar, 2024',
    rating: 5,
    content:
      'The quality of this product is outstanding! It arrived well-packaged and exceeded my expectations. The materials are durable and the design is both functional and stylish. I\'ve received so many compliments already!',
    likes: 28,
    comments: 12,
  },
  {
    id: 2,
    userName: 'Alex Morgan',
    userAvatar: 'https://notion-avatars.netlify.app/api/avatar?preset=male-1',
    date: '18 Mar, 2024',
    rating: 4,
    content:
      'I was a bit hesitant about the price at first, but after using it for a week, I can confidently say it\'s worth every penny. The only improvement I can think of would be to include more color options. Highly recommend!',
    likes: 15,
    comments: 3,
  },
  {
    id: 3,
    userName: 'Sarah Chen',
    userAvatar: 'https://notion-avatars.netlify.app/api/avatar?preset=male-3',
    date: '15 Mar, 2024',
    rating: 5,
    content:
      'This product has made such a difference in my daily routine. It\'s easy to use, reliable, and looks great in my home. The customer service was also excellent when I had a question about the settings.',
    likes: 42,
    comments: 8,
  },
]

export const storeData: StoreData = {
  pageTitle: 'Customer Stories',
  pageDescription: 'Discover what our community has to say about their experience. Real stories from real users.',
  averageRating: 4.8,
  totalReviews: 1482,
  ratingDistribution: { '5': 75, '4': 20, '3': 5, '2': 5, '1': 5 },
}
```
