Pull quote for articles
One sentence from the text set large. Inset beside the paragraphs, centered, with a sweeping marker or beside a portrait.
One sentence, set large
A pull quote lifts one sentence out of the text, so a skimming reader stops and takes the core message along. The four versions differ in how loud they are, from a quiet inset to a large band of its own.
Inset leans into the margin beside the paragraphs on wide screens and sits between them with a rule on phones. Centered stands alone and large on a calm band and rises word by word under a drawn quotation mark. With marker sweeps a marker over the key phrase as the quote scrolls in and adds a button that copies the quote. With portrait puts the speaker beside the quote with name, role and where it was said.
Pick a sentence that works on its own, readers see it before the paragraph around it. Use one pull quote per article, two at most in long reads. The portrait version suits interviews and customer stories.
Details
What Pull quote brings along, read from the registry and the source.
- Category
- Article elements
- Area
- Content
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/pull-quote.tsx
- Brings along
- utilskiticonsectionrevealmotionword-reveal
- npm packages
- None
- Languages
- English and German, 3 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The PullQuoteProps interface from components/ui/pull-quote.tsx, with the comments from the code.
variantoptionalPullQuoteVariantinsetcenteredhighlightportrait
quoterequiredstringThe quote, `*words*` mark a phrase
citeoptionalstringWho said it
roleoptionalstringRole or place of the speaker
sourceoptionalstringWhere it was said, e.g. an interview or a book
eyebrowoptionalReact.ReactNodebodyoptionalstring[]inset: the paragraphs around the quote
atoptionalnumberinset: the quote follows this paragraph (0 based), default 0
imageoptionalReact.ReactNodeportrait: the picture of the speaker
Built-in texts
Pull quote writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 3 texts
| Key | English | German |
|---|---|---|
| copy | English Copy quote | German Zitat kopieren |
| copied | English Copied | German Kopiert |
| quote | English Quote | German Zitat |
Accessibility
Read from the source of the component.
- Sets aria-live, so screen readers announce purpose and state.
- Uses the role p.
- Announces changes through a live region without moving focus.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 235 lines
'use client';
import { useState } from 'react';
import { cn, useInView, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { Eyebrow, Section } from '@/components/ui/section';
import { Reveal } from '@/components/ui/reveal';
import { Parallax } from '@/components/ui/motion';
import { WordReveal } from '@/components/ui/word-reveal';
/*
* One sentence lifted out of a text and set large, so a reader who skims
* still takes it with them.
*
* inset beside the paragraphs, leaning into the margin on wide
* screens, between them with a rule on phones
* centered alone and large, rising word by word under a drawn mark
* highlight a phrase that a marker sweeps over, with a copy button
* portrait the speaker's picture beside the quote, with the source
*
* `*words*` in the quote mark a phrase: accent in inset and centered, the
* marker in highlight.
*/
export type PullQuoteVariant = 'inset' | 'centered' | 'highlight' | 'portrait';
const TEXTS = {
en: { copy: 'Copy quote', copied: 'Copied', quote: 'Quote' },
de: { copy: 'Zitat kopieren', copied: 'Kopiert', quote: 'Zitat' },
};
type Texts = (typeof TEXTS)['en'];
export interface PullQuoteProps {
variant?: PullQuoteVariant;
/** The quote, `*words*` mark a phrase */
quote: string;
/** Who said it */
cite?: string;
/** Role or place of the speaker */
role?: string;
/** Where it was said, e.g. an interview or a book */
source?: string;
eyebrow?: React.ReactNode;
/** inset: the paragraphs around the quote */
body?: string[];
/** inset: the quote follows this paragraph (0 based), default 0 */
at?: number;
/** portrait: the picture of the speaker */
image?: React.ReactNode;
labels?: Partial<Texts>;
className?: string;
}
/** Splits `*phrase*` out of the text, the phrase goes through `mark` */
function parts(text: string, mark: (s: string, i: number) => React.ReactNode) {
return text.split(/\*([^*]+)\*/g).map((s, i) => (i % 2 ? mark(s, i) : s));
}
const plain = (text: string) => text.replace(/\*([^*]+)\*/g, '$1');
/** The drawn opening mark, in a calm tint of the accent */
function Mark({ className }: { className?: string }) {
return (
<svg aria-hidden viewBox="0 0 64 48" className={cn('h-8 w-auto text-[color-mix(in_oklab,var(--primary)_70%,var(--border-strong))]', className)}>
<path d="M0 48V28C0 12 8 2 24 0l3 7C18 10 14 16 14 24h12v24H0Zm37 0V28C37 12 45 2 61 0l3 7c-9 3-13 9-13 17h12v24H37Z" fill="currentColor" />
</svg>
);
}
/** The source of the quote, a figcaption unless it sits deeper inside one () */
function Cite({ cite, role, source, className, as: Tag = 'figcaption' }: Pick<PullQuoteProps, 'cite' | 'role' | 'source'> & { className?: string; as?: 'figcaption' | 'div' }) {
if (!cite && !role && !source) return null;
return (
<Tag className={cn('text-[14px] leading-snug', className)}>
{cite && <span className="block font-semibold text-foreground">{cite}</span>}
{(role || source) && <span className="mt-1 block text-muted-foreground">{[role, source].filter(Boolean).join(' · ')}</span>}
</Tag>
);
}
const PROSE = 'text-pretty text-[17px] leading-[1.75] text-[color-mix(in_oklab,var(--foreground)_86%,var(--background))] sm:text-[18px]';
/* ------------------------------------------------------------------ */
function Inset(p: PullQuoteProps) {
const body = p.body ?? [];
const at = Math.min(Math.max(0, p.at ?? 0), Math.max(0, body.length - 1));
const accent = (s: string, i: number) => (
<span key={i} className="kit-accent-text">
{s}
</span>
);
const quote = (
<Reveal effect="rise" className="my-10 lg:float-right lg:my-2 lg:-mr-56 lg:ml-10 lg:w-[19rem] xl:-mr-64 xl:w-[21rem]">
<figure className="border-l-2 border-primary pl-6 lg:border-l-0 lg:border-t-2 lg:pl-0 lg:pt-6">
<blockquote className="text-balance text-[clamp(1.375rem,1.2rem+0.6vw,1.625rem)] font-medium leading-[1.25] tracking-[-0.025em] text-foreground">
<p>{parts(p.quote, accent)}</p>
</blockquote>
<Cite cite={p.cite} role={p.role} source={p.source} className="mt-5" />
</figure>
</Reveal>
);
return (
<article className="mx-auto max-w-[36rem] lg:mr-[max(16rem,calc(50%-12rem))] lg:ml-auto xl:mr-[max(18rem,calc(50%-14rem))]">
{p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
<div className={cn(PROSE, '[&>p+p]:mt-6')}>
{body.length === 0 && quote}
{body.map((b, i) => (
<div key={i} className="contents">
<p className={cn(i > 0 && 'mt-6')}>{b}</p>
{i === at && quote}
</div>
))}
</div>
<div className="clear-both" />
</article>
);
}
function Centered(p: PullQuoteProps) {
const text = plain(p.quote);
return (
<figure className="mx-auto max-w-[52rem] text-center">
<Parallax speed={36} className="mx-auto w-fit">
<Mark className="mx-auto mb-8 h-9 sm:h-11" />
</Parallax>
{p.eyebrow && <p className="sr-only">{p.eyebrow}</p>}
<blockquote>
<WordReveal as="p" text={text} stagger={40} duration={700} className="text-balance text-[clamp(1.75rem,1.1rem+2.4vw,3rem)] font-medium leading-[1.14] tracking-[-0.035em] text-foreground" />
</blockquote>
<figcaption className="mt-10">
<Reveal delay={250} className="flex flex-col items-center gap-4">
<span aria-hidden className="h-px w-12 bg-border-strong" />
<Cite as="div" cite={p.cite} role={p.role} source={p.source} className="text-[15px]" />
</Reveal>
</figcaption>
</figure>
);
}
function Highlight(p: PullQuoteProps & { t: Texts }) {
const [ref, inView] = useInView<HTMLQuoteElement>('0px 0px -20% 0px');
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(`„${plain(p.quote)}“${p.cite ? ` (${p.cite})` : ''}`);
setCopied(true);
window.setTimeout(() => setCopied(false), 1800);
} catch {
/* the browser refused, nothing to show */
}
};
const marker = (s: string, i: number) => (
<mark
key={i}
className={cn(
'rounded-[0.2em] bg-transparent bg-no-repeat px-[0.08em] text-foreground [box-decoration-break:clone] transition-[background-size] delay-300 duration-1000 ease-[var(--ease-kit)] motion-reduce:transition-none',
inView ? 'bg-[length:100%_100%]' : 'bg-[length:0%_100%]',
)}
style={{ backgroundImage: 'linear-gradient(color-mix(in oklab, var(--primary) 28%, transparent), color-mix(in oklab, var(--primary) 28%, transparent))' }}
>
{s}
</mark>
);
return (
<div className="mx-auto max-w-[56rem]">
<div className="grid grid-cols-[minmax(0,1fr)] gap-8 sm:grid-cols-[auto_minmax(0,1fr)] sm:gap-10">
<Mark className="h-7 sm:mt-2 sm:h-9" />
<figure>
{p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
<blockquote ref={ref}>
<p className="text-pretty text-[clamp(1.5rem,1.15rem+1.4vw,2.375rem)] font-medium leading-[1.3] tracking-[-0.03em] text-[color-mix(in_oklab,var(--foreground)_80%,var(--background))]">{parts(p.quote, marker)}</p>
</blockquote>
<figcaption className="mt-8 flex flex-col gap-5 border-t border-border pt-6 sm:flex-row sm:items-center sm:justify-between">
<Cite as="div" cite={p.cite} role={p.role} source={p.source} />
<button
type="button"
onClick={copy}
className="inline-flex min-h-11 cursor-pointer items-center gap-2 self-start rounded-lg border border-border bg-background px-4 text-[13px] font-medium text-foreground shadow-[var(--shadow-sm)] transition-colors hover:bg-accent outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-ring sm:self-auto"
>
<Icon name={copied ? 'check' : 'copy'} size={15} className={cn(copied && 'text-success-foreground')} />
<span aria-live="polite">{copied ? p.t.copied : p.t.copy}</span>
</button>
</figcaption>
</figure>
</div>
</div>
);
}
function Portrait(p: PullQuoteProps) {
const accent = (s: string, i: number) => (
<span key={i} className="kit-accent-text">
{s}
</span>
);
return (
<div className="grid grid-cols-[minmax(0,1fr)] items-center gap-10 md:grid-cols-[minmax(0,5fr)_minmax(0,7fr)] md:gap-12 lg:gap-20">
<Reveal effect="scale" className="relative mx-auto w-full max-w-[22rem] md:max-w-none">
<div aria-hidden className="absolute -inset-4 -z-10 rounded-[2rem] bg-[radial-gradient(closest-side,var(--glow),transparent)] blur-2xl" />
<div data-clip-ok className="relative aspect-[4/5] overflow-hidden rounded-2xl border border-border bg-muted shadow-[var(--shadow-lg)] [&>*]:size-full">{p.image}</div>
<span aria-hidden className="absolute -bottom-5 -right-3 flex size-14 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-[var(--shadow-lg)] sm:-right-5">
<Mark className="h-5 text-primary-foreground" />
</span>
</Reveal>
<figure>
{p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
<blockquote>
<Reveal effect="blur">
<p className="text-balance text-[clamp(1.625rem,1.15rem+1.7vw,2.625rem)] font-medium leading-[1.18] tracking-[-0.03em] text-foreground">{parts(p.quote, accent)}</p>
</Reveal>
</blockquote>
<figcaption>
<Reveal delay={200}>
<Cite as="div" cite={p.cite} role={p.role} source={p.source} className="mt-8 border-l-2 border-primary pl-4 text-[15px]" />
</Reveal>
</figcaption>
</figure>
</div>
);
}
/**
* A pull quote: inset beside the text, centered and large, with a sweeping
* marker and copy button, or beside a portrait of the speaker.
* <PullQuote variant="inset" quote="We keep *one day* free." cite="Mira Kade" body={['…', '…']} />
*/
export function PullQuote(props: PullQuoteProps) {
const { variant = 'inset', className, labels } = props;
const t = useTexts(TEXTS, labels);
return (
<Section className={cn(variant === 'centered' && 'border-y border-border bg-surface', className)}>
{variant === 'centered' ? <Centered {...props} /> : variant === 'highlight' ? <Highlight {...props} t={t} /> : variant === 'portrait' ? <Portrait {...props} /> : <Inset {...props} />}
</Section>
);
}Frequent questions
How do I install Pull quote?
With aiw add @ai-website/pull-quote. The command puts components/ui/pull-quote.tsx into your project, the code is yours from then on. It brings along icon, section, reveal, motion and word-reveal. It needs no further npm packages.
Which variants does Pull quote have?
4 variants, Inset, Centered, With marker and With portrait. You pick one with the variant prop, for example variant="centered".
Can I translate the texts of Pull quote?
Yes. Pull quote carries 3 texts of its own in English and German, such as “Copy quote”, “Copied” and “Quote”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Pull quote?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Pull quote with aiw_components and install it with aiw_add @ai-website/pull-quote. Name the variant in your prompt, for example variant="inset", and the assistant writes your content straight into the props.