Margin Quote
Editorial testimonials with a hanging-punctuation serif pull quote, margin attribution and tick progress.
npx shadcn@latest add https://sundaeui.dev/r/margin-quote.json'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
export interface Quote {
quote: string
name: string
role: string
/** Optional context shown in the margin, e.g. company or issue number. */
note?: string
}
export interface MarginQuoteProps {
quotes: Quote[]
/** Auto-advance interval in ms. 0 disables. */
interval?: number
label?: string
className?: string
}
/**
* Editorial testimonials: a large serif pull quote with hanging punctuation,
* attribution set in the margin, and tick-mark progress.
*/
export function MarginQuote({ quotes, interval = 7000, label = 'Testimonials', className }: MarginQuoteProps) {
const [i, setI] = useState(0)
const [paused, setPaused] = useState(false)
const [dir, setDir] = useState<1 | -1>(1)
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
const go = useCallback(
(next: number) => {
setDir(next > i || (i === quotes.length - 1 && next === 0) ? 1 : -1)
setI((next + quotes.length) % quotes.length)
},
[i, quotes.length],
)
useEffect(() => {
if (!interval || paused || matchMedia('(prefers-reduced-motion: reduce)').matches) return
timer.current = setTimeout(() => go(i + 1), interval)
return () => {
if (timer.current) clearTimeout(timer.current)
}
}, [i, interval, paused, go])
const q = quotes[i]
return (
<section
aria-roledescription="carousel"
aria-label={label}
className={['relative w-full max-w-5xl text-stone-900 dark:text-stone-100', className].filter(Boolean).join(' ')}
onPointerEnter={() => setPaused(true)}
onPointerLeave={() => setPaused(false)}
onFocus={() => setPaused(true)}
onBlur={() => setPaused(false)}
onKeyDown={(e) => {
if (e.key === 'ArrowRight') go(i + 1)
if (e.key === 'ArrowLeft') go(i - 1)
}}
>
<div className="grid gap-8 md:grid-cols-[180px_minmax(0,1fr)] md:gap-12">
{/* Margin */}
<aside className="order-2 flex flex-row items-end justify-between gap-6 border-t border-stone-300 pt-4 md:order-1 md:flex-col md:items-start md:justify-start md:border-t-0 md:border-r md:pt-1 md:pr-8 dark:border-stone-700">
<div key={`m${i}`} className="motion-safe:animate-[mq-fade_600ms_cubic-bezier(.22,1,.36,1)_both]">
<p className="text-[15px] font-semibold tracking-[-0.01em]">{q.name}</p>
<p className="mt-0.5 text-[13.5px] text-stone-500 dark:text-stone-400">{q.role}</p>
{q.note && <p className="mt-4 font-mono text-[11px] uppercase tracking-[0.12em] text-stone-400 dark:text-stone-500">{q.note}</p>}
</div>
<span className="font-mono text-[11px] tabular-nums text-stone-400 md:mt-10 dark:text-stone-500" aria-hidden>
{String(i + 1).padStart(2, '0')} / {String(quotes.length).padStart(2, '0')}
</span>
</aside>
{/* Quote */}
<figure className="order-1 md:order-2" aria-live={paused ? 'polite' : 'off'}>
<blockquote
key={`q${i}`}
className={[
'font-serif text-[clamp(26px,4.2vw,46px)] leading-[1.12] tracking-[-0.015em] [hanging-punctuation:first] [text-wrap:pretty]',
dir === 1
? 'motion-safe:animate-[mq-in-r_700ms_cubic-bezier(.22,1,.36,1)_both]'
: 'motion-safe:animate-[mq-in-l_700ms_cubic-bezier(.22,1,.36,1)_both]',
].join(' ')}
>
<span className="-ml-[0.42em] text-stone-400 dark:text-stone-500">“</span>
{q.quote}
<span className="text-stone-400 dark:text-stone-500">”</span>
</blockquote>
<figcaption className="sr-only">
{q.name}, {q.role}
</figcaption>
<div className="mt-10 flex items-center gap-6">
<div className="flex flex-1 gap-1.5" role="tablist" aria-label="Choose testimonial">
{quotes.map((x, k) => (
<button
key={k}
type="button"
role="tab"
aria-selected={k === i}
aria-label={`${x.name}`}
onClick={() => go(k)}
className="group relative h-6 flex-1 cursor-pointer"
>
<span className="absolute inset-x-0 top-1/2 h-px -translate-y-1/2 bg-stone-300 transition-colors group-hover:bg-stone-500 dark:bg-stone-700" />
{k === i && (
<span
key={`p${i}${paused}`}
className="absolute left-0 top-1/2 h-px w-full origin-left -translate-y-1/2 bg-stone-900 dark:bg-stone-100"
style={{
animation: interval && !paused ? `mq-progress ${interval}ms linear both` : undefined,
}}
/>
)}
</button>
))}
</div>
<div className="flex gap-1">
{[-1, 1].map((d) => (
<button
key={d}
type="button"
onClick={() => go(i + d)}
aria-label={d === -1 ? 'Previous testimonial' : 'Next testimonial'}
className="grid size-9 place-items-center rounded-full text-stone-500 ring-1 ring-stone-300 transition hover:bg-stone-900 hover:text-white hover:ring-stone-900 dark:ring-stone-700 dark:hover:bg-stone-100 dark:hover:text-stone-900"
>
<svg viewBox="0 0 16 16" className={['size-3.5', d === -1 ? 'rotate-180' : ''].join(' ')} fill="none" aria-hidden>
<path d="M3 8h10m-4-4 4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
))}
</div>
</div>
</figure>
</div>
<style>{`
@keyframes mq-in-r { from { opacity: 0; transform: translateX(18px); filter: blur(4px) } }
@keyframes mq-in-l { from { opacity: 0; transform: translateX(-18px); filter: blur(4px) } }
@keyframes mq-fade { from { opacity: 0; transform: translateY(6px) } }
@keyframes mq-progress { from { transform: translateY(-50%) scaleX(0) } to { transform: translateY(-50%) scaleX(1) } }
`}</style>
</section>
)
}Overview
Testimonials set like a magazine spread, not a row of cards with avatars and stars. The quote is large and serif, with the opening mark hung outside the text block so the left edge stays clean. Attribution sits in a narrow margin column separated by a hairline rule.
Quotes advance on their own, and the active tick fills to show the time left. Hovering or focusing pauses everything. Transitions slide and un-blur in the direction you are moving.
Written in Tailwind with the stone palette. It collapses to a single column on small screens, with attribution moving below the quote.
When to use
- Marketing and product pages that need a complete, responsive band of content.
When not to use
- Inside dense app screens, where a smaller component fits better.
Installation
pnpm dlx shadcn@latest add https://sundaeui.dev/r/margin-quote.jsonnpx shadcn@latest add https://sundaeui.dev/r/margin-quote.jsonyarn dlx shadcn@latest add https://sundaeui.dev/r/margin-quote.jsonbunx --bun shadcn@latest add https://sundaeui.dev/r/margin-quote.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy margin-quote.tsx into components/sundae/.
- Make sure Tailwind scans that folder (v4 detects it automatically; v3 needs it in `content`).
- Import the component and use it as shown below.
Usage
import { MarginQuote } from '@/components/sundae/margin-quote'
<MarginQuote
interval={7000}
quotes={[
{ quote: 'We stopped designing a new modal for every feature…', name: 'Ines Duarte', role: 'Head of Design, Fieldnote', note: 'Customer since 2024' },
{ quote: '…', name: '…', role: '…' },
]}
/>Props
| Prop | Type | Default |
|---|---|---|
quotes*{ quote, name, role, note? }. | Quote[] | — |
intervalAuto-advance in ms. Pass 0 to disable. | number | 7000 |
labelAccessible name of the carousel. | string | 'Testimonials' |
Accessibility
- Uses aria-roledescription="carousel". Auto-rotation pauses on hover and focus, and never runs with reduced motion.
- The live region is polite only while the user is interacting, so screen readers are not interrupted by automatic changes.
- Arrow keys move between quotes, and each tick is a labelled tab button.
Customization
- The quote uses Tailwind’s font-serif. Map it to your editorial face in the theme.
- Swap stone- for neutral- or zinc- classes to match your greys.
FAQ
- Is Margin Quote free?
- Yes. Margin Quote is free for personal and commercial projects, with full source on this page.
- How do I install Margin Quote?
- Run npx shadcn@latest add https://sundaeui.dev/r/margin-quote.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Margin Quote support keyboard and screen readers?
- Uses aria-roledescription="carousel". Auto-rotation pauses on hover and focus, and never runs with reduced motion. The live region is polite only while the user is interacting, so screen readers are not interrupted by automatic changes.
- Does Margin Quote respect reduced motion?
- Uses aria-roledescription="carousel". Auto-rotation pauses on hover and focus, and never runs with reduced motion.
More like this
All sections →- Pro
Ledger Pricing
Sections · Editorial - Pro
Billboard Hero
Sections · Brutalist - Pro
Bento Features
Sections · Understated - Pro
Changelog Timeline
Sections · Editorial - Pro
Newsletter CTA
Sections · Refined - Pro
Product Footer
Sections · Understated