“Tawny gave us a homepage that felt finished on the first day. We shipped the journal, swapped in our own copy, and spent the week on the work.”
Maya Chen
Design lead, Atelier North
People
A serif headline with overlapping portraits, peeking quote cards, and company marks on one index.
“Tawny gave us a homepage that felt finished on the first day. We shipped the journal, swapped in our own copy, and spent the week on the work.”
Maya Chen
Design lead, Atelier North
Copy the files below into your project.
'use client'
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent, type KeyboardEvent as ReactKeyboardEvent } from 'react'
import Image from 'next/image'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import {
slideMotion,
slideOffset,
swipeDirection,
TESTIMONIAL_AUTOPLAY_MS,
TESTIMONIAL_EASE,
TESTIMONIAL_MOVE_MS,
testimonialCarousel,
testimonialThemeVars,
wrapIndex,
type Testimonial,
} from '@/lib/testimonial-carousel'
import { cn } from '@tawny/ui/lib/utils'
const iconButtonClass =
'grid size-9 place-items-center rounded-full text-foreground shadow-[inset_0_0_0_1px_var(--t-line)] transition-colors hover:bg-foreground/4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background'
type MotionPreference = 'pending' | 'reduce' | 'ok'
type ChangeSource = 'manual' | 'auto'
function useMotionPreference() {
const [motion, setMotion] = useState<MotionPreference>('pending')
useEffect(() => {
const media = window.matchMedia('(prefers-reduced-motion: reduce)')
const update = () => setMotion(media.matches ? 'reduce' : 'ok')
update()
media.addEventListener('change', update)
return () => media.removeEventListener('change', update)
}, [])
return {
reduced: motion === 'reduce',
ready: motion !== 'pending',
}
}
export function TestimonialCarousel({
items = testimonialCarousel,
className,
}: {
items?: Testimonial[]
className?: string
}) {
const count = items.length
const [index, setIndex] = useState(0)
const [hovering, setHovering] = useState(false)
const [keyboardFocus, setKeyboardFocus] = useState(false)
const [announcement, setAnnouncement] = useState('')
const { reduced, ready } = useMotionPreference()
const rootRef = useRef<HTMLElement>(null)
const tabListRef = useRef<HTMLDivElement>(null)
const pendingFocusId = useRef<string | null>(null)
const gesture = useRef<{ x: number; y: number; id: number } | null>(null)
const consumedSwipe = useRef(false)
const active = items[index]
const paused = !ready || reduced || hovering || keyboardFocus
function select(next: number, source: ChangeSource) {
if (count === 0) return
const wrapped = wrapIndex(next, count)
if (wrapped === index) return
const item = items[wrapped]
if (!item) return
setIndex(wrapped)
if (source === 'manual') {
setAnnouncement(`${item.name}, ${item.role}, ${item.company}. ${item.quote}`)
}
}
useEffect(() => {
const id = pendingFocusId.current
if (!id) return
pendingFocusId.current = null
document.getElementById(id)?.focus()
}, [index])
useEffect(() => {
const list = tabListRef.current
const tab = list?.querySelector<HTMLElement>('[aria-selected="true"]')
if (!list || !tab) return
if (list.scrollWidth <= list.clientWidth + 1) return
const left = tab.offsetLeft - (list.clientWidth - tab.offsetWidth) / 2
list.scrollTo({
left: Math.max(0, left),
behavior: reduced ? 'auto' : 'smooth',
})
}, [index, reduced])
function syncKeyboardFocus() {
const root = rootRef.current
const current = document.activeElement
if (!(current instanceof HTMLElement) || !root?.contains(current)) {
setKeyboardFocus(false)
return
}
setKeyboardFocus(current.matches(':focus-visible'))
}
function onTabKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
const next =
event.key === 'ArrowRight'
? index + 1
: event.key === 'ArrowLeft'
? index - 1
: event.key === 'Home'
? 0
: event.key === 'End'
? count - 1
: null
if (next === null) return
event.preventDefault()
event.stopPropagation()
const wrapped = wrapIndex(next, count)
if (wrapped === index) return
pendingFocusId.current = `testimonial-carousel-tab-${items[wrapped]?.id}`
select(wrapped, 'manual')
}
function onSectionKeyDown(event: ReactKeyboardEvent<HTMLElement>) {
if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return
if ((event.target as HTMLElement).closest('[role="tablist"]')) return
event.preventDefault()
select(index + (event.key === 'ArrowRight' ? 1 : -1), 'manual')
}
function onPointerDown(event: ReactPointerEvent<HTMLDivElement>) {
if (event.pointerType === 'mouse' && event.button !== 0) return
gesture.current = { x: event.clientX, y: event.clientY, id: event.pointerId }
consumedSwipe.current = false
}
function onPointerUp(event: ReactPointerEvent<HTMLDivElement>) {
const start = gesture.current
gesture.current = null
if (!start || start.id !== event.pointerId) return
const dx = event.clientX - start.x
const dy = event.clientY - start.y
const direction = swipeDirection(dx, dy)
if (direction === null) return
consumedSwipe.current = true
select(index + direction, 'manual')
}
if (!active) return null
return (
<section
ref={rootRef}
id="testimonial-carousel"
aria-roledescription="carousel"
aria-label="Testimonials"
className={cn(
'bg-background px-6 py-16 text-foreground md:px-8 md:py-24',
className,
)}
style={testimonialThemeVars}
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
onFocusCapture={() => {
queueMicrotask(syncKeyboardFocus)
}}
onBlurCapture={() => {
queueMicrotask(syncKeyboardFocus)
}}
onKeyDown={onSectionKeyDown}
>
<div className="mx-auto w-full max-w-[1072px]">
<h2 className="mx-auto max-w-[18rem] text-balance text-center font-serif text-[1.75rem] leading-[1.15] tracking-[-0.02em] text-foreground sm:max-w-[28rem] md:max-w-[34rem] md:text-[2.5rem] md:leading-[1.12]">
Thousands approved. Here's what{' '}
<span className="mx-[0.18em] inline-flex translate-y-[0.08em] align-baseline">
{items.slice(0, 3).map((item, avatarIndex) => (
<span
key={item.id}
className="relative size-[0.92em] shrink-0 overflow-hidden rounded-full shadow-[0_0_0_0.08em_var(--background)]"
style={{
marginLeft: avatarIndex === 0 ? 0 : '-0.3em',
zIndex: 3 - avatarIndex,
}}
>
<Image
src={item.avatar}
alt=""
fill
sizes="48px"
className="object-cover"
/>
</span>
))}
</span>{' '}
they're saying.
</h2>
<div
className="relative mt-10 grid touch-pan-y overflow-hidden md:mt-12"
style={{ touchAction: 'pan-y' }}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onPointerCancel={() => {
gesture.current = null
}}
>
{items.map((item, itemIndex) => {
const offset = slideOffset(itemIndex, index, count)
const motion = slideMotion(offset)
return (
<article
key={item.id}
id={`testimonial-carousel-slide-${item.id}`}
role="group"
aria-roledescription="slide"
aria-label={`${itemIndex + 1} of ${count}`}
aria-hidden={offset !== 0}
className={cn(
'relative col-start-1 row-start-1 flex h-full min-h-[300px] w-[88%] flex-col justify-between gap-8 justify-self-start overflow-hidden rounded-2xl bg-[var(--t-card)] p-6 md:min-h-[380px] md:w-[78%] md:p-10',
offset === 1 && 'cursor-pointer',
)}
style={{
...motion,
transition: reduced
? 'none'
: `transform ${TESTIMONIAL_MOVE_MS}ms ${TESTIMONIAL_EASE}, opacity ${TESTIMONIAL_MOVE_MS}ms ease`,
}}
onClick={() => {
if (consumedSwipe.current) {
consumedSwipe.current = false
return
}
if (offset === 1) select(itemIndex, 'manual')
}}
>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 right-0 w-3/5"
style={{
backgroundImage:
'radial-gradient(var(--t-dot) 1px, transparent 1px)',
backgroundSize: '18px 18px',
maskImage:
'radial-gradient(120% 105% at 100% 50%, #000 0%, transparent 72%)',
WebkitMaskImage:
'radial-gradient(120% 105% at 100% 50%, #000 0%, transparent 72%)',
}}
/>
<p className="relative z-10 max-w-[46ch] text-base font-medium leading-snug text-[var(--t-body)] md:text-xl md:leading-[1.33]">
“{item.quote}”
</p>
<div className="relative z-10 flex items-center gap-3">
<Image
src={item.avatar}
alt=""
width={36}
height={36}
className="size-9 shrink-0 rounded-[10px] object-cover"
/>
<div className="min-w-0">
<p className="text-[15px] font-medium leading-tight text-foreground">
{item.name}
</p>
<p className="mt-0.5 text-[15px] leading-tight text-[var(--t-soft)]">
{item.role}, {item.company}
</p>
</div>
</div>
</article>
)
})}
</div>
<div className="mt-5 flex items-center gap-5 md:mt-6">
<div
className="relative h-px flex-1 bg-[var(--t-line)]"
role="progressbar"
aria-valuemin={1}
aria-valuemax={count}
aria-valuenow={index + 1}
aria-valuetext={`Testimonial ${index + 1} of ${count}`}
aria-label="Testimonial progress"
>
{ready && !reduced ? (
<div
key={index}
className="absolute inset-y-0 left-0 w-full origin-left bg-[var(--t-fill)]"
style={{
animationName: 'tawny-testimonial-progress',
animationDuration: `${TESTIMONIAL_AUTOPLAY_MS}ms`,
animationTimingFunction: 'linear',
animationFillMode: 'forwards',
animationPlayState: paused ? 'paused' : 'running',
}}
onAnimationEnd={(event) => {
if (event.target !== event.currentTarget) return
select(index + 1, 'auto')
}}
/>
) : (
<div
className="absolute inset-y-0 left-0 bg-[var(--t-fill)]"
style={{ width: `${((index + 1) / count) * 100}%` }}
/>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
className={iconButtonClass}
aria-label="Previous testimonial"
aria-controls={`testimonial-carousel-slide-${active.id}`}
onClick={() => select(index - 1, 'manual')}
>
<ChevronLeft className="size-4" aria-hidden="true" />
</button>
<button
type="button"
className={iconButtonClass}
aria-label="Next testimonial"
aria-controls={`testimonial-carousel-slide-${active.id}`}
onClick={() => select(index + 1, 'manual')}
>
<ChevronRight className="size-4" aria-hidden="true" />
</button>
</div>
</div>
<div
ref={tabListRef}
role="tablist"
aria-label="Companies"
className="mt-5 flex w-full items-center justify-between gap-1 overflow-x-auto py-1 [scrollbar-width:none] md:mt-6 [&::-webkit-scrollbar]:hidden"
onKeyDown={onTabKeyDown}
>
{items.map((item, itemIndex) => {
const selected = itemIndex === index
return (
<button
key={item.id}
type="button"
role="tab"
id={`testimonial-carousel-tab-${item.id}`}
aria-selected={selected}
aria-controls={`testimonial-carousel-slide-${item.id}`}
tabIndex={selected ? 0 : -1}
className="relative flex h-9 shrink-0 items-center justify-center rounded-full px-3 text-foreground outline-none focus-visible:ring-2 focus-visible:ring-foreground/40 focus-visible:ring-inset"
onClick={() => select(itemIndex, 'manual')}
>
<span
aria-hidden="true"
className={cn(
'absolute inset-0 rounded-full bg-[var(--t-card)] transition-opacity duration-200',
selected ? 'opacity-100' : 'opacity-0',
)}
/>
<span
className={cn(
'relative flex items-center gap-2 transition-opacity duration-200',
selected ? 'opacity-100' : 'opacity-40',
)}
>
<Image
src={item.companyLogo}
alt=""
width={16}
height={16}
className="size-4 shrink-0 dark:invert"
/>
<span className="text-sm font-medium tracking-[-0.01em] whitespace-nowrap md:text-[15px]">
{item.company}
</span>
</span>
</button>
)
})}
</div>
</div>
<p className="sr-only" aria-live="polite">
{announcement}
</p>
</section>
)
}export type Testimonial = {
id: string
quote: string
name: string
role: string
company: string
/** Illustrated placeholder portrait. Not a photograph. */
avatar: string
/** Monochrome mark placeholder. Not a customer logo. */
companyLogo: string
}
/**
* Original Tawny quotes. Portraits and marks are drawn placeholders in
* `public/images/testimonials/`, not photos or real company logos.
*/
export const testimonialCarousel: Testimonial[] = [
{
id: 'atelier',
quote:
'Tawny gave us a homepage that felt finished on the first day. We shipped the journal, swapped in our own copy, and spent the week on the work.',
name: 'Maya Chen',
role: 'Design lead',
company: 'Atelier North',
avatar: '/images/testimonials/maya-chen.svg',
companyLogo: '/images/testimonials/atelier-north.svg',
},
{
id: 'harbor',
quote:
'The components stay quiet in the best way. Lists, headers, and frames sit in one system, so a new page does not look like a different product.',
name: 'Jonah Ellis',
role: 'Founder',
company: 'Harbor & Co',
avatar: '/images/testimonials/jonah-ellis.svg',
companyLogo: '/images/testimonials/harbor-and-co.svg',
},
{
id: 'fieldnote',
quote:
'We replaced three one-off marketing pages with Tawny designs and kept our own content model intact. The type, the spacing, and the motion already agreed with each other, which is the part that usually takes a sprint of restyling. Our team reviews the live demo, scaffolds a copy, and edits the words from there. The frame stays put, even when a quote needs another line or two to finish the thought.',
name: 'Priya Raman',
role: 'Staff engineer',
company: 'Fieldnote',
avatar: '/images/testimonials/priya-raman.svg',
companyLogo: '/images/testimonials/fieldnote.svg',
},
{
id: 'kiln',
quote:
'I wanted a shop that still felt like a studio. Cobalt was the closest thing to handing someone a finished storefront and saying, start here.',
name: 'Leo Park',
role: 'Creative director',
company: 'Kiln Studio',
avatar: '/images/testimonials/leo-park.svg',
companyLogo: '/images/testimonials/kiln-studio.svg',
},
{
id: 'paper',
quote:
'The previews are the actual layouts, not screenshots of them. That saved us a round of asking whether the live page would really look like the picture.',
name: 'Amira Solano',
role: 'Product designer',
company: 'Paper Route',
avatar: '/images/testimonials/amira-solano.svg',
companyLogo: '/images/testimonials/paper-route.svg',
},
{
id: 'lumen',
quote:
'Desk was the first dashboard we did not have to reskin before a stakeholder meeting. We changed the sample data and left the frame alone.',
name: 'Chris Adelayo',
role: 'Engineering manager',
company: 'Lumen Desk',
avatar: '/images/testimonials/chris-adelayo.svg',
companyLogo: '/images/testimonials/lumen-desk.svg',
},
]
export const TESTIMONIAL_AUTOPLAY_MS = 7000
export const TESTIMONIAL_MOVE_MS = 700
export const TESTIMONIAL_EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'
/** Card, hairline, and ink. Resolved on the element so light and dark both apply. */
export const testimonialThemeVars = {
'--t-card': 'var(--accent)',
'--t-line': 'color-mix(in srgb, var(--foreground) 12%, transparent)',
'--t-fill': 'color-mix(in srgb, var(--foreground) 58%, transparent)',
'--t-soft': 'color-mix(in srgb, var(--foreground) 58%, transparent)',
'--t-body': 'color-mix(in srgb, var(--foreground) 80%, transparent)',
'--t-dot': 'color-mix(in srgb, var(--foreground) 22%, transparent)',
} as const
export function wrapIndex(index: number, count: number) {
return ((index % count) + count) % count
}
/** Signed distance from the active slide, wrapped so the short way wins. */
export function slideOffset(index: number, active: number, count: number) {
let offset = (index - active + count) % count
if (offset > count / 2) offset -= count
return offset
}
/** Signed distance, unwrapped. The ends stay on the long side of zero. */
export function rawOffset(index: number, active: number) {
return index - active
}
export type SlideMotion = {
transform: string
opacity: number
zIndex: number
pointerEvents: 'auto' | 'none'
}
/** Where a card sits for a signed offset. The live carousel uses these poses. */
export function slideMotion(offset: number): SlideMotion {
if (offset === 0) {
return {
transform: 'translateX(0) scale(1)',
opacity: 1,
zIndex: 2,
pointerEvents: 'auto',
}
}
if (offset === 1) {
return {
transform: 'translateX(102.564%) scale(0.97)',
opacity: 1,
zIndex: 1,
pointerEvents: 'auto',
}
}
if (offset === -1) {
return {
transform: 'translateX(-33.333%) scale(0.97)',
opacity: 0,
zIndex: 1,
pointerEvents: 'none',
}
}
if (offset > 1) {
return {
transform: 'translateX(133.333%) scale(0.97)',
opacity: 0,
zIndex: 0,
pointerEvents: 'none',
}
}
return {
transform: 'translateX(-40%) scale(0.97)',
opacity: 0,
zIndex: 0,
pointerEvents: 'none',
}
}
/** 1 moves to the next quote, −1 to the previous. Null is a scroll or a tap. */
export function swipeDirection(dx: number, dy: number): -1 | 1 | null {
if (Math.abs(dx) < 48 || Math.abs(dx) < Math.abs(dy) * 1.2) return null
return dx < 0 ? 1 : -1
}