10.1k
New Components: Field, Input Group, Item and more

Building Blocks for the Web

Clean, modern building blocks. Copy and paste into your apps. Works with all Vue frameworks. Open Source. Free forever.

Files
components/ReviewCards.vue
<script setup lang="ts">
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import { Rating } from '@/components/ui/rating'

interface ReviewItem {
  id: string
  author: string
  avatar?: string
  rating: number
  content: string
}

interface RatingCount {
  rating: number
  count: number
}

const props = withDefaults(
  defineProps<{
    average?: number
    totalCount?: number
    counts?: RatingCount[]
    reviews?: ReviewItem[]
  }>(),
  {
    average: 4.3,
    totalCount: 1624,
    counts: () => [
      { rating: 5, count: 1019 },
      { rating: 4, count: 162 },
      { rating: 3, count: 97 },
      { rating: 2, count: 199 },
      { rating: 1, count: 147 },
    ],
    reviews: () => [
      {
        id: '1',
        rating: 5,
        author: 'Emily Selman',
        content: 'This jacket is incredible. Kept me warm and dry through a full week of backcountry skiing in the Dolomites. The vents are a game-changer for uphill sections — I never overheated once.',
      },
      {
        id: '2',
        rating: 5,
        author: 'Hector Gibbons',
        content: 'Best ski jacket I\'ve ever owned. The build quality is outstanding — taped seams, solid zippers, and the articulated fit lets me move freely on every run. Worth every penny for serious riders.',
      },
      {
        id: '3',
        rating: 4,
        author: 'Mark Edwards',
        content: 'Really happy with the warmth and breathability. Used it for a full season in the Alps and it performed perfectly in everything from bluebird days to heavy snow. Only wish the goggle pocket was a bit larger.',
      },
    ],
  },
)

function getInitials(name: string): string {
  return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
}

function percentage(count: number): number {
  return props.totalCount > 0 ? Math.round((count / props.totalCount) * 100) : 0
}
</script>

<template>
  <div class="mx-auto max-w-2xl px-4 py-16 sm:px-6 sm:py-24 lg:grid lg:max-w-7xl lg:grid-cols-12 lg:gap-x-8 lg:px-8 lg:py-32">
    <!-- Left: Summary -->
    <div class="lg:col-span-4">
      <h2 class="text-2xl font-bold tracking-tight">Customer Reviews</h2>

      <!-- Average rating -->
      <div class="mt-3 flex items-center">
        <Rating :rating="average" :max="5" />
        <p class="ml-2 text-sm">Based on {{ totalCount }} reviews</p>
      </div>

      <!-- Rating distribution bars -->
      <div class="mt-6">
        <h3 class="sr-only">Review data</h3>
        <dl class="space-y-3">
          <div v-for="count in counts" :key="count.rating" class="flex items-center text-sm">
            <dt class="flex flex-1 items-center">
              <p class="w-3 font-medium">{{ count.rating }}<span class="sr-only"> star reviews</span></p>
              <Rating :rating="count.count > 0 ? 1 : 0" :max="1" class="ml-1 shrink-0" />
              <Progress :model-value="percentage(count.count)" class="ml-3" />
            </dt>
            <dd class="ml-3 w-10 text-right text-sm tabular-nums">{{ percentage(count.count) }}%</dd>
          </div>
        </dl>
      </div>

      <!-- Write a review CTA -->
      <div class="mt-10">
        <h3 class="text-lg font-medium">Share your thoughts</h3>
        <p class="mt-1 text-sm text-muted-foreground">If you've used this product, share your thoughts with other customers</p>
        <Button variant="outline" class="mt-6 w-full lg:w-full sm:w-auto">
          Write a review
        </Button>
      </div>
    </div>

    <!-- Right: Featured reviews -->
    <div class="mt-16 lg:col-span-7 lg:col-start-6 lg:mt-0">
      <h3 class="sr-only">Recent reviews</h3>
      <div class="-my-12 divide-y">
        <div v-for="review in reviews" :key="review.id" class="py-12">
          <div class="flex items-center">
            <Avatar class="size-12">
              <AvatarImage v-if="review.avatar" :src="review.avatar" :alt="review.author" />
              <AvatarFallback>{{ getInitials(review.author) }}</AvatarFallback>
            </Avatar>
            <div class="ml-4">
              <h4 class="text-sm font-bold">{{ review.author }}</h4>
              <Rating :rating="review.rating" :max="5" class="mt-1" />
            </div>
          </div>
          <p class="mt-4 text-sm/6 text-muted-foreground italic">{{ review.content }}</p>
        </div>
      </div>
    </div>
  </div>
</template>
Review section with rating summary, distribution bars, and individual reviews.
review-cards
Files
components/ReviewList.vue
<script setup lang="ts">
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Rating } from '@/components/ui/rating'

withDefaults(
  defineProps<{
    title?: string
    reviews?: ReviewItem[]
  }>(),
  {
    title: 'Customer Reviews',
    reviews: () => mockReviews,
  },
)

function getInitials(name: string): string {
  return name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
}

function formatDate(dateStr: string): string {
  try {
    return new Date(dateStr).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
  }
  catch {
    return dateStr
  }
}
</script>

<script lang="ts">
interface ReviewItem {
  id: string
  author: string
  avatar?: string
  date: string
  rating: number
  content: string
}

const mockReviews: ReviewItem[] = [
  {
    id: '1',
    author: 'Emily Selman',
    date: '2025-01-16',
    rating: 5,
    content: 'Absolutely love this jacket. Kept me warm and dry during a full day of backcountry riding in heavy snow. The hood fits perfectly over my helmet and the vents work great for uphill sections.',
  },
  {
    id: '2',
    author: 'Hector Gibbons',
    date: '2025-01-12',
    rating: 5,
    content: 'Best ski jacket I\'ve owned. The build quality is outstanding — taped seams, solid zippers, and the fit allows full range of motion. Worth every penny for serious riders.',
  },
  {
    id: '3',
    author: 'Mark Edwards',
    date: '2025-01-06',
    rating: 4,
    content: 'Really happy with the warmth and breathability. Used it for a week in the Alps and it performed perfectly. Only wish the pockets were a bit larger for goggles.',
  },
]
</script>

<template>
  <div class="mx-auto max-w-2xl px-4 py-16 sm:px-6 sm:py-24 lg:max-w-7xl lg:px-8">
    <h2 class="sr-only">{{ title }}</h2>

    <div class="-my-10">
      <div
        v-for="(review, index) in reviews"
        :key="review.id"
        class="flex gap-4 text-sm"
      >
        <!-- Avatar -->
        <div class="shrink-0 py-10">
          <Avatar class="size-10">
            <AvatarImage v-if="review.avatar" :src="review.avatar" :alt="review.author" />
            <AvatarFallback>{{ getInitials(review.author) }}</AvatarFallback>
          </Avatar>
        </div>

        <!-- Content -->
        <div
          class="flex-1 py-10"
          :class="index > 0 && 'border-t'"
        >
          <h3 class="font-medium">{{ review.author }}</h3>
          <p class="text-muted-foreground">
            <time :datetime="review.date">{{ formatDate(review.date) }}</time>
          </p>

          <Rating :rating="review.rating" :max="5" class="mt-4" />

          <p class="mt-4 text-sm/6 text-muted-foreground">{{ review.content }}</p>
        </div>
      </div>
    </div>
  </div>
</template>
Simple review list with avatars, star ratings, author, date, and review text.
review-list