Abstract image
Rotating rated quotes beside a collage of five customer photos.
Last updated
---
/**
* Abstract image: Rotating rated quotes beside a collage of five customer photos.
*
* @block testimonials
* @order 26
*/
import Section from '@/components/apogee/Section/Section.astro'
import Avatar from '@/components/apogee/Avatar/Avatar.astro'
import Rating from '@/components/apogee/Rating/Rating.astro'
import Carousel from '@/components/apogee/Carousel/Carousel.astro'
import Image from '@/components/apogee/Image/Image.astro'
import type { Media } from '@/components/apogee/sections/_shared/spine.ts'
import type { TestimonialsContent } from '@/components/apogee/sections/_shared/testimonials.ts'
/** @composable */
interface Props extends TestimonialsContent {
/** Up to five customer photos for the collage (decorative, hidden from screen readers). @category content */
images?: Media[]
}
const photo = (id: number) => ({ src: `https://picsum.photos/id/${id}/600/600`, alt: '', width: 600, height: 600 })
const {
label = 'Testimonials',
items = [
{ quote: 'We shipped our redesign in three weeks instead of three months.', name: 'Amara Nwosu', role: 'Head of Operations, Lumen', avatar: 'https://picsum.photos/id/1027/192/192', rating: 5 },
{ quote: 'Every page we build now looks like it belongs together.', name: 'Daniel Reyes', role: 'Engineering Lead, Quanta', avatar: 'https://picsum.photos/id/91/192/192', rating: 5 },
{ quote: 'Support answered in minutes, and the fix was live the same afternoon.', name: 'Sofia Marchetti', role: 'Product Designer, Fieldwork', avatar: 'https://picsum.photos/id/832/192/192', rating: 5 },
],
images = [photo(1027), photo(91), photo(832), photo(856), photo(823)],
} = Astro.props
// The collage layout: one grid cell per photo, in order.
const cells = ['row-start-3 col-start-3 row-end-7 col-end-7', 'row-start-1 col-start-7 row-end-7 col-end-11', 'row-start-7 col-start-1 row-end-10 col-end-5', 'row-start-7 col-start-5 row-end-13 col-end-9', 'row-start-7 col-start-9 row-end-10 col-end-13']
---
<Section spacing="large" class="overflow-hidden">
<div class="grid items-center gap-12 lg:grid-cols-2 lg:gap-16">
<div class="min-w-0">
<Carousel label={label} visibleItems={{ base: 1 }} controlsPosition="below">
{items.map((t) => (
<figure class="flex flex-col gap-8">
{t.rating != null && <Rating value={t.rating} readonly size="small" color="warning" aria-label={`Rated ${t.rating} out of 5`} />}
<blockquote class="text-2xl font-medium tracking-tight text-balance md:text-4xl"><p>“{t.quote}”</p></blockquote>
<figcaption class="flex items-center gap-3"><Avatar src={t.avatar} name={t.name} alt="" size="xlarge" border /><span class="flex flex-col"><span class="flex items-center gap-1 font-semibold">{t.name}</span>{t.role && <span class="text-sm text-neutral-faded">{t.role}</span>}</span></figcaption>
</figure>
))}
</Carousel>
</div>
<div class="-mx-[25%] overflow-hidden sm:-mx-[10%] md:mx-0">
<div class="grid h-122 grid-cols-12 grid-rows-12 gap-2 sm:h-124 md:gap-4" aria-hidden="true">
{images.slice(0, cells.length).map((m, i) => <Image src={m.src} alt="" width={m.width} height={m.height} fit="cover" class={`size-full ${cells[i]}`} />)}
</div>
</div>
</div>
</Section>
Show codeHide code
---
/**
* Abstract image: Rotating rated quotes beside a collage of five customer photos.
*
* @block testimonials
* @order 26
*/
import Section from '@/components/apogee/Section/Section.astro'
import Avatar from '@/components/apogee/Avatar/Avatar.astro'
import Rating from '@/components/apogee/Rating/Rating.astro'
import Carousel from '@/components/apogee/Carousel/Carousel.astro'
import Image from '@/components/apogee/Image/Image.astro'
import type { Media } from '@/components/apogee/sections/_shared/spine.ts'
import type { TestimonialsContent } from '@/components/apogee/sections/_shared/testimonials.ts'
/** @composable */
interface Props extends TestimonialsContent {
/** Up to five customer photos for the collage (decorative, hidden from screen readers). @category content */
images?: Media[]
}
const photo = (id: number) => ({ src: `https://picsum.photos/id/${id}/600/600`, alt: '', width: 600, height: 600 })
const {
label = 'Testimonials',
items = [
{ quote: 'We shipped our redesign in three weeks instead of three months.', name: 'Amara Nwosu', role: 'Head of Operations, Lumen', avatar: 'https://picsum.photos/id/1027/192/192', rating: 5 },
{ quote: 'Every page we build now looks like it belongs together.', name: 'Daniel Reyes', role: 'Engineering Lead, Quanta', avatar: 'https://picsum.photos/id/91/192/192', rating: 5 },
{ quote: 'Support answered in minutes, and the fix was live the same afternoon.', name: 'Sofia Marchetti', role: 'Product Designer, Fieldwork', avatar: 'https://picsum.photos/id/832/192/192', rating: 5 },
],
images = [photo(1027), photo(91), photo(832), photo(856), photo(823)],
} = Astro.props
// The collage layout: one grid cell per photo, in order.
const cells = ['row-start-3 col-start-3 row-end-7 col-end-7', 'row-start-1 col-start-7 row-end-7 col-end-11', 'row-start-7 col-start-1 row-end-10 col-end-5', 'row-start-7 col-start-5 row-end-13 col-end-9', 'row-start-7 col-start-9 row-end-10 col-end-13']
---
<Section spacing="large" class="overflow-hidden">
<div class="grid items-center gap-12 lg:grid-cols-2 lg:gap-16">
<div class="min-w-0">
<Carousel label={label} visibleItems={{ base: 1 }} controlsPosition="below">
{items.map((t) => (
<figure class="flex flex-col gap-8">
{t.rating != null && <Rating value={t.rating} readonly size="small" color="warning" aria-label={`Rated ${t.rating} out of 5`} />}
<blockquote class="text-2xl font-medium tracking-tight text-balance md:text-4xl"><p>“{t.quote}”</p></blockquote>
<figcaption class="flex items-center gap-3"><Avatar src={t.avatar} name={t.name} alt="" size="xlarge" border /><span class="flex flex-col"><span class="flex items-center gap-1 font-semibold">{t.name}</span>{t.role && <span class="text-sm text-neutral-faded">{t.role}</span>}</span></figcaption>
</figure>
))}
</Carousel>
</div>
<div class="-mx-[25%] overflow-hidden sm:-mx-[10%] md:mx-0">
<div class="grid h-122 grid-cols-12 grid-rows-12 gap-2 sm:h-124 md:gap-4" aria-hidden="true">
{images.slice(0, cells.length).map((m, i) => <Image src={m.src} alt="" width={m.width} height={m.height} fit="cover" class={`size-full ${cells[i]}`} />)}
</div>
</div>
</div>
</Section>Add
npx @ava-ui/apogee add testimonials/abstract-image
This copies the block to src/components/apogee/blocks/testimonials/ with everything it uses: Avatar, Carousel, Image, Rating, Section. Every piece of copy is a prop, with the sample copy as its default. Pass yours, or null to hide a part; see copy is props.
Accessibility
- The stars are read as one rating (“Rated 5 out of 5”).
- The carousel is a named region; its arrows are buttons and it scrolls by swipe or keyboard too.
- Photos that only decorate have an empty
alt; give yours one when it carries meaning.
Related
TestimonialsOne featured quote, or a grid of quote cards.
CarouselA horizontal scroll-snap track for browsing slides. Native scroll and swipe work with zero JS; arrows, dots, and autoplay are progressive enhancement.
RatingA star rating on native radios — pick a value on a scale, or display one read-only.