Testimonial carousel
A manual carousel with no autoplay, announced through a live region.
Marketingintermediatetestimonialscarouselmanuallive-region
Live preview
full widthLive preview — open it in a new tab for the full-height version.
Source
This exact file renders the preview above.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
'use client'
import { useState } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { Container } from '@/components/ui/layout'
import { Avatar } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/cn'
import { testimonials } from '@/content/demo'
/**
* Testimonial carousel
*
* A manual carousel — no autoplay. Auto-advancing content fails WCAG 2.2.2
* unless it can be paused, and a quote that moves while you are reading it is
* hostile regardless of the guideline.
*
* The live region announces the position, and the dot controls are real
* buttons with names rather than decorated spans.
*/
export default function TestimonialCarousel() {
const [index, setIndex] = useState(0)
const quote = testimonials[index]
const go = (next: number) => setIndex((next + testimonials.length) % testimonials.length)
return (
<section className="border-b border-line bg-canvas py-section">
<Container size="narrow">
<div className="flex items-center justify-between gap-4">
<h2 className="display-type text-xl font-semibold text-ink-strong sm:text-2xl">
From the people using it
</h2>
<div className="flex items-center gap-1.5">
<Button
variant="outline"
size="icon-sm"
onClick={() => go(index - 1)}
aria-label="Previous testimonial"
>
<ChevronLeft className="size-4" />
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={() => go(index + 1)}
aria-label="Next testimonial"
>
<ChevronRight className="size-4" />
</Button>
</div>
</div>
<div aria-live="polite" aria-atomic="true" className="mt-8">
<p className="sr-only">
Testimonial {index + 1} of {testimonials.length}
</p>
<figure className="rounded-xl border border-line bg-surface p-6 sm:p-8">
<blockquote className="text-md leading-relaxed text-ink sm:text-lg">
“{quote?.quote}”
</blockquote>
<figcaption className="mt-6 flex items-center gap-3">
<Avatar name={quote?.name ?? 'Anonymous'} size="md" decorative />
<div>
<p className="text-sm font-semibold text-ink-strong">{quote?.name}</p>
<p className="text-sm text-ink-muted">
{quote?.role}, {quote?.company}
</p>
</div>
</figcaption>
</figure>
</div>
<div className="mt-5 flex items-center justify-center gap-2">
{testimonials.map((item, dotIndex) => (
<button
key={item.name}
type="button"
onClick={() => setIndex(dotIndex)}
aria-current={dotIndex === index ? 'true' : undefined}
className={cn(
'h-1.5 rounded-full transition-all duration-200',
dotIndex === index ? 'w-6 bg-accent' : 'w-1.5 bg-line-strong hover:bg-ink-subtle',
)}
>
<span className="sr-only">Show testimonial from {item.name}</span>
</button>
))}
</div>
</Container>
</section>
)
}
components/blocks/sections/testimonials/carousel.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
'use client'
import { useState } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { Container } from '@/components/ui/layout'
import { Avatar } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/cn'
import { testimonials } from '@/content/demo'
/**
* Testimonial carousel
*
* A manual carousel — no autoplay. Auto-advancing content fails WCAG 2.2.2
* unless it can be paused, and a quote that moves while you are reading it is
* hostile regardless of the guideline.
*
* The live region announces the position, and the dot controls are real
* buttons with names rather than decorated spans.
*/
export default function TestimonialCarousel() {
const [index, setIndex] = useState(0)
const quote = testimonials[index]
const go = (next: number) => setIndex((next + testimonials.length) % testimonials.length)
return (
<section className="border-b border-line bg-canvas py-section">
<Container size="narrow">
<div className="flex items-center justify-between gap-4">
<h2 className="display-type text-xl font-semibold text-ink-strong sm:text-2xl">
From the people using it
</h2>
<div className="flex items-center gap-1.5">
<Button
variant="outline"
size="icon-sm"
onClick={() => go(index - 1)}
aria-label="Previous testimonial"
>
<ChevronLeft className="size-4" />
</Button>
<Button
variant="outline"
size="icon-sm"
onClick={() => go(index + 1)}
aria-label="Next testimonial"
>
<ChevronRight className="size-4" />
</Button>
</div>
</div>
<div aria-live="polite" aria-atomic="true" className="mt-8">
<p className="sr-only">
Testimonial {index + 1} of {testimonials.length}
</p>
<figure className="rounded-xl border border-line bg-surface p-6 sm:p-8">
<blockquote className="text-md leading-relaxed text-ink sm:text-lg">
“{quote?.quote}”
</blockquote>
<figcaption className="mt-6 flex items-center gap-3">
<Avatar name={quote?.name ?? 'Anonymous'} size="md" decorative />
<div>
<p className="text-sm font-semibold text-ink-strong">{quote?.name}</p>
<p className="text-sm text-ink-muted">
{quote?.role}, {quote?.company}
</p>
</div>
</figcaption>
</figure>
</div>
<div className="mt-5 flex items-center justify-center gap-2">
{testimonials.map((item, dotIndex) => (
<button
key={item.name}
type="button"
onClick={() => setIndex(dotIndex)}
aria-current={dotIndex === index ? 'true' : undefined}
className={cn(
'h-1.5 rounded-full transition-all duration-200',
dotIndex === index ? 'w-6 bg-accent' : 'w-1.5 bg-line-strong hover:bg-ink-subtle',
)}
>
<span className="sr-only">Show testimonial from {item.name}</span>
</button>
))}
</div>
</Container>
</section>
)
}
components/ui/button.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
import type { ButtonHTMLAttributes, ReactNode } from 'react'
import Link from 'next/link'
import { cn } from '@/lib/cn'
import { variants } from '@/lib/variants'
import { Spinner } from './spinner'
/**
* Button
*
* The whole action surface of Foundry in one component. It is intentionally a
* *shared* component (no `'use client'`): rendered from a Server Component it
* ships zero JavaScript, and it upgrades to a client island automatically when
* a Client Component imports it.
*
* Height, padding and radius resolve from density and radius tokens, so a
* button restyles itself when the density or palette axis changes. Focus is
* handled once, globally, by the `:focus-visible` rule in `globals.css`.
*/
const buttonVariants = variants(
cn(
'relative inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium',
'transition-colors duration-150 ease-standard select-none',
'disabled:pointer-events-none disabled:opacity-50',
'aria-disabled:pointer-events-none aria-disabled:opacity-50',
),
{
variants: {
variant: {
primary: 'bg-accent text-accent-ink hover:bg-accent-hover active:bg-accent-active',
secondary: 'bg-surface-inverse text-ink-inverse hover:opacity-90 active:opacity-80',
outline: 'border border-line-strong bg-surface text-ink hover:bg-surface-sunken',
ghost: 'text-ink hover:bg-surface-sunken active:bg-surface-sunken',
destructive: 'bg-danger text-white hover:opacity-90 active:opacity-80',
success: 'bg-success text-white hover:opacity-90 active:opacity-80',
warning: 'bg-warning text-white hover:opacity-90 active:opacity-80',
link: 'text-accent underline underline-offset-4 hover:text-accent-hover',
soft: 'bg-accent-soft text-accent-soft-ink border border-accent-line hover:brightness-[0.97]',
},
size: {
sm: 'h-control-sm px-3 text-xs rounded-md',
md: 'h-control px-[var(--density-control-padding-x)] text-sm rounded-md',
lg: 'h-control-lg px-5 text-base rounded-md',
icon: 'h-control w-control p-0 rounded-md',
'icon-sm': 'h-control-sm w-control-sm p-0 rounded-sm',
},
block: { true: 'w-full', false: '' },
},
defaultVariants: { variant: 'primary', size: 'md', block: false },
compound: [
{ variant: 'link', size: 'sm', class: 'h-auto px-0' },
{ variant: 'link', size: 'md', class: 'h-auto px-0' },
{ variant: 'link', size: 'lg', class: 'h-auto px-0' },
],
},
)
export type ButtonVariant =
| 'primary'
| 'secondary'
| 'outline'
| 'ghost'
| 'destructive'
| 'success'
| 'warning'
| 'link'
| 'soft'
export type ButtonSize = 'sm' | 'md' | 'lg' | 'icon' | 'icon-sm'
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant
size?: ButtonSize
block?: boolean
/** Swaps content for a spinner while preserving the button's measured width. */
loading?: boolean
/** Announced by assistive tech while `loading` is true. */
loadingLabel?: string
leadingIcon?: ReactNode
trailingIcon?: ReactNode
}
export function Button({
variant = 'primary',
size = 'md',
block = false,
loading = false,
loadingLabel = 'Working',
leadingIcon,
trailingIcon,
className,
children,
disabled,
type = 'button',
...props
}: ButtonProps) {
return (
<button
type={type}
className={buttonVariants({ variant, size, block, className })}
disabled={disabled ?? loading}
aria-busy={loading || undefined}
{...props}
>
{loading ? (
<>
{/* Label stays in the DOM but hidden so the control never collapses
to spinner width halfway through an interaction. */}
<span className="invisible flex items-center gap-2" aria-hidden="true">
{leadingIcon}
{children}
{trailingIcon}
</span>
<span className="absolute inset-0 flex items-center justify-center">
<Spinner size="sm" />
<span className="sr-only">{loadingLabel}</span>
</span>
</>
) : (
<>
{leadingIcon}
{children}
{trailingIcon}
</>
)}
</button>
)
}
export interface ButtonLinkProps {
href: string
variant?: ButtonVariant
size?: ButtonSize
block?: boolean
className?: string
children?: ReactNode
leadingIcon?: ReactNode
trailingIcon?: ReactNode
'aria-label'?: string
'aria-current'?: 'page' | 'step' | 'true' | undefined
target?: string
rel?: string
prefetch?: boolean
}
/** Anchor styled as a button, for when the action is really navigation. */
export function ButtonLink({
href,
variant = 'primary',
size = 'md',
block = false,
className,
children,
leadingIcon,
trailingIcon,
...props
}: ButtonLinkProps) {
return (
<Link href={href} className={buttonVariants({ variant, size, block, className })} {...props}>
{leadingIcon}
{children}
{trailingIcon}
</Link>
)
}
export { buttonVariants }
Demo source — adapt to your project. Foundry is not published as a package.
Usage
No autoplay. Auto-advancing content fails WCAG 2.2.2 unless it can be paused, and a quote that moves while you are reading it is hostile regardless of the guideline.
- Dot controls are real buttons with names, not decorated spans.
- Announce the position so a screen-reader user knows how many there are.
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.
- Previous and next
- Dot controls
- Position announcement
Accessibility
- Live region
- Changing quote is announced politely with its position.
- Named controls
- Every dot says which testimonial it shows.
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.