Skip to content

Rated testimonials

Star ratings announced as text rather than as a row of filled paths.

Marketingstartertestimonialsratingstarsreviews

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

This exact file renders the preview above.

import { Star } from 'lucide-react'
import { Container } from '@/components/ui/layout'
import { Avatar } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { testimonials } from '@/content/demo'

/**
 * Rated testimonials
 *
 * Star ratings paired with quotes. The stars are decorative; the rating is
 * announced as text ("Rated 5 out of 5"), because a row of filled paths tells
 * a screen reader nothing.
 */
function Rating({ value }: { value: number }) {
  return (
    <span className="flex items-center gap-0.5">
      <span className="sr-only">Rated {value} out of 5</span>
      {[1, 2, 3, 4, 5].map((star) => (
        <Star
          key={star}
          className={
            star <= value ? 'size-3.5 fill-warning text-warning' : 'size-3.5 text-line-strong'
          }
          aria-hidden="true"
        />
      ))}
    </span>
  )
}

export default function RatedTestimonials() {
  const rated = testimonials
    .slice(0, 4)
    .map((quote, index) => ({ ...quote, rating: index === 3 ? 4 : 5 }))

  return (
    <section className="border-b border-line bg-canvas py-section">
      <Container>
        <div className="flex flex-wrap items-end justify-between gap-4">
          <div className="max-w-xl">
            <h2 className="display-type text-2xl font-semibold text-ink-strong sm:text-3xl">
              Rated by the teams using it.
            </h2>
          </div>
          <div className="flex items-center gap-3">
            <Rating value={5} />
            <Badge tone="success">4.9 average · 240 reviews</Badge>
          </div>
        </div>

        <ul className="mt-10 grid gap-4 sm:grid-cols-2">
          {rated.map((quote) => (
            <li key={quote.name}>
              <figure className="flex h-full flex-col rounded-xl border border-line bg-surface p-5">
                <Rating value={quote.rating} />
                <blockquote className="mt-3 flex-1 text-sm leading-relaxed text-ink">
                  “{quote.quote}”
                </blockquote>
                <figcaption className="mt-5 flex items-center gap-2.5">
                  <Avatar name={quote.name} size="sm" decorative />
                  <div className="min-w-0">
                    <p className="truncate text-xs font-semibold text-ink-strong">{quote.name}</p>
                    <p className="truncate text-xs text-ink-muted">
                      {quote.role}, {quote.company}
                    </p>
                  </div>
                </figcaption>
              </figure>
            </li>
          ))}
        </ul>
      </Container>
    </section>
  )
}

components/blocks/sections/testimonials/with-rating.tsx

import { Star } from 'lucide-react'
import { Container } from '@/components/ui/layout'
import { Avatar } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { testimonials } from '@/content/demo'

/**
 * Rated testimonials
 *
 * Star ratings paired with quotes. The stars are decorative; the rating is
 * announced as text ("Rated 5 out of 5"), because a row of filled paths tells
 * a screen reader nothing.
 */
function Rating({ value }: { value: number }) {
  return (
    <span className="flex items-center gap-0.5">
      <span className="sr-only">Rated {value} out of 5</span>
      {[1, 2, 3, 4, 5].map((star) => (
        <Star
          key={star}
          className={
            star <= value ? 'size-3.5 fill-warning text-warning' : 'size-3.5 text-line-strong'
          }
          aria-hidden="true"
        />
      ))}
    </span>
  )
}

export default function RatedTestimonials() {
  const rated = testimonials
    .slice(0, 4)
    .map((quote, index) => ({ ...quote, rating: index === 3 ? 4 : 5 }))

  return (
    <section className="border-b border-line bg-canvas py-section">
      <Container>
        <div className="flex flex-wrap items-end justify-between gap-4">
          <div className="max-w-xl">
            <h2 className="display-type text-2xl font-semibold text-ink-strong sm:text-3xl">
              Rated by the teams using it.
            </h2>
          </div>
          <div className="flex items-center gap-3">
            <Rating value={5} />
            <Badge tone="success">4.9 average · 240 reviews</Badge>
          </div>
        </div>

        <ul className="mt-10 grid gap-4 sm:grid-cols-2">
          {rated.map((quote) => (
            <li key={quote.name}>
              <figure className="flex h-full flex-col rounded-xl border border-line bg-surface p-5">
                <Rating value={quote.rating} />
                <blockquote className="mt-3 flex-1 text-sm leading-relaxed text-ink">
                  “{quote.quote}”
                </blockquote>
                <figcaption className="mt-5 flex items-center gap-2.5">
                  <Avatar name={quote.name} size="sm" decorative />
                  <div className="min-w-0">
                    <p className="truncate text-xs font-semibold text-ink-strong">{quote.name}</p>
                    <p className="truncate text-xs text-ink-muted">
                      {quote.role}, {quote.company}
                    </p>
                  </div>
                </figcaption>
              </figure>
            </li>
          ))}
        </ul>
      </Container>
    </section>
  )
}

components/ui/badge.tsx

import type { HTMLAttributes, ReactNode } from 'react'
import { variants } from '@/lib/variants'

/**
 * Badge
 *
 * A compact, non-interactive label. Tones map to the status token trios, and
 * because a badge is often the only signal in a dense table, the `dot` option
 * exists to add a second, colour-independent cue alongside the text.
 */
const badgeVariants = variants('inline-flex items-center gap-1.5 whitespace-nowrap font-medium', {
  variants: {
    tone: {
      neutral: 'bg-surface-sunken text-ink-muted border-line',
      accent: 'bg-accent-soft text-accent-soft-ink border-accent-line',
      success: 'bg-success-soft text-success border-success-line',
      warning: 'bg-warning-soft text-warning border-warning-line',
      danger: 'bg-danger-soft text-danger border-danger-line',
      info: 'bg-info-soft text-info border-info-line',
      inverse: 'bg-surface-inverse text-ink-inverse border-transparent',
    },
    appearance: {
      soft: 'border',
      outline: 'border bg-transparent',
      solid: 'border border-transparent',
    },
    size: {
      sm: 'h-4.5 rounded-sm px-1.5 text-2xs',
      md: 'h-5.5 rounded-sm px-2 text-xs',
    },
  },
  defaultVariants: { tone: 'neutral', appearance: 'soft', size: 'md' },
  compound: [
    { appearance: 'solid', tone: 'accent', class: 'bg-accent text-accent-ink' },
    { appearance: 'solid', tone: 'success', class: 'bg-success text-white' },
    { appearance: 'solid', tone: 'warning', class: 'bg-warning text-white' },
    { appearance: 'solid', tone: 'danger', class: 'bg-danger text-white' },
    { appearance: 'solid', tone: 'info', class: 'bg-info text-white' },
    { appearance: 'solid', tone: 'neutral', class: 'bg-ink text-ink-inverse' },
    { appearance: 'outline', tone: 'neutral', class: 'text-ink-muted' },
  ],
})

export type BadgeTone = 'neutral' | 'accent' | 'success' | 'warning' | 'danger' | 'info' | 'inverse'

export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
  tone?: BadgeTone
  appearance?: 'soft' | 'outline' | 'solid'
  size?: 'sm' | 'md'
  /** Adds a leading dot so the badge does not rely on hue alone. */
  dot?: boolean
  icon?: ReactNode
}

export function Badge({
  tone = 'neutral',
  appearance = 'soft',
  size = 'md',
  dot = false,
  icon,
  className,
  children,
  ...props
}: BadgeProps) {
  return (
    <span className={badgeVariants({ tone, appearance, size, className })} {...props}>
      {dot ? (
        <span className="size-1.5 shrink-0 rounded-full bg-current" aria-hidden="true" />
      ) : null}
      {icon}
      {children}
    </span>
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

The stars are decorative; the rating is announced as 'Rated 5 out of 5', because a row of filled paths tells a screen reader nothing.

  • Include at least one four-star rating. A wall of fives reads as fabricated.
  • Show the review count next to the aggregate, or the average is meaningless.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Aggregate rating
  • Per-quote rating
  • Two columns

Accessibility

Rating in text
Every rating carries a visually hidden sentence.
Decorative stars
Star icons are aria-hidden.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.