---
name: review-cards
title: Review Cards
url: https://ui.frontic.com/components/sections/review-cards.md
tier: sections
install: npx @frontic/ui add review-cards
files:
  - components/sections/review-cards/ReviewCards.vue
  - components/sections/review-cards/index.ts
  - components/sections/review-cards/types.ts
registryDependencies:
  - avatar
  - badge
  - button
  - format
  - progress
  - rating
  - separator
  - text
dependencies:
  - "@lucide/vue"
  - reka-ui
---

# Review Cards

Review section with the average score, distribution bars, a write-a-review prompt, and dated reviews marked when the purchase is verified.

## Install

```sh
npx @frontic/ui add review-cards
```

Built from: [Avatar](https://ui.frontic.com/components/ui/avatar.md), [Badge](https://ui.frontic.com/components/ui/badge.md), [Button](https://ui.frontic.com/components/ui/button.md), [Format](https://ui.frontic.com/lib/format.md), [Progress](https://ui.frontic.com/components/ui/progress.md), [Rating](https://ui.frontic.com/components/ui/rating.md), [Separator](https://ui.frontic.com/components/ui/separator.md), [Text](https://ui.frontic.com/lib/text.md)

npm packages: `@lucide/vue`, `reka-ui`

## Examples

```vue
<script setup lang="ts">
import { ReviewCards, type RatingCount, type ReviewItem } from '@/components/sections/review-cards'

const avatar = (n: number) => `https://static.frontic.com/cdn-cgi/image/width=128,format=auto/ui/avatars/${n}.jpg`

// Reviews of the Frost Escape Parka.
const counts: RatingCount[] = [
  { rating: 5, count: 151 },
  { rating: 4, count: 38 },
  { rating: 3, count: 12 },
  { rating: 2, count: 7 },
  { rating: 1, count: 4 },
]

const reviews: ReviewItem[] = [
  {
    id: '1',
    author: 'Marta K.',
    avatar: avatar(8),
    date: '2026-10-04',
    rating: 5,
    verified: true,
    content: 'A week of −15 °C and a biting wind on the slopes, and I stayed warm. The hood fits over a helmet.',
  },
  {
    id: '2',
    author: 'Jonas B.',
    avatar: avatar(3),
    date: '2026-09-28',
    rating: 4,
    verified: true,
    content: 'Warm without the bulk. Runs a little long in the arms, so I went one size down.',
  },
  {
    id: '3',
    author: 'Lea S.',
    avatar: avatar(9),
    date: '2026-09-17',
    rating: 5,
    content: 'My daily coat from November to March. Rain beads straight off it, and the inner pockets hold a phone and gloves.',
  },
]
</script>

<template>
  <ReviewCards :average="4.6" :total-count="212" :counts="counts" :reviews="reviews" />
</template>
```

## API reference

### ReviewCards

Shows the average rating with its breakdown, review cards and a write-a-review prompt. Renders a `<div>`.

`data-slot="review-cards"` · `ReviewCards.vue`

Styling: `review-cards-average`, which extends [`page-title`](https://ui.frontic.com/DESIGN.md#page-title); `review-cards-content`, which extends [`copy-muted`](https://ui.frontic.com/DESIGN.md#copy-muted); `review-cards-heading`, which extends [`heading`](https://ui.frontic.com/DESIGN.md#heading); [`part-title-lg`](https://ui.frontic.com/DESIGN.md#part-title-lg); [`hint`](https://ui.frontic.com/DESIGN.md#hint); [`meta`](https://ui.frontic.com/DESIGN.md#meta). Change them there and every part that extends them follows.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `average` | `number` | `0` | The average rating out of 5, shown large with one decimal. |
| `totalCount` | `number` | `0` | How many reviews there are in all; the rating shares are taken from it. |
| `counts` | `RatingCount[]` | `[]` | How many reviews gave each star rating, shown as bars in the order given. |
| `reviews` | `ReviewItem[]` | `[]` | The reviews listed beside the summary. |
| `title` | `string` | `'Customer Reviews'` | Shown as the section's heading. |
| `summaryLabel` | `((count: number) => string)`: `(count: number): string` | ``Based on ${count} ${count === 1 ? 'review' : 'reviews'}`` | Shown beside the average, saying how many reviews it is based on. |
| `distributionLabel` | `string` | `'Review data'` | Screen-reader heading of the rating bars. |
| `starReviewsLabel` | `string` | `'star reviews'` | Read after the star number of each rating bar, e.g. `5 star reviews`. |
| `shareTitle` | `string` | `'Share your thoughts'` | Heading above the write-a-review button. |
| `shareText` | `string` | `'If you've used this product, share your thoughts with other customers'` | Shown under the share heading, inviting the shopper to write a review. |
| `writeReviewLabel` | `string` | `'Write a review'` | Shown on the button that starts a new review. |
| `reviewsLabel` | `string` | `'Recent reviews'` | Screen-reader heading of the review list. |
| `verifiedLabel` | `string` | `'Verified purchase'` | Shown on the badge of a review from a verified purchase. |
| `locale` | `string` | `'en-US'` | BCP 47 locale the average and the review dates are formatted in; the ConfigProvider's when left out. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |

| Event | Payload | Description |
| --- | --- | --- |
| `write-review` |  | Fires when the shopper clicks the write-a-review button. |

## Exports

```ts
import { type ReviewItem, type RatingCount } from '@/components/sections/review-cards'
```

- `ReviewItem`: below
- `RatingCount`: below

```ts
interface ReviewItem {
  id: string
  author: string
  avatar?: string
  rating: number
  content: string
  /** ISO date, shown in the item's locale. */
  date: string
  verified?: boolean
}

interface RatingCount {
  rating: number
  count: number
}
```

## Styling

| Part | Recipe |
| --- | --- |
| `default` | [`review-cards`](https://ui.frontic.com/DESIGN.md#review-cards) |
| `heading` | [`review-cards-heading`](https://ui.frontic.com/DESIGN.md#review-cards-heading) |
| `average` | [`review-cards-average`](https://ui.frontic.com/DESIGN.md#review-cards-average) |
| `summary` | [`review-cards-summary`](https://ui.frontic.com/DESIGN.md#review-cards-summary) |
| `star` | [`review-cards-star`](https://ui.frontic.com/DESIGN.md#review-cards-star) |
| `percent` | [`review-cards-percent`](https://ui.frontic.com/DESIGN.md#review-cards-percent) |
| `author` | [`review-cards-author`](https://ui.frontic.com/DESIGN.md#review-cards-author) |
| `content` | [`review-cards-content`](https://ui.frontic.com/DESIGN.md#review-cards-content) |
| `part-title-lg` | [`part-title-lg`](https://ui.frontic.com/DESIGN.md#part-title-lg) |
| `hint` | [`hint`](https://ui.frontic.com/DESIGN.md#hint) |
| `meta` | [`meta`](https://ui.frontic.com/DESIGN.md#meta) |

## Source

`components/sections/review-cards/ReviewCards.vue`

```vue
<script lang="ts">
/** Shows the average rating with its breakdown, review cards and a write-a-review prompt. */
export default {}
</script>

<script setup lang="ts">
import { CircleCheckIcon } from '@lucide/vue'

import type { HTMLAttributes } from 'vue'
import type { RatingCount, ReviewItem } from './types'
import { computed, toRef } from 'vue'
import { Avatar, AvatarFallback, AvatarImage } from '@library/components/ui/avatar'
import { Badge } from '@library/components/ui/badge'
import { Button } from '@library/components/ui/button'
import { Progress } from '@library/components/ui/progress'
import { Rating } from '@library/components/ui/rating'
import { Separator } from '@library/components/ui/separator'
import { formatDate, formatRating, percentOf } from '@library/lib/format'
import { useLocale } from 'reka-ui'
import { initials } from '@library/lib/text'

const props = withDefaults(
  defineProps<{
    /** The average rating out of 5, shown large with one decimal. */
    average?: number
    /** How many reviews there are in all; the rating shares are taken from it. */
    totalCount?: number
    /** How many reviews gave each star rating, shown as bars in the order given. */
    counts?: RatingCount[]
    /** The reviews listed beside the summary. */
    reviews?: ReviewItem[]
    /** Shown as the section's heading. */
    title?: string
    /** Shown beside the average, saying how many reviews it is based on. */
    summaryLabel?: (count: number) => string
    /** Screen-reader heading of the rating bars. */
    distributionLabel?: string
    /** Read after the star number of each rating bar, e.g. `5 star reviews`. */
    starReviewsLabel?: string
    /** Heading above the write-a-review button. */
    shareTitle?: string
    /** Shown under the share heading, inviting the shopper to write a review. */
    shareText?: string
    /** Shown on the button that starts a new review. */
    writeReviewLabel?: string
    /** Screen-reader heading of the review list. */
    reviewsLabel?: string
    /** Shown on the badge of a review from a verified purchase. */
    verifiedLabel?: string
    /** BCP 47 locale the average and the review dates are formatted in; the ConfigProvider's when left out. */
    locale?: string
    class?: HTMLAttributes['class']
  }>(),
  {
    average: 0,
    totalCount: 0,
    counts: () => [],
    reviews: () => [],
    title: 'Customer Reviews',
    summaryLabel: (count: number) => `Based on ${count} ${count === 1 ? 'review' : 'reviews'}`,
    distributionLabel: 'Review data',
    starReviewsLabel: 'star reviews',
    shareTitle: 'Share your thoughts',
    shareText: "If you've used this product, share your thoughts with other customers",
    writeReviewLabel: 'Write a review',
    reviewsLabel: 'Recent reviews',
    verifiedLabel: 'Verified purchase',
    locale: 'en-US',
  },
)
const locale = useLocale(toRef(() => props.locale))
const emit = defineEmits<{
  /** Fires when the shopper clicks the write-a-review button. */
  'write-review': []
}>()

const averageText = computed(() => formatRating(props.average, locale.value))

const dateText = (date: string) => formatDate(date, { year: 'numeric', month: 'short', day: 'numeric' }, locale.value)

const percentage = (count: number) => percentOf(count, props.totalCount)
</script>

<template>
  <div data-slot="review-cards" :class="['fui-review-cards @container', props.class]">
    <div class="mx-auto max-w-2xl px-4 py-12 @2xl:px-6 @2xl:py-16 @5xl:grid @5xl:max-w-7xl @5xl:grid-cols-12 @5xl:gap-x-8 @5xl:px-8">
      <div class="@5xl:col-span-4">
        <h2 data-slot="review-cards-heading" class="fui-review-cards-heading">
          {{ title }}
        </h2>

        <div class="mt-4 flex items-center gap-4">
          <span data-slot="review-cards-average" class="fui-review-cards-average tabular-nums">{{ averageText }}</span>
          <p data-slot="review-cards-summary" class="fui-review-cards-summary">
            {{ summaryLabel(totalCount) }}
          </p>
        </div>

        <div class="mt-6">
          <h3 class="sr-only">{{ distributionLabel }}</h3>
          <dl class="flex flex-col gap-2">
            <div v-for="count in counts" :key="count.rating" class="flex items-center gap-3">
              <dt class="flex flex-1 items-center gap-3">
                <span data-slot="review-cards-star" class="fui-review-cards-star shrink-0 tabular-nums">
                  {{ count.rating }}<span class="sr-only"> {{ starReviewsLabel }}</span>
                </span>
                <Progress :model-value="percentage(count.count)" :aria-label="`${count.rating} ${starReviewsLabel}`" />
              </dt>
              <dd data-slot="review-cards-percent" class="fui-review-cards-percent text-end tabular-nums">{{ percentage(count.count) }}%</dd>
            </div>
          </dl>
        </div>

        <div class="mt-10">
          <div class="flex flex-col gap-1">
            <h3 data-slot="part-title-lg" class="fui-part-title-lg">
              {{ shareTitle }}
            </h3>
            <p data-slot="hint" class="fui-hint">{{ shareText }}</p>
          </div>
          <Button variant="outline" color="neutral" class="mt-4 w-full @2xl:w-auto @5xl:w-full" @click="emit('write-review')">{{ writeReviewLabel }}</Button>
        </div>
      </div>

      <div class="mt-12 @5xl:col-span-7 @5xl:col-start-6 @5xl:mt-0">
        <h3 class="sr-only">{{ reviewsLabel }}</h3>
        <div class="-my-8">
          <template v-for="(review, index) in reviews" :key="review.id">
            <Separator v-if="index > 0" />
            <div class="py-8">
              <div class="flex items-center gap-4">
                <Avatar>
                  <AvatarImage v-if="review.avatar" :src="review.avatar" :alt="review.author" />
                  <AvatarFallback>{{ initials(review.author) }}</AvatarFallback>
                </Avatar>
                <div class="flex min-w-0 flex-1 flex-col gap-1">
                  <div class="flex flex-wrap items-baseline justify-between gap-x-3">
                    <h4 data-slot="review-cards-author" class="fui-review-cards-author">
                      {{ review.author }}
                    </h4>
                    <time data-slot="meta" class="fui-meta" :datetime="review.date">{{ dateText(review.date) }}</time>
                  </div>
                  <div class="flex flex-wrap items-center gap-2">
                    <Rating :rating="review.rating" :max="5" size="sm" />
                    <Badge v-if="review.verified" variant="success" size="sm">
                      <CircleCheckIcon data-icon />
                      {{ verifiedLabel }}
                    </Badge>
                  </div>
                </div>
              </div>
              <p data-slot="review-cards-content" class="fui-review-cards-content">
                {{ review.content }}
              </p>
            </div>
          </template>
        </div>
      </div>
    </div>
  </div>
</template>
```

`components/sections/review-cards/types.ts`

```ts
export interface ReviewItem {
  id: string
  author: string
  avatar?: string
  rating: number
  content: string
  /** ISO date, shown in the item's locale. */
  date: string
  verified?: boolean
}

export interface RatingCount {
  rating: number
  count: number
}
```
