# Product Review & Ratings 1

> A paginated review list with reactions and a write-a-review form

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

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

## Install

```bash
npx shadcn@latest add https://shadcnstore.com/r/review-rating-1.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`, `card`, `input`, `label`, `pagination`, `textarea`

## Files

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

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

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

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

```tsx
'use client'

import { useMemo, useState } from 'react'
import { Star, ThumbsUp, MessageCircle, Calendar } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { ReviewRating1ReactionToggle } from './review-rating-1-reaction-toggle'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Card, CardContent } from '@/components/ui/card'
import { cn } from '@/lib/utils'
import {
  Pagination,
  PaginationContent,
  PaginationItem,
  PaginationLink,
  PaginationNext,
  PaginationPrevious,
} from '@/components/ui/pagination'
import { type Review, reviews, storeData } from "@/components/data/review-rating-1-data"
import { ReviewRating1ReviewForm } from './review-rating-1-review-form'

export function ReviewRating1() {
  const [currentPage, setCurrentPage] = useState(1)
  const [isLiked, setIsLiked] = useState<Record<number, boolean>>({})
  const reviewsPerPage = 2 // Show only 2 reviews per page

  // Calculate pagination
  const totalPages = Math.ceil(reviews.length / reviewsPerPage)
  const currentReviews = useMemo<Review[]>(() => {
    const startIndex = (currentPage - 1) * reviewsPerPage
    const endIndex = startIndex + reviewsPerPage
    return reviews.slice(startIndex, endIndex)
  }, [currentPage, reviewsPerPage])

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

      <div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
        <div className="flex flex-col gap-6 lg:col-span-2">
          {currentReviews.map(review => (
            <Card key={review.id} className="transition-all hover:shadow-md">
              <CardContent>
                <div className="flex items-start justify-between gap-4">
                  <div className="flex items-start gap-4">
                    <Avatar className="bg-muted size-12">
                      <AvatarImage src={review.userAvatar} alt={`${review.userName}'s avatar`} />
                      <AvatarFallback>{review.userName.substring(0, 2)}</AvatarFallback>
                    </Avatar>
                    <div>
                      <h3 className="font-medium">{review.userName}</h3>
                      <div className="text-muted-foreground mt-1 flex items-center gap-2 text-sm">
                        <div className="flex" aria-label={`Rated ${review.rating} out of 5`}>
                          {Array(5)
                            .fill(0)
                            .map((_, i) => (
                              <Star
                                key={i}
                                className={cn(
                                  'size-4',
                                  i < review.rating
                                    ? 'fill-foreground text-foreground'
                                    : 'text-foreground fill-transparent'
                                )}
                              />
                            ))}
                        </div>
                        <span>•</span>
                        <time
                          dateTime={new Date(review.date).toISOString().split('T')[0]}
                          className="flex items-center"
                        >
                          <Calendar className="me-1 size-3" />
                          {review.date}
                        </time>
                      </div>
                    </div>
                  </div>
                </div>
                <p className="text-muted-foreground mt-4">{review.content}</p>
                <div className="mt-4 flex gap-4">
                  <Button
                    variant="ghost"
                    size="sm"
                    onClick={() => setIsLiked(prev => ({ ...prev, [review.id]: !prev[review.id] }))}
                    className="h-8 px-3 text-xs text-muted-foreground hover:bg-primary/10 hover:text-primary cursor-pointer"
                    aria-label={`${isLiked[review.id] ? 'Unlike' : 'Like'} this review`}
                  >
                    <ReviewRating1ReactionToggle active={!!isLiked[review.id]} icon={ThumbsUp} iconClassName='size-4' activeColorClassName='text-primary' />
                    <span>{(review.likes ?? 0) + (isLiked[review.id] ? 1 : 0)}</span>
                  </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"
                    aria-label="View comments"
                  >
                    <MessageCircle className="size-4" />
                    <span>{review.comments}</span>
                  </Button>
                </div>
              </CardContent>
            </Card>
          ))}
          {totalPages > 1 ? (
            <Pagination className="mt-6">
              <PaginationContent>
                <PaginationItem>
                  <PaginationPrevious
                    onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
                    className={currentPage === 1 ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
                  />
                </PaginationItem>
                {Array.from({ length: totalPages }).map((_, i) => (
                  <PaginationItem key={i}>
                    <PaginationLink
                      onClick={() => setCurrentPage(i + 1)}
                      isActive={currentPage === i + 1}
                      className="cursor-pointer"
                    >
                      {i + 1}
                    </PaginationLink>
                  </PaginationItem>
                ))}
                <PaginationItem>
                  <PaginationNext
                    onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
                    className={currentPage === totalPages ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
                  />
                </PaginationItem>
              </PaginationContent>
            </Pagination>
          ) : null}
        </div>

        <ReviewRating1ReviewForm storeData={storeData} />
      </div>
    </div>
  )
}

export default ReviewRating1
```

### `storage/app/registry-sources/e-commerce/reviews-ratings/review-rating-1/review-rating-1-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 ReviewRating1ReactionToggle({
  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 ReviewRating1ReactionToggle
```

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

```tsx
'use client'

import { FormEvent, useState } from 'react'
import { Send } from 'lucide-react'
import { Star } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { cn } from '@/lib/utils'

import { type StoreData } from "@/components/data/review-rating-1-data"

interface FormState {
  name: string
  email: string
  review: string
}

export function ReviewRating1ReviewForm({ storeData }: { storeData: StoreData }) {
  const [isSubmitting, setIsSubmitting] = useState(false)
  const [rating, setRating] = useState<number>(0)
  const [hoveredStar, setHoveredStar] = useState<number>(0)
  const [formData, setFormData] = useState<FormState>({
    name: '',
    email: '',
    review: '',
  })

  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    setIsSubmitting(true)
    // Simulate form submission
    setTimeout(() => {
      setRating(0)
      setFormData({ name: '', email: '', review: '' })
      setIsSubmitting(false)
    }, 1000)
  }

  return (
    <div className="lg:sticky lg:top-8 lg:h-fit">
      <Card>
        <CardHeader>
          <CardTitle id="review-form-heading">{storeData.formTitle}</CardTitle>
          <CardDescription>{storeData.formDescription}</CardDescription>
        </CardHeader>
        <CardContent>
          <form onSubmit={handleSubmit} className="flex flex-col gap-6">
            <div className="flex flex-col gap-2">
              <div className="flex gap-1" role="radiogroup" aria-label="Rating">
                {[1, 2, 3, 4, 5].map(star => (
                  <Button
                    variant="ghost"
                    key={star}
                    type="button"
                    onClick={() => setRating(star)}
                    onMouseEnter={() => setHoveredStar(star)}
                    onMouseLeave={() => setHoveredStar(0)}
                    className="h-9 px-4 py-2 focus-visible:ring-ring cursor-pointer rounded-full !p-1 hover:bg-transparent focus:outline-hidden focus-visible:ring-2"
                    aria-pressed={rating === star}
                    aria-label={`Rate ${star} ${star === 1 ? 'star' : 'stars'}`}
                  >
                    <Star className={cn('size-6', (hoveredStar || rating) >= star ? 'fill-foreground' : '')} />
                  </Button>
                ))}
              </div>
            </div>

            <div className="flex flex-col gap-2">
              <Label htmlFor="name">{storeData.nameLabel}</Label>
              <Input
                id="name"
                value={formData.name}
                onChange={e => setFormData({ ...formData, name: e.target.value })}
                placeholder={storeData.namePlaceholder}
                required
                aria-required="true"
                className="h-9"
              />
            </div>

            <div className="flex flex-col gap-2">
              <Label htmlFor="email">{storeData.emailLabel}</Label>
              <Input
                id="email"
                type="email"
                value={formData.email}
                onChange={e => setFormData({ ...formData, email: e.target.value })}
                placeholder={storeData.emailPlaceholder}
                required
                aria-required="true"
                className="h-9"
              />
            </div>

            <div className="flex flex-col gap-2">
              <Label htmlFor="review">{storeData.reviewLabel}</Label>
              <Textarea
                id="review"
                value={formData.review}
                onChange={e => setFormData({ ...formData, review: e.target.value })}
                placeholder={storeData.reviewPlaceholder}
                required
                aria-required="true"
              />
            </div>

            <Button type="submit" className="h-9 px-4 py-2 w-full cursor-pointer" disabled={isSubmitting}>
              {isSubmitting ? storeData.submittingButtonText : storeData.submitButtonText}
              {!isSubmitting && <Send className="size-4" />}
            </Button>
          </form>
        </CardContent>
      </Card>
    </div>
  )
}

export default ReviewRating1ReviewForm
```

### `storage/app/registry-sources/e-commerce/reviews-ratings/review-rating-1/review-rating-1-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
  formTitle: string
  formDescription: string
  nameLabel: string
  emailLabel: string
  reviewLabel: string
  namePlaceholder: string
  emailPlaceholder: string
  reviewPlaceholder: string
  submitButtonText: string
  submittingButtonText: string
}

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:
      "I was blown away by the quality of this product. It's clear that a lot of thought and care went into its design. The attention to detail is remarkable, and it's been an absolute pleasure to use. I've already recommended it to several friends who are equally impressed with their purchases.",
    likes: 8,
    comments: 2,
  },
  {
    id: 2,
    userName: 'Anuj Mishra',
    userAvatar: 'https://notion-avatars.netlify.app/api/avatar?preset=male-1',
    date: '16 Dec, 2023',
    rating: 5,
    content:
      "The customer service experience was exceptional from start to finish. When I had questions about my order, the team responded promptly and went above and beyond to ensure I was completely satisfied. It's rare to find such dedication to customer happiness these days.",
    likes: 10,
    comments: 5,
  },
  {
    id: 3,
    userName: 'Robert Karmazov',
    userAvatar: 'https://notion-avatars.netlify.app/api/avatar?preset=male-2',
    date: '24 Oct, 2023',
    rating: 5,
    content:
      "I was hesitant about the price at first, but after using this product for several weeks, I can confidently say it's worth every penny. The quality and durability are outstanding, and it's made a noticeable difference in my daily routine. I'm already planning to purchase more items from this store.",
    likes: 4,
    comments: 0,
  },
  {
    id: 4,
    userName: 'Sophia Chen',
    userAvatar: 'https://notion-avatars.netlify.app/api/avatar?preset=female-2',
    date: '15 May, 2024',
    rating: 4,
    content:
      "This is a fantastic product that has really improved my daily routine. The quality is excellent and it's very user-friendly. The only reason I'm not giving it 5 stars is that I wish it came in more color options. Other than that, it's been a great purchase and I would definitely recommend it to others.",
    likes: 6,
    comments: 1,
  },
]

export const storeData: StoreData = {
  pageTitle: 'Customer Reviews',
  pageDescription: 'Read what our customers have to say about their experience with our products and services.',
  formTitle: 'Submit Your Review',
  formDescription: 'Share your experience with our community',
  nameLabel: 'Name',
  emailLabel: 'Email',
  reviewLabel: 'Write Your Review',
  namePlaceholder: 'John Doe',
  emailPlaceholder: 'mail@example.com',
  reviewPlaceholder: 'Write here...',
  submitButtonText: 'Submit Review',
  submittingButtonText: 'Submitting...',
}
```
