a quote as a card and as its link preview image, drawn by one set of rules
npx shadcn add @ag/quoteAlready installed? Preview an update
npx shadcn add @ag/quote --dry-runYour installed source stays unchanged until you update it. How updates work
- A glass quote's surface is a glass
Panel.
also called blockquote, testimonial
shelf social & links
comes with quote-card
uses material, avatar, avatar-portrait, image, panel
used by markdown

I didn't have time to write a short letter, so I wrote a long one instead.
what a quote's own page opens with
Those who would give up essential Liberty, to purchase a little temporary Safety, deserve neither Liberty nor Safety.
Benjamin Franklin1755The man who chases two rabbits, catches neither.
ConfuciusI didn't have time to write a short letter, so I wrote a long one instead.
Mark Twain1876source ↗The purpose of a system is what it does.
Less, but better.
the link preview, drawn in the DOM

Those who would give up essential Liberty, to purchase a little temporary Safety, deserve neither Liberty nor Safety.

The man who chases two rabbits, catches neither.

I didn't have time to write a short letter, so I wrote a long one instead.
The purpose of a system is what it does.
Less, but better.
a quotation inside an article, one step up from the page
Those who would give up essential Liberty, to purchase a little temporary Safety, deserve neither Liberty nor Safety.
Benjamin Franklin1755The man who chases two rabbits, catches neither.
ConfuciusI didn't have time to write a short letter, so I wrote a long one instead.
Mark Twain1876source ↗The purpose of a system is what it does.
Less, but better.
the surface from the nearest MaterialProvider; the card has no glass face, it is its own preview
I didn't have time to write a short letter, so I wrote a long one instead.
Mark Twain1876I didn't have time to write a short letter, so I wrote a long one instead.
Mark Twain1876the same cases, drawn by renderQuoteSvg
Not a component and not on any page: this is what an OG route answers with, at the 1200×630 every platform crops from. It is drawn by renderQuoteSvg, which takes no React, so a build script can write it to disk or a route can return it. Inlined here only so it can be looked at without sharing anything, and next to the card above it, so a drift between them would be visible on this page before it was visible on a shared link.
the demo above, verbatim
import { readFileSync } from "node:fs"
import { join } from "node:path"
import sharp from "sharp"
import { Sample } from "@/app/samples"
import {
type Quote as QuoteData,
type QuoteTone,
renderQuoteSvg,
toneFrom,
} from "@/registry/base-nova/lib/quote-card"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { Quote, type QuoteVariant } from "@/registry/base-nova/ui/quote"
import { QuoteStill } from "@/registry/base-nova/ui/quote-still"
/** Mark Twain, photographed before 1910 and long in the public domain. A real face,
* because the card slides a portrait so its subject clears the dissolve: a landscape in
* that slot proves the layout runs and nothing about whether it works. */
const TWAIN = "/mark-twain.png"
/** What THIS page has: `app/layout.tsx` loads Geist through next/font, so the browser
* resolves it for an inlined still too. A consumer names their own, and a still that
* is rasterized offline needs the face where its rasterizer looks — fontconfig for
* librsvg, a font buffer for next/og. Naming one that is not there is the bug this
* demo exists to not repeat: it does not fail, it substitutes. */
const FACE = "Geist"
/** WHAT THE CARD NEEDS FROM A PORTRAIT'S PIXELS ARRIVES AS A SIDECAR, and this page
* consumes it exactly the way a site would: `public/<name>.json` beside
* `public/<name>.png`, two numbers — where the subject sits and the picture's tone —
* written once by `mise portrait` on the machine that has Vision and sips. Nothing here
* decodes a pixel. Twain is a monochrome photograph, so his ground stays grey; Franklin
* (the Duplessis oil) comes out warm and Confucius (the Wu Daozi rubbing) rosy, because
* a monochrome portrait proves the tone rule does nothing and a rule that does nothing
* cannot be judged. */
type Look = {
tone?: QuoteTone
focus?: number
full?: { src: string; width: number; height: number }
}
function sidecar(publicPng: string): Look {
const json = join(
process.cwd(),
"public",
publicPng.replace(/\.[^.]+$/, ".json"),
)
// The sidecar holds the FACTS - where the subject is, the average colour, the natural
// size - and the tone is derived here by the rule, so a change to the rule needs no
// re-annotation. The size is what the avatar's lightbox needs to fly the portrait home.
const { focus, average, size } = JSON.parse(readFileSync(json, "utf8")) as {
focus: number
average: [number, number, number]
size: [number, number]
}
return {
focus,
tone: toneFrom(...average),
full: { src: publicPng, width: size[0], height: size[1] },
}
}
/** EVERY CASE A CONSUMER WILL HIT, not the three that flatter the layout. Short and long,
* a face and no face, colour and monochrome, a date, a source, an author with neither,
* and no author at all. Each row is rendered at every weight below, so what a weight
* does with a missing part is visible next to what it does with the part present. */
const CASES: readonly [string, QuoteData, Look][] = [
[
"long · warm portrait · date",
{
text: "Those who would give up essential Liberty, to purchase a little temporary Safety, deserve neither Liberty nor Safety.",
author: {
name: "Benjamin Franklin",
href: "#",
avatar: "/benjamin-franklin.png",
full: sidecar("/benjamin-franklin.png").full,
},
date: "1755",
},
sidecar("/benjamin-franklin.png"),
],
[
"short · rosy drawing · no date",
{
text: "The man who chases two rabbits, catches neither.",
author: {
name: "Confucius",
href: "#",
avatar: "/confucius.png",
full: sidecar("/confucius.png").full,
},
},
sidecar("/confucius.png"),
],
[
"medium · monochrome · date · source",
{
text: "I didn't have time to write a short letter, so I wrote a long one instead.",
author: {
name: "Mark Twain",
href: "#",
avatar: TWAIN,
full: sidecar(TWAIN).full,
},
source: "#",
date: "1876",
},
sidecar(TWAIN),
],
[
"short · no face · date",
{
text: "The purpose of a system is what it does.",
author: { name: "Stafford Beer", href: "#" },
date: "2002",
},
{},
],
["no author at all", { text: "Less, but better." }, {}],
]
/** A still cannot fetch, so the face has to arrive as bytes. This is the whole of what
* an OG route does with an avatar, and the reason `renderQuoteSvg` takes a data URI
* rather than a URL: at the moment the preview is drawn there is no browser, no
* network and no origin to resolve `/mark-twain.png` against. The bytes are a JPEG of
* the opaque portrait, a tenth of its PNG: every still carries its face whole, and a
* page of stills (this one, the gallery's card) carries them all. */
async function dataUri(publicPath: string): Promise<string> {
const jpeg = await sharp(join(process.cwd(), "public", publicPath))
.jpeg({ quality: 85 })
.toBuffer()
return `data:image/jpeg;base64,${jpeg.toString("base64")}`
}
// #region twain
/** One quote, its face read from the portrait's sidecar. */
const TWAIN_QUOTE: QuoteData = {
text: "I didn't have time to write a short letter, so I wrote a long one instead.",
author: { name: "Mark Twain", href: "#", avatar: TWAIN },
date: "1876",
}
const TWAIN_LOOK = sidecar(TWAIN)
// #endregion
// #region weight
/** Every case at one weight. */
function AtWeight({ variant }: { variant: QuoteVariant }) {
return (
<div className="space-y-6">
{CASES.map(([label, q, x]) => (
<div key={label} className="space-y-2">
<div className="font-mono text-muted-foreground/60 text-xs lowercase">
{label}
</div>
<Quote {...q} tone={x.tone} focus={x.focus} variant={variant} />
</div>
))}
</div>
)
}
// #endregion
export default async function Demo() {
// The SAME quotes, drawn by both emitters: the weights below in the DOM, and the
// previews by the renderer in quote-card, which runs without React so a build or an
// OG route can draw one. Only the NAME names a face — the words stay on the serif slot
// both surfaces default to; naming Geist here once drew the still in a sans under a
// card in a serif, which is the drift this page exists to show is absent.
const faces = { nameFamily: FACE, fonts: [FACE] }
const stills = await Promise.all(
CASES.map(async ([label, q, x]) => ({
label,
svg: renderQuoteSvg(q, {
...faces,
background: x.tone?.ground,
accent: x.tone?.accent,
focus: x.focus,
avatar: q.author?.avatar ? await dataUri(q.author.avatar) : undefined,
}),
})),
)
return (
<div className="space-y-16">
<Sample name="one" glance title="one quote, as a card" with="twain">
<Quote
{...TWAIN_QUOTE}
tone={TWAIN_LOOK.tone}
focus={TWAIN_LOOK.focus}
variant="card"
/>
</Sample>
<Sample
name="feature"
title="feature"
description="what a quote's own page opens with"
with="weight"
>
<AtWeight variant="feature" />
</Sample>
<Sample
name="card"
title="card"
description="the link preview, drawn in the DOM"
with="weight"
>
<AtWeight variant="card" />
</Sample>
<Sample
name="prose"
title="prose"
description="a quotation inside an article, one step up from the page"
with="weight"
>
<AtWeight variant="prose" />
</Sample>
<Sample
name="glass"
title="prose and feature, in glass"
description="the surface from the nearest MaterialProvider; the card has no glass face, it is its own preview"
with="twain"
>
<MaterialProvider material="glass" scheme="dark">
<div className="grid gap-6 rounded-xl bg-[url(/lightbox/peaks.jpg)] bg-cover bg-center p-8">
<Quote
{...TWAIN_QUOTE}
tone={TWAIN_LOOK.tone}
focus={TWAIN_LOOK.focus}
variant="prose"
/>
<Quote
{...TWAIN_QUOTE}
tone={TWAIN_LOOK.tone}
focus={TWAIN_LOOK.focus}
variant="feature"
/>
</div>
</MaterialProvider>
</Sample>
<Sample
name="still"
title="the still, without react"
description="the same cases, drawn by renderQuoteSvg"
>
<p className="mb-6 max-w-prose text-foreground/60 text-sm">
Not a component and not on any page: this is what an OG route answers
with, at the 1200×630 every platform crops from. It is drawn by{" "}
<code className="font-mono text-xs">renderQuoteSvg</code>, which takes
no React, so a build script can write it to disk or a route can return
it. Inlined here only so it can be looked at without sharing anything,
and next to the card above it, so a drift between them would be
visible on this page before it was visible on a shared link.
</p>
<div className="grid gap-4">
{stills.map(({ label, svg }) => (
<div key={label} className="space-y-2">
<div className="font-mono text-muted-foreground/60 text-xs lowercase">
{label}
</div>
<QuoteStill
svg={svg}
className="overflow-hidden rounded-lg border border-border"
/>
</div>
))}
</div>
</Sample>
</div>
)
}the source, every file an install writes
// Somebody else's words, on the page, at THREE WEIGHTS — and every one of them reads its
// numbers from `quote-card`, the same module that draws the social preview, so there is
// nothing to keep in sync by hand and nothing that can drift when either is touched.
//
// card the poster: the portrait bleeding out of the right edge, the ghost mark, the
// words composed in the band. It IS the link preview, drawn in the DOM.
// feature the page's own weight: no frame, no portrait — the mark, the words set at
// display size over it, one line of attribution. What a quote's own page opens
// with, and the step the other two left missing.
// prose a quotation inside an article, at the page's own type, marked by a small
// mark rather than a rule. What an embed in a feed card is.
//
// WHAT THE THREE CANNOT SHARE WITH THE STILL is measurement. A browser knows exactly how
// wide a character is and the still has no way to find out, so the still estimates `ch`
// and these state the measure in real `ch` and let the layout engine be right. They agree
// on how many characters a line carries; they may disagree about which word ends one.
//
// It renders a real `<blockquote>` with a real `<cite>`, because that is what this is,
// and a screen reader announcing "blockquote" is information the styling cannot carry.
//
// STYLE IS UTILITIES IN THE JSX, merged with `cn` and `className` last, the way every
// shadcn item is, so a consumer overrides a padding by passing a class and never fights
// a stylesheet. The numbers that are not fixed values — everything the card and the
// feature derive from the module's constants (cqw sizes, shares of the frame, the focus
// gradient, the dissolve mask) — arrive as custom properties on `style` and LAND through
// `-(--…)` utilities: `w-(--ag-quote-column)`, `text-(length:--ag-quote-size)`. The
// property is the seam between the arithmetic and the CSS; the class says which
// property each number drives. There is no quote.css.
import { cn } from "@/lib/utils"
import {
ATTRIB_GAP,
assert,
BLOCK_AT,
CAP,
CARD_H,
CARD_W,
DATE_EM,
DESC,
DISSOLVE,
EDGE,
FACE_FEATHER,
FACE_SHARE,
FEATURE_MEASURE,
FOCUS,
GLOW,
GROUND,
glowOf,
grainLayer,
INDENT,
LINE,
MARGIN,
MARK_BLUR,
MARK_BOX,
MARK_EM,
MARK_OPACITY,
MARK_PATH,
NAME_EM,
QUOTE_CH,
type Quote as QuoteData,
type QuoteTone,
quoteClean,
quoteMeasure,
quoteSet,
SIZE_MAX,
} from "@/registry/base-nova/lib/quote-card"
import { Avatar } from "@/registry/base-nova/ui/avatar"
import { AvatarPortrait } from "@/registry/base-nova/ui/avatar-portrait"
import { Image } from "@/registry/base-nova/ui/image"
import type { Material, Scheme } from "@/registry/base-nova/ui/material"
import { QuoteSurface } from "@/registry/base-nova/ui/quote-surface"
export type QuoteVariant = "card" | "feature" | "prose"
export interface QuoteProps extends QuoteData {
/** Which weight. Defaults to `prose`, the one that shouts least. */
variant?: QuoteVariant
/** The picture's own colour, ground and ink. Derived from the pixels by whoever has
* them (`toneFrom`); without it the card is neutral, which is honest — a hue from a
* hash of the author's name is a colour nobody chose, and it lands wherever the hash
* lands. */
tone?: QuoteTone
/** Where the subject sits across the portrait, 0 to 1, so the card's dissolve does
* not eat it. Same number the still takes, from the same detector. */
focus?: number
/** The width the measure is set against, in px. For `card` and `feature`, and only
* worth passing when the quote is not in the usual reading column.
*
* `source` is drawn in `feature` and `prose`, never on the card: the still has
* nowhere to put a link, and the card matching its own preview outranks carrying one
* more affordance. */
width?: number
/** The page's serif's average character advance, as a share of the em — see QUOTE_CH.
* The browser wraps in real `ch`, so this only decides the SIZE; leave it and a
* condensed face is set a third too small for its measure. Pass the same number the
* still gets, so the page and the preview set the same type. */
ch?: number
/** The surface `prose` and `feature` stand on: plain, one step up from the page, or
* the one glass material (`QuoteSurface`); default the nearest MaterialProvider's.
* The card has no glass face: it is its own link preview, drawn in the DOM, and a
* card in glass would be a card its preview does not match. Asked for one, it
* throws. */
material?: Material
/** The colour scheme the surface stands in (material). */
scheme?: Scheme
className?: string
children?: React.ReactNode
}
/** The reading column a quote is set for, matching the site's `max-w-3xl`. */
export const QUOTE_WIDTH = 768
const pct = (n: number) => `${(n * 100).toFixed(3)}%`
/** A hundredth of the CONTAINER's width, whatever property it lands on. `%` means that
* only for horizontal offsets and widths; everywhere else it means something surprising
* — as font-size it is the parent's font-size, as an abspos height the container's
* height. */
const cqw = (n: number) => `${(n * 100).toFixed(3)}cqw`
const unit = 1 / 150
const aspect = CARD_W / CARD_H
/** THE MARK SCALES WITH THE WEIGHT. At the card's size it is a ghost in the picture's
* colour and belongs to the picture; the feature has no picture, so at that size and
* blur it is a smear behind the first line. Half the height, a quarter of the blur, a
* shade fainter: a mark that says "quotation" and then gets out of the way. */
const FEATURE_MARK = 0.5
const FEATURE_BLUR = 0.25
const FEATURE_FILL = 0.8
/** THE WORDS CLIMB INTO THE MARK. No band to centre them in, so they start half way
* down it: the overlap is what gives the card depth, and hanging them below the mark
* reads as two objects stacked. */
const FEATURE_CLIMB = 0.25
/** The words are a QUOTATION and take a reading serif: `--font-quote` (tokens) names
* the face, the same slot the still fills with its `fontFamily`. NOT `--font-serif`: a
* page's serif is usually its heading face, and a display serif drawn for headlines
* (Instrument Serif on adriangalilea.com) was illegible at reading size the moment the
* quote inherited it. */
const WORDS = "m-0 font-quote *:m-0 [&>p+p]:mt-2"
/** The attribution line: one row, the page's muted voice, never italic (a figcaption is
* not a caption in the typographic sense here). */
const BY = "flex items-center not-italic text-muted-foreground"
/** The name is a NAME. Colouring it makes the accent mean "person", which it does not
* mean anywhere else, and pulls the eye off the sentence. */
const NAME = "font-sans not-italic text-foreground/75"
const LINK = "text-inherit no-underline hover:underline"
/** THREE VOICES: the words are a quotation, the name is a person, the date is metadata.
* The still draws the same three; a card disagreeing with its own preview about which
* is which is the drift the shared module exists to prevent. */
const META = "font-mono text-muted-foreground/80"
/** THE MEASURE IS THE RULE, in real `ch`: the browser knows how wide a character of this
* face is and the still can only estimate. Both carry the same characters a line, and
* both BALANCE: `quoteWrap` is `text-wrap: balance` written out, so the card's lines
* fall the way the still's do rather than greedy-with-a-tidy-rag (`pretty`). */
const MEASURED = "*:max-w-(--ag-quote-measure) *:text-balance"
export function Quote({
text,
author,
source,
date,
variant = "prose",
tone,
focus = FOCUS,
width = QUOTE_WIDTH,
ch = QUOTE_CH,
material,
scheme,
className,
children,
}: QuoteProps) {
const words = quoteClean(text)
const body = children ?? <p>{words}</p>
const ink = tone?.accent ?? "currentColor"
// THE SAME LEFT EDGE AT EVERY WEIGHT: the mark and the attribution hang from the
// margin, and only the words step in from it. The padding IS the margin the glyph sits
// at; the words carry the indent themselves.
//
// AT THIS SCALE THE MARK IS A GLYPH, NOT A GHOST. The card's mark is a big soft shape
// the words climb over, and that only works with a block of words to climb; behind a
// single line it read as a smudge under the first word at any size that was tried. So
// it does what an opening quotation mark does in a book: it sits in the gutter to the
// left of the first line, cap-high, sharp, and the words start after it. Not fainter —
// faint at this size is dirt; small and clean is a mark.
//
// ONE STEP UP IN SIZE from the page's type. A serif set at the body size reads smaller
// than the sans around it (a lower x-height, finer strokes), and a display serif on a
// dark ground reads smaller still, so one step up is what keeps the quote legible in
// an article. The mark, sized in em, scales with it; the attribution is metadata and
// keeps the metadata size.
// EVERY SIZE IN EM of the quote's own type, which is one step up from the page's
// (1.125em): the same quote in an article and in a page set small (a file's face) is
// one quote made small, never a small quote in a large frame. At a 16 px page it is
// the 18 px quote with 16 px padding it was drawn as.
if (variant === "prose")
return (
<QuoteSurface
variant="prose"
material={material}
scheme={scheme}
className={cn(
"relative m-0 py-[0.889em] pr-[1.111em] pl-[0.8em] text-[1.125em]",
className,
)}
style={{ "--ag-quote-ink": ink } as React.CSSProperties}
>
<Mark className="left-[0.8em] top-[1.089em] h-[0.95em] opacity-30" />
<blockquote
data-slot="quote-body"
className={cn(WORDS, "ms-[1.2em] text-foreground/92")}
>
{body}
</blockquote>
<By
className={cn(BY, "mt-[0.667em] gap-[0.444em] text-[0.667em]")}
author={author}
date={date}
source={source}
tone={tone}
focus={focus}
face="sm"
/>
</QuoteSurface>
)
if (variant === "feature") {
// The words are set in from the left by the still's INDENT and the mark sits at the
// margin the words are indented FROM, so the two stack the way they do on the card —
// the overlap is the depth. No band to centre in: the block flows, and it starts
// half way down the mark so the words climb into it rather than hang below it.
const column = 1 - unit * INDENT
const { size } = quoteSet(words, width * column, {
ch,
measure: FEATURE_MEASURE,
sizeMax: (width / aspect) * SIZE_MAX,
})
// A container, so the mark and the type scale with the column they are actually in.
// THE WORDS ARE THE INDENTED THING: the mark and the attribution hang from the
// margin, exactly as on the card, and only the quotation steps in from it. Indenting
// the attribution along with them put the avatar under the first letter instead of
// under the mark, and the composition lost its left edge.
const mark = MARK_EM / aspect
return (
<QuoteSurface
variant="feature"
material={material}
scheme={scheme}
className={cn("relative m-0 @container px-8 py-6", className)}
style={
{
"--ag-quote-ink": ink,
"--ag-quote-indent": pct(unit * INDENT),
"--ag-quote-measure": `${quoteMeasure(words.length, FEATURE_MEASURE)}ch`,
"--ag-quote-size": cqw(size / width),
"--ag-quote-mark": cqw(mark * FEATURE_MARK),
"--ag-quote-mark-fill": String(MARK_OPACITY * FEATURE_FILL),
"--ag-quote-soften": cqw(mark * MARK_BLUR * FEATURE_BLUR),
"--ag-quote-climb": cqw(mark * FEATURE_CLIMB),
"--ag-quote-line": String(LINE),
"--ag-quote-gap": cqw(unit * EDGE),
} as React.CSSProperties
}
>
<Mark className="left-8 top-6 h-(--ag-quote-mark) opacity-(--ag-quote-mark-fill) blur-(--ag-quote-soften)" />
<blockquote
data-slot="quote-body"
className={cn(
WORDS,
MEASURED,
"ms-(--ag-quote-indent) pt-(--ag-quote-climb) text-(length:--ag-quote-size) leading-(--ag-quote-line) text-foreground/92",
)}
>
{body}
</blockquote>
<By
className={cn(BY, "mt-(--ag-quote-gap) gap-3 text-sm")}
author={author}
date={date}
source={source}
tone={tone}
focus={focus}
face="md"
/>
</QuoteSurface>
)
}
assert(
material !== "glass",
"the quote card is its own link preview and has no glass face; a glass quote is prose or feature",
)
// THE CARD. Every number below is the still's, recomputed as a share of the frame
// rather than in pixels, so one set of constants drives both surfaces at any size.
//
// BLOCK_AT is the one that cannot cross: placing a block at an arbitrary fraction of
// the slack means knowing how tall it turned out, and here the browser decides that
// after this code has run. Centring is the only fraction the DOM gives for free, so
// the two surfaces agree exactly while BLOCK_AT is a half and would need a measuring
// pass the day it is not.
assert(
BLOCK_AT === 0.5,
`the web card can only centre its words; BLOCK_AT is ${BLOCK_AT}`,
)
const height = width / aspect
// Without a picture the words take the whole frame, exactly as the still gives it to
// them; sizing them for the picture's column anyway set a bare quote 59% small.
const column = author?.avatar
? 1 - FACE_SHARE - unit * (MARGIN + INDENT)
: 1 - unit * (2 * MARGIN + INDENT)
// The band the still composes in, so a quote too long for the frame THROWS here the
// way it throws there. Without it the fixed aspect-ratio box just clipped the words
// mid-sentence, silently — the one thing the module promises never happens.
const nameSize = NAME_EM * width
const band = height - 2 * unit * EDGE * width - (CAP + DESC) * nameSize
const { size } = quoteSet(words, width * column, {
bandH: band,
ch,
sizeMax: height * SIZE_MAX,
})
// The picture is a square as tall as the frame, slid so the subject clears the
// dissolve, and bounded so it neither leaves ground at the right edge nor empties the
// fade. Identical arithmetic to the still's, in fractions of the width.
const faceX = 1 - FACE_SHARE
const featherX = faceX + (1 - faceX) * FACE_FEATHER
const slotX = Math.max(
1 - 1 / aspect,
Math.min(faceX, (featherX + 1) / 2 - focus / aspect),
)
// The mask runs across the PICTURE, so the stops are in its own coordinates.
const into = (x: number) => pct((x - slotX) * aspect)
const dissolve = `linear-gradient(to right, ${DISSOLVE.map(
([at, on]) => `rgb(0 0 0 / ${on}) ${into(faceX + (featherX - faceX) * at)}`,
).join(", ")})`
// A CONTAINER, so every distance inside is a share of the CARD rather than of the
// page: one set of constants drives a 1200px preview and a 480px column without a
// breakpoint anywhere. The ground is two layers, grain over glow — the same filter the
// still runs, tiled, so the glow does not band into rings in eight bits.
return (
<figure
data-slot="quote"
data-variant="card"
className={cn(
"relative m-0 @container aspect-(--ag-quote-aspect) overflow-hidden rounded-lg bg-(image:--ag-quote-ground) text-foreground",
className,
)}
style={
{
"--ag-quote-ground": `${grainLayer()}, radial-gradient(${GLOW.r * 100}% ${GLOW.r * 100}% at ${GLOW.cx * 100}% ${GLOW.cy * 100}%, ${glowOf(tone?.ground ?? GROUND).join(", ")})`,
"--ag-quote-ink": ink,
"--ag-quote-aspect": `${CARD_W} / ${CARD_H}`,
"--ag-quote-margin": pct(unit * MARGIN),
"--ag-quote-indent": pct(unit * (MARGIN + INDENT)),
"--ag-quote-edge": pct(unit * EDGE * aspect),
"--ag-quote-floor": pct(
unit * EDGE * aspect + (CAP + DESC) * NAME_EM * aspect,
),
"--ag-quote-column": pct(column),
"--ag-quote-measure": `${quoteMeasure(words.length)}ch`,
"--ag-quote-size": cqw(size / width),
"--ag-quote-name": cqw(NAME_EM),
"--ag-quote-by-gap": cqw(NAME_EM * ATTRIB_GAP),
"--ag-quote-date": cqw(DATE_EM),
"--ag-quote-mark": cqw(MARK_EM / aspect),
"--ag-quote-mark-fill": String(MARK_OPACITY),
"--ag-quote-soften": cqw((MARK_EM / aspect) * MARK_BLUR),
"--ag-quote-line": String(LINE),
"--ag-quote-slot": pct(slotX),
"--ag-quote-square": cqw(1 / aspect),
"--ag-quote-dissolve": dissolve,
} as React.CSSProperties
}
>
{author?.avatar && (
// THE BACKGROUND IS TWO THINGS AND ONLY TWO: the ground and the picture, with a
// dissolve between them. The picture is a square as tall as the frame, never
// cropped sideways — SLID so its subject clears the fade, and the mask hides the
// rest. Sized in cqw on BOTH axes: a replaced element with `height: 100%` inside
// a box whose height comes from `aspect-ratio` is a case Safari resolves as
// indefinite (it collapsed to a strip along the top edge); a square whose side is
// stated outright is the same number in every engine.
// Decorative: the name below it is the accessible attribution.
<Image
data-slot="quote-portrait"
className="absolute top-0 left-(--ag-quote-slot) size-(--ag-quote-square) max-w-none object-cover mask-(--ag-quote-dissolve)"
src={author.avatar}
alt=""
/>
)}
{/* The ghost, in the picture's own colour so it belongs to the photograph rather
than to the type. It takes no space: the words are composed over it. */}
<Mark className="left-(--ag-quote-margin) top-(--ag-quote-edge) h-(--ag-quote-mark) opacity-(--ag-quote-mark-fill) blur-(--ag-quote-soften)" />
{/* The words are CENTRED in the band the mark and the attribution leave, so the air
around them never depends on how long the quote is. The band's floor is the
attribution's CAP, not its baseline: the still reserves (CAP+DESC)·nameSize
below the band. The column IS --ag-quote-column: quoteSet was fed exactly this
width, and any arithmetic on top of it re-wraps the lines the size was chosen
for. */}
<blockquote
data-slot="quote-body"
className={cn(
WORDS,
MEASURED,
"absolute left-(--ag-quote-indent) top-(--ag-quote-edge) bottom-(--ag-quote-floor) flex w-(--ag-quote-column) flex-col justify-center text-(length:--ag-quote-size) leading-(--ag-quote-line) text-foreground",
)}
>
{body}
</blockquote>
{/* ONE LINE at the foot, as far off the floor as the mark hangs from the ceiling —
a matched pair holding the frame — and sized off the FRAME, never off the
quote, so a set of cards has one caption. */}
<By
className={cn(
BY,
"absolute left-(--ag-quote-margin) bottom-(--ag-quote-edge) m-0 gap-(--ag-quote-by-gap) text-(length:--ag-quote-name)",
)}
nameClassName="text-[length:inherit]"
dateClassName="text-(length:--ag-quote-date)"
author={author}
date={date}
/>
</figure>
)
}
/** THE MARK IS THE SIGNAL AT EVERY WEIGHT, and a rule down the side is not. A rule is
* what every blockquote on the web does; the mark is what makes this one the same object
* as its card. One drawing, shipped as an outline; each weight places and scales it. */
function Mark({ className }: { className: string }) {
return (
<svg
data-slot="quote-mark"
className={cn("absolute w-auto fill-(--ag-quote-ink)", className)}
viewBox={`0 0 ${MARK_BOX.w} ${MARK_BOX.h}`}
aria-hidden="true"
>
<path d={MARK_PATH} />
</svg>
)
}
/** ONE LINE: the name, then the date, then — where the weight has room for it — the
* source. The name is a NAME and stays in the page's own colour: colouring it makes the
* accent mean "person", which it means nowhere else, and pulls the eye off the
* sentence. */
function By({
className,
nameClassName,
dateClassName,
author,
date,
source,
tone,
focus,
face,
}: Pick<QuoteData, "author" | "date" | "source"> & {
className: string
/** The card sets the name and the date off the FRAME; the other weights off the page. */
nameClassName?: string
dateClassName?: string
tone?: QuoteTone
focus?: number
/** Which rung the face is drawn at; none, and there is no face. */
face?: "sm" | "md"
}) {
if (!author && !date && !source) return null
return (
<figcaption data-slot="quote-by" className={className}>
{face &&
author?.avatar &&
// The same face as everywhere else on a page, from the same two facts the card
// reads: positioned on the subject, ringed in the picture's colour; and a
// lightbox trigger when the portrait is worth a look.
(author.full ? (
<AvatarPortrait
src={author.avatar}
alt={author.name}
size={face}
focus={focus}
tone={tone}
full={author.full}
/>
) : (
<Avatar
src={author.avatar}
alt={author.name}
size={face}
focus={focus}
tone={tone}
/>
))}
{author && (
<cite data-slot="quote-name" className={cn(NAME, nameClassName)}>
{author.href ? (
<a className={LINK} href={author.href}>
{author.name}
</a>
) : (
author.name
)}
</cite>
)}
{date && (
<span
data-slot="quote-date"
className={cn(META, "text-xs", dateClassName)}
>
{date}
</span>
)}
{source && (
<a
data-slot="quote-source"
className={cn(META, LINK, "ms-auto text-xs")}
href={source}
target="_blank"
rel="noopener noreferrer"
>
source ↗
</a>
)}
</figcaption>
)
}"use client"
import type * as React from "react"
import { cn } from "@/lib/utils"
import {
type Material,
type Scheme,
useMaterial,
useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import { schemeOf } from "@/registry/base-nova/ui/material-glass"
import { Panel } from "@/registry/base-nova/ui/panel"
// The surface a quote stands on in `prose` and `feature`, and nothing else: the one
// part of `Quote` that asks the page a question (which material, which scheme), so
// it is the one part that runs on the client. The words, the mark and the
// arithmetic that sets them stay on the server, where `quote-card`'s rules run once.
/** ONE STEP UP FROM THE PAGE: an embed is a thing quoted INTO the article, and a
* surface is what says so: the same words flat on the page read as the author's
* own. Plain, a mix off the foreground rather than a theme token, so it is one step
* up on any ground without a variable to wire. Glass, a glass Panel, rounder. */
const PLAIN = "rounded-xl bg-foreground/4"
export function QuoteSurface({
variant,
material,
scheme,
className,
style,
children,
}: {
variant: "prose" | "feature"
material?: Material
/** The colour scheme it stands in (material): default the nearest provider's. */
scheme?: Scheme
className?: string
style?: React.CSSProperties
children: React.ReactNode
}) {
const m = useMaterial(material)
const [s, here] = useSurfaceScheme<HTMLElement>(scheme)
// Glass is the one glass container's (Panel), its body, spill and edge.
if (m === "glass")
return (
<Panel
render={<figure />}
ref={here}
data-slot="quote"
data-variant={variant}
material="glass"
scheme={s}
radius="1rem"
bodyClassName="block gap-0 p-0"
className={className}
style={style}
>
{children}
</Panel>
)
return (
<figure
ref={here}
data-slot="quote"
data-variant={variant}
data-material={m}
className={cn(PLAIN, schemeOf(s), className)}
style={style}
>
{children}
</figure>
)
}"use client"
// A still from `renderQuoteSvg`, inlined in a page, with its noise on the page's pixels.
//
// The markup is right for the raster it was drawn for, one still unit to one pixel. On a
// page the still is shown at whatever width its column gives it, and the grain and the
// dissolve's dither are speckles one DEVICE pixel wide: drawn at any other scale they
// are resampled, averaged away, and the glow and the dissolve band. Only the page knows
// the width and the pixel ratio, so this is where the tile is put on them (`GRAIN_FIT`
// in quote-card is the contract), again on every resize.
import { useEffect, useRef } from "react"
import { cn } from "@/lib/utils"
import { assert, GRAIN_FIT } from "@/registry/base-nova/lib/quote-card"
/** One device pixel per tile pixel, the tile's origin on a device pixel. Setting an
* attribute on two patterns per resize: the browser re-rasterizes one tile per scale,
* which it does on a resize anyway, and composites the bitmap on every other paint. */
function fitGrain(still: SVGSVGElement): void {
const box = still.getBoundingClientRect()
// A still that is not laid out (display: none, a closed panel) has no pixels to fit.
if (box.width === 0) return
const dpr = window.devicePixelRatio
const scale =
(GRAIN_FIT.fine * still.viewBox.baseVal.width) / (box.width * dpr)
assert(
scale >= 1,
`a still ${Math.round(box.width * dpr)} device pixels wide is finer than its noise's user space carries (${GRAIN_FIT.fine}x): WebKit would stretch the tile and the grain would average away. Raise GRAIN_FIT.fine`,
)
const dx = -((box.left * dpr) % 1)
const dy = -((box.top * dpr) % 1)
for (const pattern of still.querySelectorAll(GRAIN_FIT.selector))
pattern.setAttribute(
"patternTransform",
`scale(${scale}) translate(${dx} ${dy})`,
)
}
export function QuoteStill({
svg,
className,
}: {
/** `renderQuoteSvg`'s output. */
svg: string
className?: string
}) {
const ref = useRef<HTMLDivElement>(null)
// biome-ignore lint/correctness/useExhaustiveDependencies: a new `svg` replaces the <svg> element the observer watches, so the effect must follow it
useEffect(() => {
const still = ref.current?.querySelector("svg")
assert(still, "QuoteStill was given no <svg>: pass renderQuoteSvg's output")
const observer = new ResizeObserver(() => fitGrain(still))
observer.observe(still)
return () => observer.disconnect()
}, [svg])
return (
<div
ref={ref}
data-slot="quote-still"
className={cn("[&>svg]:block [&>svg]:h-auto [&>svg]:w-full", className)}
// biome-ignore lint/security/noDangerouslySetInnerHtml: renderQuoteSvg escapes every value it interpolates
dangerouslySetInnerHTML={{ __html: svg }}
/>
)
}