Pull Quote
An editorial pull quote for body copy — centered, floated left or right with text wrapping around it, or bled past the measure.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/pull-quote.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "PullQuote" component (no client JS at
all — it is a pure server component, all layout is CSS).
Contract
- forwardRef<HTMLElement> onto a `figure`, extending React.HTMLAttributes so
the consumer can spread id / lang / data-* onto it.
- Props: align = "center" | "left" | "right" | "bleed" (default "center"),
mark = "glyph" | "rule" | "none" (default "glyph"), hanging = boolean
(default false), author / authorRole / source (ReactNode), sourceHref
(string), children = the quotation itself.
- authorRole is a job title, NOT an ARIA role — do not name the prop `role`,
it would collide with the DOM attribute coming in through ...props.
Structure (this is the part everyone gets wrong)
- figure
> span[aria-hidden] <- the decorative mark, empty in the DOM
> blockquote[cite] <- the quotation, nothing else
> figcaption <- name / role / cite, SIBLING of blockquote
- `figcaption` is only valid as a child of `figure`; putting it inside
`blockquote` is invalid HTML and also folds the attribution into the quoted
text. Keep them siblings.
- The person's name is NOT wrapped in `cite`: per the HTML spec `cite` marks
the title of a work, so only `source` gets it, optionally wrapping an `a`
that also feeds `blockquote[cite]`.
- Render figcaption only when at least one of author / authorRole / source is
given, so a bare quote produces no empty caption box.
Behavior — the decorative quote mark
- The oversized opening quote is CSS generated content: an EMPTY span carrying
`before:content-[var(--pull-quote-mark)]`, with the character supplied as a
CSS string token through inline style (default '"“"' i.e. a left double
quotation mark).
- Two things follow, and both are the point: generated content is not in the
DOM, so selecting the quote and copying it can never pick the glyph up (do
not rely on user-select:none for this — it is engine-dependent), and the
span is aria-hidden so no screen reader announces a stray quotation mark.
Real quotation marks, if the copy has them, live in the text and are copied
with it.
- mark="rule" reuses the same slot as a short 2px bar (centered when the text
is centered); mark="none" renders nothing.
Behavior — floats and the heading that follows
- align="left" / "right" float the figure at >=md only (float-left / -right,
w-2/5, mr-8 / ml-8, small top margin) and add `clear-both` so consecutive
quotes stack instead of sitting side by side. Below md the float is simply
never applied, because a 40% float on a phone leaves the body a slot.
- A float that is taller than the paragraph after it will let the NEXT heading
start beside it, which reads as the quote covering the heading. The
component cannot reach forward in the DOM, but CSS can: the floated figure
also carries `md:[&~h2]:clear-both` (and h3 / h4), i.e. a subsequent-sibling
rule that gives every later heading `clear: both`. It only reaches siblings
under the same parent, which is what an article is.
Behavior — bleed
- align="bleed" is a band (bg-muted, rounded, padded) that grows OUTWARD via
negative inline margins, taking the SMALLEST of three caps:
6% scale down inside narrow columns
var(--pull-quote-bleed, 1rem) absolute cap = the gutter contract
max(0px, (100vw - 100% - 2rem) / 2) room actually left beside the page
The third term collapses to 0 when the column nearly fills the viewport, so
the bleed disappears on phones with no breakpoint, and the 2rem slack also
covers a classic scrollbar. Do NOT use the usual
`margin-inline: calc(50% - 50vw)`: it assumes the column is centered and
that 100vw excludes the scrollbar, and it escapes the page in both sidebar
layouts and pages with a stable scrollbar gutter.
- The contract to state in the docs: the bleed eats into the article
container's own horizontal padding, so raise --pull-quote-bleed to match
that gutter (px-10 -> 2.5rem) for a stronger bleed.
Behavior — hanging punctuation
- hanging=true hangs the opening quotation mark outside the column edge. Only
Safari implements `hanging-punctuation`, so ship the portable version first
— `text-indent: -0.42em` on the blockquote, which shifts the first line only
— and upgrade inside `supports-[hanging-punctuation:first]` to indent 0 plus
the real property. Because text-indent is inherited, the mark span needs
`indent-0` so the glyph does not slide with it.
- Document that hanging is only correct when the text really starts with a
quotation mark; otherwise the first letter is pulled into the margin.
Rendering & styling
- Semantic tokens only: bg-muted for the bleed band, text-primary/25 for the
mark, bg-primary/40 for the rule, text-muted-foreground for the secondary
caption line, ring-ring for the source link focus ring. No hex, no oklch.
- Type scale: centered / bled quotes are text-xl sm:text-2xl, floated ones
text-lg because the column is 40% wide. text-pretty on the figure.
- `wrap-anywhere` on both the blockquote and the figcaption: a 58-character
token or a 47-character name inside a 40% float otherwise pushes its own
content past the box (measured: 287px of content in a 237px box). There is
no flex or grid container here, so min-w-0 does nothing — do not add it.
- No animation, no state, no effects: nothing to gate behind
prefers-reduced-motion, and no "use client".
Customization levers
- align is geometry, mark is voice: pair align="center" + mark="glyph" for a
magazine break, align="left" + mark="rule" for a quieter marginal note.
- Float width (w-2/5) and gap (mr-8 / ml-8) are the two numbers that decide
how much body copy survives beside the quote; the md breakpoint is where the
float switches on — raise it to lg if your measure is narrow.
- --pull-quote-bleed is the bleed budget; set it per container, not globally.
- --pull-quote-mark swaps the glyph for another language's opening mark
('"«"', '"„"', '"「"') without touching the component.
- Swap float-left / float-right for float-start / float-end and mr / ml for
me / ms if you ship RTL.
- The caption is two lines by design (name, then role · source); collapse it
to one line, or drop the em dash, by editing the figcaption only.Concepts
figcaptionis a sibling, not a child — the attribution belongs to the figure, not to the quoted text. Nesting it insideblockquoteis invalid HTML and quietly claims the speaker's name is part of what they said.- Decoration as generated content — the oversized quote mark is a
::beforeon an emptyaria-hiddenspan, so it exists only in the paint tree: copying the quote returns the sentence verbatim, and no screen reader reads a floating punctuation mark.user-select: nonewould only cover the copy half, and only in some engines. - A float has to clear itself out of the way — a quote taller than the paragraph after it will let the next heading start beside it. The floated figure carries a subsequent-sibling rule (
figure ~ h2 { clear: both }), which is the only way a component can affect DOM that comes after it. - Bleed is a budget, not a viewport trick — the block grows into the article's own gutter, capped by a percentage, a custom property and the room actually left beside the page, so it widens on a wide screen and disappears on a phone without a media query, and never adds a horizontal scrollbar.
- Hanging punctuation, portably — the real property is Safari-only, so the default is a negative
text-indentthat shifts just the first line, upgraded through@supportswhere the property exists. Turn it on only when the sentence really starts with a quotation mark. - Wrapping is what keeps a narrow float honest — inside a 40% column, one long token or a long name will otherwise render wider than its own box;
overflow-wrap: anywhereon the quote and the caption is what holds the measure.
Balanced Heading
A heading that breaks its lines to even lengths instead of stranding one word on the last line — pure CSS text-wrap with a copy-safe fallback for engines that lack it.
Link Underline
Underline motion for inline body links — slide, center, wipe or thicken — that survives line wraps, descenders, reduced motion and forced colors.