Text

Pull Quote

An editorial pull quote for body copy — centered, floated left or right with text wrapping around it, or bled past the measure.

Preview in your theme

Loading preview…

import * as React from "react"

import { cn } from "@/lib/utils"

export type PullQuoteAlign = "center" | "left" | "right" | "bleed"
export type PullQuoteMark = "glyph" | "rule" | "none"

/**
 * 装饰引号走 CSS `content`(而不是一个文本节点):生成内容不在 DOM 里,所以
 * **复制引语时永远不会被带上**,也不需要指望 user-select:none 在每个引擎里
 * 都把它排除出剪贴板。外层那个空 span 再挂 aria-hidden,读屏也不会念它。
 * 值是一个 CSS 字符串 token,消费者传 style 覆盖即可换成 « / „ / 「。
 */
const MARK_GLYPH = '"“"'

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/pull-quote.json

Prompt

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

  • figcaption is a sibling, not a child — the attribution belongs to the figure, not to the quoted text. Nesting it inside blockquote is 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 ::before on an empty aria-hidden span, 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: none would 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-indent that shifts just the first line, upgraded through @supports where 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: anywhere on the quote and the caption is what holds the measure.

On This Page