HextaUI

Reviews

Product ratings, customer photos and reviews for a product page. Handles rating filters, helpful votes, review photos and products with no reviews yet.

Open in new tab

Reviews is the reviews section of a product page: an average with stars, a rating histogram, feature ratings, a size scale, a strip of customer photos and the review list. It takes an array of reviews and works out the stats itself, or takes stats from your API when you only load a page at a time.

Every histogram bar is a button. Click 4 stars and the list shows only 4-star reviews, the bar stays highlighted, a removable 4 stars chip appears above the list and screen readers hear how many reviews match. Filters are single-choice, combine with With photos, search within reviews and four sort orders, and an empty result offers to clear them. With five reviews or fewer the histogram is hidden, since a distribution of three reviews says nothing.

Each review shows its stars, title, body clamped at six lines with Read more, the buyer’s name with a Verified buyer badge, the variant they bought and their size feedback. Photos open a lightbox. A merchant reply sits indented under the review. Helpful votes count up straight away, one vote per review, and roll back with a message if your API fails. Rating-only and photo-only reviews lay out without empty gaps.

WriteReview is a dialog, a bottom sheet on phones, with a star rating input, title, body with a minimum length, a Runs small · True to size · Runs large scale, photo uploads with previews, name and email. It validates in place, keeps the draft if the shopper closes it, and ends on “Thanks, your review will appear after a quick check.” RatingStars, RatingInput, ReviewsSummary, ReviewPhotos and ReviewList also work on their own.

  1. HextaUI Pro holen

    Eine einmalige Zahlung schaltet jeden Block frei, auch künftige. Preise ansehen.

  2. Die Pro-Registry zu components.json hinzufügen

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  3. Token hinzufügen

    Erstelle auf deiner Kontoseite einen Token und trage ihn in .env.local als HEXTAUI_PRO_TOKEN ein.

  4. Den Block hinzufügen

    pnpm dlx shadcn@latest add @hextaui-pro/reviews

From Judge.me, Yotpo or Shopify

Map each provider’s review once to the Review shape. Judge.me pictures, Yotpo images and custom fit fields, helpful votes and merchant replies all have a place, and Shopify’s product review metaobjects map in a few lines.

import type { Review, ReviewFit } from "@/components/blocks/reviews/review"

type JudgeMeReview = {
  id: number
  rating: number
  title: string | null
  body: string | null
  created_at: string
  verified: "buyer" | "nothing" | "email"
  reviewer: { name: string }
  pictures: { urls: { original: string; small: string } }[]
  votes?: { up: number; down: number }
  reply?: { content: string; created_at: string } | null
}

type YotpoReview = {
  id: number
  score: number
  title: string
  content: string
  created_at: string
  verified_buyer: boolean
  user: { display_name: string }
  votes_up: number
  votes_down: number
  images_data?: { id: number; original_url: string; thumb_url: string }[]
  custom_fields?: Record<string, { title: string; value: string }>
  comment?: { content: string; created_at: string } | null
}

type ShopifyReviewMetaobject = {
  id: string
  rating: { value: string }
  title?: { value: string }
  body?: { value: string }
  author: { value: string }
  createdAt: string
  verified?: { value: string }
}

const fits: Record<string, ReviewFit> = {
  "Runs small": -1,
  "True to size": 0,
  "Runs large": 1,
}

export function fromJudgeMe(review: JudgeMeReview): Review {
  return {
    id: String(review.id),
    rating: review.rating,
    title: review.title ?? undefined,
    body: review.body ?? undefined,
    author: review.reviewer.name,
    verified: review.verified === "buyer",
    createdAt: review.created_at,
    photos: review.pictures.map((picture, index) => ({
      id: `${review.id}-${index}`,
      src: picture.urls.original,
      thumbnail: picture.urls.small,
    })),
    helpful: review.votes,
    reply: review.reply
      ? { author: "", body: review.reply.content, createdAt: review.reply.created_at }
      : undefined,
  }
}

export function fromYotpo(review: YotpoReview): Review {
  const fields = Object.values(review.custom_fields ?? {})
  const fit = fields.find((field) => field.title === "Fit")?.value
  return {
    id: String(review.id),
    rating: review.score,
    title: review.title || undefined,
    body: review.content || undefined,
    author: review.user.display_name,
    verified: review.verified_buyer,
    createdAt: review.created_at,
    fit: fit ? fits[fit] : undefined,
    photos: review.images_data?.map((image) => ({
      id: String(image.id),
      src: image.original_url,
      thumbnail: image.thumb_url,
    })),
    helpful: { up: review.votes_up, down: review.votes_down },
    reply: review.comment
      ? { author: "", body: review.comment.content, createdAt: review.comment.created_at }
      : undefined,
  }
}

export function fromShopifyMetaobject(review: ShopifyReviewMetaobject): Review {
  const rating = JSON.parse(review.rating.value) as { value: string }
  return {
    id: review.id,
    rating: Number(rating.value),
    title: review.title?.value,
    body: review.body?.value,
    author: review.author.value,
    verified: review.verified?.value === "true",
    createdAt: review.createdAt,
  }
}

Server-side pagination

For products with thousands of reviews, pass stats from your API and set manual. Rating, photo, search and sort changes come out through onQueryChange, Show more calls onLoadMore, and the list dims while the next page loads.

"use client"

import * as React from "react"

import { defaultQuery, type Review, type ReviewQuery, type ReviewStats } from "@/components/blocks/reviews/review"
import { Reviews } from "@/components/blocks/reviews/reviews"

type Page = { reviews: Review[]; total: number; cursor: string | null }

async function fetchReviews(
  productId: string,
  query: ReviewQuery,
  cursor: string | null
): Promise<Page> {
  const params = new URLSearchParams({
    sort: query.sort,
    q: query.search,
    photos: String(query.withPhotos),
    limit: "10",
  })
  if (query.rating !== null) params.set("rating", String(query.rating))
  if (cursor) params.set("cursor", cursor)
  const response = await fetch(`/api/products/${productId}/reviews?${params}`)
  if (!response.ok) throw new Error("Couldn’t load reviews")
  return response.json()
}

export function ProductReviews({
  productId,
  stats,
  firstPage,
}: {
  productId: string
  stats: ReviewStats
  firstPage: Page
}) {
  const [query, setQuery] = React.useState<ReviewQuery>(defaultQuery)
  const [page, setPage] = React.useState(firstPage)
  const [loading, setLoading] = React.useState(false)
  const request = React.useRef(0)

  const changeQuery = async (next: ReviewQuery) => {
    setQuery(next)
    const id = ++request.current
    setLoading(true)
    try {
      const result = await fetchReviews(productId, next, null)
      if (id === request.current) setPage(result)
    } finally {
      if (id === request.current) setLoading(false)
    }
  }

  return (
    <Reviews
      manual
      stats={stats}
      reviews={page.reviews}
      total={page.total}
      query={query}
      onQueryChange={changeQuery}
      loading={loading}
      hasMore={page.cursor !== null}
      onLoadMore={async () => {
        const next = await fetchReviews(productId, query, page.cursor)
        setPage((current) => ({
          ...next,
          reviews: [...current.reviews, ...next.reviews],
        }))
      }}
      onVote={async (review, vote) => {
        const response = await fetch(`/api/reviews/${review.id}/vote`, {
          method: "POST",
          body: JSON.stringify({ vote }),
        })
        if (!response.ok) throw new Error("Vote failed")
      }}
    />
  )
}

Submit with photos

WriteReview hands you the rating, text, fit, photo Files, name and email. Post them as FormData. Throw an Error with a specific message and it shows in the dialog while everything the shopper wrote stays put.

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"

import type { ReviewDraft } from "@/components/blocks/reviews/review"
import { WriteReview } from "@/components/blocks/reviews/write-review"

async function submitReview(productId: string, draft: ReviewDraft) {
  const form = new FormData()
  form.set("productId", productId)
  form.set("rating", String(draft.rating))
  form.set("title", draft.title)
  form.set("body", draft.body)
  form.set("name", draft.name)
  form.set("email", draft.email)
  if (draft.fit !== null) form.set("fit", String(draft.fit))
  for (const photo of draft.photos) form.append("photos", photo, photo.name)

  const response = await fetch("/api/reviews", { method: "POST", body: form })
  if (response.status === 409) {
    throw new Error("You’ve already reviewed this product with that email.")
  }
  if (!response.ok) {
    throw new Error("Couldn’t send your review. Try again in a moment.")
  }
}

export function ReviewButton({ productId, title }: { productId: string; title: string }) {
  const [open, setOpen] = React.useState(false)
  return (
    <>
      <Button variant="outline" onClick={() => setOpen(true)}>
        Write a review
      </Button>
      <WriteReview
        open={open}
        onOpenChange={setOpen}
        product={{ title }}
        maxPhotos={4}
        onSubmit={(draft) => submitReview(productId, draft)}
      />
    </>
  )
}

Aufbau

Die Teile, die du zusammensetzt, von außen nach innen.

PartBeschreibung
ReviewsThe whole section: summary on the left, photos and list on the right, stacked on narrow screens.
ReviewsSummaryAverage, stars, review count, histogram filter, feature ratings, size scale and Write a review.
ReviewPhotosA scrolling strip of every customer photo, opening a lightbox that links back to the review.
ReviewListToolbar, filter status, review items, Show more and empty states.
WriteReviewThe review form in a dialog, with validation, photo uploads and a success state.
RatingStars, RatingInputStar display with fractional fill, and a star radio group for writing a review.
getReviewStats, queryReviews, formatCountWork out stats, filter and sort reviews, and format counts like 1,284 or 12.4K.

Reviews

Also takes div props.

PropTypStandard
reviewsThe reviews to show. In manual mode, the pages loaded so far.
Review[]–
statsCount, average, distribution, feature ratings and fit. Worked out from reviews when left out.
ReviewStats–
attributesWhich feature ratings to show, in order, with labels for each end of the scale.
{ name: string; low?: string; high?: string }[]–
photoReviewsReviews for the photo strip, when they differ from the loaded page.
Review[]–
queryThe rating filter, With photos, search and sort. Controlled when set.
ReviewQuery–
defaultQueryThe starting query when uncontrolled.
Partial<ReviewQuery>–
onQueryChangeCalled when a filter, the search or the sort changes. Search changes are debounced.
(query: ReviewQuery) => void–
manualThe reviews are already filtered, sorted and paged by your server.
booleanfalse
totalHow many reviews match the query, in manual mode.
number–
hasMoreWhether another page exists, in manual mode.
boolean–
onLoadMoreLoads the next page. A rejected promise shows Couldn’t load more reviews with a retry.
() => void | Promise<void>–
loadingDims the list while a new query loads.
boolean–
pageSizeReviews per Show more step when paging on the client.
number5
onVoteSaves a helpful vote. Counts change at once and roll back if it throws. null removes the vote.
(review, vote: "up" | "down" | null) => void | Promise<void>–
votesVotes the shopper already cast, by review id.
Record<string, "up" | "down">–
onSubmitReviewEnables Write a review. Throw an Error to show its message in the form.
(draft: ReviewDraft) => void | Promise<void>–
productShown at the top of the review form.
{ title: string; image?: string; variant?: string }–
fitShow the size scale in the summary and the form.
booleantrue
histogramThresholdHide the histogram at this many reviews or fewer.
number5
merchantNameName used for merchant replies without an author.
string–
localeLocale for numbers and dates.
Intl.LocalesArgument"en-US"
labelsEvery user-facing string, per part.
{ summary?, list?, photos?, write? }–

ReviewsSummary

Also takes section props.

PropTypStandard
statsThe numbers to show.
ReviewStats–
ratingThe active histogram filter.
1 | 2 | 3 | 4 | 5 | null–
onRatingChangeCalled with the clicked rating, or null when the active bar is clicked again.
(rating) => void–
onWriteReviewShows the Write a review button.
() => void–
headingLevelHeading level for Customer reviews.
2 | 32

ReviewList

Also takes section props, and the list props of Reviews.

PropTypStandard
countTotal reviews for the product, used for the no reviews yet state.
number–
onWriteReviewShows Write the first review in the empty state.
() => void–
showToolbarShow search, With photos and sort.
booleantrue
actionsRefreveal clears filters if needed, pages to the review, scrolls to it and focuses it.
Ref<{ reveal(id: string): void }>–

ReviewPhotos

Also takes section props.

PropTypStandard
reviewsReviews whose photos fill the strip.
Review[]–
onJumpToReviewShows Read review in the lightbox.
(review: Review) => void–
PropTypStandard
openWhether the dialog is open. Controlled when set.
boolean–
onOpenChangeCalled when the dialog opens or closes. It can’t close while submitting.
(open: boolean) => void–
onSubmitReceives rating, title, body, fit, photos as File[], name and email.
(draft: ReviewDraft) => void | Promise<void>–
minBodyLengthMinimum characters when the shopper writes a review body.
number20
maxPhotosHow many photos can be attached.
number5
maxPhotoBytesLargest photo accepted, in bytes.
number10485760
defaultValuesPrefill for signed-in shoppers or a rating picked from an email.
{ name?, email?, rating? }–

RatingStars

Also takes span props.

PropTypStandard
valueA rating from 0 to 5. Fractions fill part of a star.
number–
sizeStar size.
"sm" | "default" | "lg""default"

RatingInput

Also takes div props.

PropTypStandard
valueThe chosen rating. Controlled when set.
1 | 2 | 3 | 4 | 5 | null–
onValueChangeCalled when a star is chosen.
(value) => void–
invalidMarks the group invalid.
boolean–
labelsStar names and the Poor to Excellent descriptions.
Partial<RatingInputLabels>–
PropTypStandard
id, rating, author, createdAtRequired fields. createdAt is an ISO date.
string, number, string, string–
title, bodyOptional, so rating-only and photo-only reviews work.
string–
verified, variant, fitVerified buyer badge, the variant bought, and runs small, true to size or runs large.
boolean, string, -1 | 0 | 1–
photosCustomer photos.
{ id; src; thumbnail?; alt? }[]–
helpful, reply, attributesVote counts, the merchant reply and per-feature ratings from 1 to 5.
{ up; down }, { author; body; createdAt? }, Record<string, number>–
TasteAktion
TabMoves through histogram bars, toolbar, reviews, photos and votes.
EnterSpaceApplies or clears a rating filter, toggles With photos or a vote.
ArrowLeftArrowRightChooses a star in the rating input, or moves between photos in the lightbox.
EscapeClears the review search, or closes the lightbox and the review form.
  • Stars are one image named “Rated 4.6 out of 5”, not five separate icons. The rating input is a radio group whose stars read “4 stars, Very good”.
  • Histogram bars are toggle buttons with aria-pressed and names like “5 stars, 812 reviews, 63%”. A polite live region announces how many reviews match after each filter change.
  • Votes are toggle buttons that read their count. A failed vote is announced as an alert and the count returns to what it was.
  • Revealing a review from the lightbox and loading more reviews move focus to the review, so keyboard users land where the content is. Every transition has a reduced-motion version, and selection never relies on color alone.

Gebaut mit

Die kostenlosen HextaUI-Komponenten, aus denen Reviews besteht. Jede lässt sich einzeln installieren.

Code

10 Dateien, hinzugefügt zu components/blocks/reviews.