Magazine Pull Quote
A magazine-spread React testimonial with an oversized quotation mark, a large serif pull quote, a drop cap and two-column body copy; on entry the mark scales into place and the text reveals line by line, with page turning for multiple testimonials.
Lays out a customer testimonial as a magazine spread: the left page is a full-bleed portrait (a two-tone gradient and an abstract bust silhouette stand in when there is no photo) with a caption, and the right page stacks a section kicker, an oversized quotation mark tucked over the top left of the pull quote, a large serif pull quote, a hairline rule, two-column case-study copy that opens with a drop cap, and the attribution with a page number — a gutter shadow down the middle binds the two pages. When it scrolls into view, the left image unfolds outward from the gutter, the quotation mark scales down and settles, the pull quote rises out of a mask one line at a time and the body copy fades in last. With several testimonials, previous / next controls in the bottom right turn the whole spread like a sheet of paper.
Bring this into your project
Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.
npx webberui@latest add magazine-pull-quote.enPlayground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<MagazinePullQuote/>
The complete example includes the preview layout and data. Install the component first, then paste this file into your project.
Installation
npx webberui@latest add https://webberui.com/r/magazine-pull-quote.en.jsonOr, once registries are configured in components.json, install it as @webberui/magazine-pull-quote.
Usage
import { MagazinePullQuote } from "@/components/ui/magazine-pull-quote";
<MagazinePullQuote
eyebrow="Customer Story"
title="Inside this issue's customer interviews"
testimonials={[
{
quote: "Since the redesign,\nguests book a table by scanning a code at the door.",
name: "Maya Lin",
role: "Head of Operations",
company: "Islet Coffee",
highlight: "scanning a code at the door",
photoCredit: "Ian Chou",
story:
"Our three locations each ran their own booking sheet, and the same table was often booked twice on weekends.\n\nIn the first month after launch, the weekend waitlist was cut in half.",
},
{
quote: "Drivers open the dispatch screen and know the next stop.",
name: "Owen Chen",
role: "Dispatch Lead",
company: "Northstar Freight",
photoSrc: "/images/team/chen.jpg",
},
]}
pageLabel="Customer Interview"
startPage={24}
onIndexChange={(i) => console.log(`Turned to ${i + 1}`)}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
testimonials | MagazinePullQuoteItem[] | — | The testimonials; page controls appear in the bottom right when there is more than one |
eyebrow | string | — | Section kicker at the top of the right page, set in uppercase with wide tracking |
title | string | — | Section heading above the spread; when provided it becomes the section's aria-labelledby |
subtitle | string | — | Supporting text under the heading |
italic | boolean | false | Whether the pull quote is set in italics |
showPhoto | boolean | true | Whether to show the left-page portrait; when off, only the right page is shown, centred |
duration | number | 700 | Line reveal duration for the pull quote (ms); falls back to --wb-magazine-pull-quote-duration |
index | number | — | Controlled current index; managed internally when omitted |
defaultIndex | number | 0 | Initial index in uncontrolled mode |
onIndexChange | (index: number) => void | — | Called when the index changes |
pageLabel | string | "Customer Interview" | Section name in the page footer; also the section's aria-label when there is no title (the Chinese build defaults to "客戶專訪", Customer Interview) |
startPage | number | 24 | Page number of the first spread; each turn adds 2 |
className | string | — | Passed through to the root section |
MagazinePullQuoteItem
| Field | Type | Default | Description |
|---|---|---|---|
quote | string | — | Pull quote text; break lines with \n, otherwise it is split after sentence-ending punctuation |
name | string | — | Name, shown in the attribution and the caption |
role | string | — | Job title |
company | string | — | Company |
rating | number | — | Rating (0–5), shown next to the attribution |
avatarHue | number | — | Hue of the placeholder portrait; derived from the index by the golden angle (137.508°) when omitted |
highlight | string | — | A fragment of the quote to mark in the accent colour |
metric | { value: string; label: string } | — | A key figure paired with the quote, shown next to the attribution |
story | string | — | Longer case-study copy set in two columns; separate paragraphs with a blank line, and the first paragraph opens with a drop cap |
photoSrc | string | — | Portrait photo URL; a gradient and abstract silhouette stand in when omitted |
photoCredit | string | — | Photographer credit shown in the caption |
How it works
- Serif type is used only for the quotation mark, pull quote, drop cap and figures, via Tailwind's
font-serif, so the actual face comes from your site'sfontFamily.serifand the component loads no font files; everything else staysfont-sans. - The timing variable
--wb-magazine-pull-quote-duration(default700ms) sets how long each pull-quote line takes to rise, with an 80ms stagger between lines. Passingdurationwrites it into that variable, and the component reads it back from the computed style after mounting, so overriding the variable higher up also works. - The rest of the timing is fixed: the quotation mark springs (stiffness 180, damping 18) down from 1.6× and a −6° tilt; the body copy fades in and rises 8px after a 300ms delay; the left image unfolds outward from the gutter over 900ms; on a page turn the right page hands off with
rotateY(-8deg)around its left edge plus opacity over 600ms while the left image crossfades. - The entrance plays once, the first time the spread scrolls into view (
useInViewonce). - The accent colour only comes from
--wb-magazine-pull-quote-accent(default#0d9488in light mode,#2dd4bfin dark mode) and is used for the kicker, quotation mark, highlighted fragment and figures. - From md up the two pages sit side by side; below md they stack and the gutter shadow is hidden.
Accessibility
- The root element is a
sectionlabelled by the heading viaaria-labelledbywhen a title is given; the spread itself is anarticle, and the quote usesfigure,blockquoteandfigcaption - The oversized quotation mark and the placeholder silhouette are
aria-hidden, the portrait image is decorative (alt=""), and the name and title are given as text in the caption and attribution - The drop cap is done with
::first-letterrather than wrapping the first letter in its own element, so screen readers read the sentence as a whole - Previous / next are
type="button"with anaria-label; with focus inside the spread the left and right arrow keys turn the page, and after a turn anaria-live="polite"region announces "2 of 3" and the name - When the user has turned on reduce motion: no scaling, tilting, sliding, clip reveal or page-turn effect — everything becomes a 150ms fade, and the page controls still work
Dark Portrait Spotlight
A React testimonial section that pairs a large portrait with the quote on a dark layout; a thumbnail row below switches customers, crossfading the portrait from greyscale into colour as the name and role slide in.
Animated Stats
A group of statistics that count from 0 to their target values when they enter the viewport, with items staggering in and sliding up.