Drop Cap
The oversized opening letter, floated across N lines with its cap-top and baseline solved from the font's metrics — hangs an opening quotation mark and skips short paragraphs.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/drop-cap.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "DropCap" component: the oversized opening
letter of a paragraph, floated across N lines. No client JS at all — no hooks, no
effects, no measurement, no "use client". The only import beyond React is the
shared cn() class-merge helper.
Contract
- Named export `DropCap`, forwardRef<HTMLElement, DropCapProps>, props extend
React.HTMLAttributes<HTMLElement>:
- as?: "p" | "div" — default "p".
- lines?: number — default 3, rounded and clamped to 2..6. One line would be
an ordinary capital; past six the letter is taller than most paragraphs.
- lineHeight?: number — default 1.6, unitless. The component SETS this on the
root (inline, after the caller's style, so it also beats a leading-* class)
rather than trusting the caller's stylesheet. Every number below is derived
from it, and a cap computed for 1.6 dropped into a paragraph led at 1.9
misses the last baseline by a third of a line. The prop is the only door.
- metrics?: { capHeight?: number; ascent?: number; descent?: number } —
defaults 0.7 / 0.9 / 0.2, all as fractions of the em, descent as a positive
magnitude (accept a negative one, since metrics tables print it that way).
These are facts about the typeface, not taste: look them up once in
capsize's metrics database or the font's OS/2 table.
- hangPunctuation?: boolean — default true.
- minLength?: number — default lines x 40 (see the refusals).
- letterClassName?: string — classes for the letter itself: colour, family,
weight, tracking. Never its size; that is computed.
- Root carries data-slot="drop-cap" plus data-drop-cap="applied" | "skipped";
the letter carries data-slot="drop-cap-letter". The remaining props spread
onto the root, className merges through cn().
- Guard every ratio: reject 0, NaN and negatives and fall back to the default,
because capHeight is a divisor and a negative ascent-descent would invert the
line box.
Geometry — solve it, never guess it
- Write L for the leading, N for the line count, C for capHeight and
D = ascent - descent. All lengths below are in body em.
- A baseline sits L/2 + D/2 below the top of its line box: half-leading plus the
ascent, with the descent pulling the other way. Only the DIFFERENCE of ascent
and descent survives that algebra, which is why one number, not two, decides
where a baseline lands.
- Two alignments are wanted at once — the cap-top on line 1's cap-top, and the
letter's baseline on line N's baseline. That is two equations in one unknown,
and they agree on exactly one size:
font-size = 1 + (N - 1) x L / C
(3 lines at L=1.6 with C=0.7 gives 5.5714em, and the number changes the moment
the leading or the typeface does.)
- Give the float line-height: D (unitless, so it multiplies its own font size).
That puts the bottom of its box exactly on its baseline, which makes the box
height meaningful instead of arbitrary.
- The float's outer top is placed at the top of line 1's line box, so the band
of text it displaces is margin-top + box height. Choose the margin so the band
is L/2 + (N-1)L + D/2 — line N's baseline. It clears line N's own descender
space by (L - D)/2, which is positive for any readable leading, so exactly N
lines shorten and line N+1 never does. In the span's own em that margin is
margin-top = (L/2 + (N-1)L + D/2) / font-size - D
and it may come out slightly negative for a font whose D exceeds its C; a
negative margin on a float is fine and is what the maths asked for.
- Progressive enhancement: inside @supports (text-box-trim: trim-both) set
text-box-trim: trim-both and text-box-edge: cap alphabetic on the letter. The
engine then trims its box to cap-top → baseline using the font's REAL metrics,
and the margin switches to (L/2 + D/2 - C) / font-size, which no longer
depends on D at cap scale. Do not trim the paragraph as well: that would move
the whole block up by its half-leading and make vertical rhythm differ between
engines. With the default metrics both paths compute the same layout, so the
enhancement only ever corrects a cap whose declared numbers were wrong.
- Emit the four computed values as inline custom properties (--drop-cap-size,
--drop-cap-leading, --drop-cap-shift, --drop-cap-shift-trim) and consume them
from Tailwind arbitrary properties. Inline styles would win over the
@supports override; custom properties keep the branch in CSS where it belongs.
Behavior — splitting the text
- React.Children.toArray(children); only a leading STRING can be split. If the
paragraph opens with an element, leave everything alone rather than guessing
where the first letter lives inside someone else's markup.
- One regex takes the string apart: leading whitespace, then any run of opening
punctuation ([\p{Pi}\p{Ps}"'¿¡] — quotes, brackets, guillemets, the Spanish
inverted marks), then ONE grapheme (\P{M}\p{M}*, so a surrogate pair or a
base + combining accent stays whole and "Å" never renders as half a code
point), then the rest.
- Render [whitespace, <span>punctuation + letter</span>, rest, ...other
children]. No wrapper, no second text node, no duplicate copy of the letter.
Behavior — refusals (all silent, all render the plain paragraph)
- The first child is not a string.
- The first grapheme is not a letter or a numeral (\p{L} or \p{N}). A dash, a
bullet or an ellipsis set three lines tall reads as a printing error.
- The measurable text is shorter than minLength. Default lines x 40 characters,
which is roughly what a shortened line holds at an ordinary measure: below it
the letter stops being a landmark and becomes a hole with two words beside it.
Only top-level strings are counted; a paragraph containing elements cannot be
measured, so it is trusted instead of falsely refused. minLength={0} forces.
- Every refusal sets data-drop-cap="skipped" so a test — or a consumer — can see
which branch ran without measuring pixels.
Behavior — the hung opening quotation mark
- A paragraph that opens on a quote has a real problem: ::first-letter includes
the punctuation, so the mark gets blown up to cap size AND pushes the letter
off the column edge. hangPunctuation (default true) keeps the mark in the cap
and hangs it into the margin instead, so the LETTER holds the edge.
- The float is its own block, so Safari's hanging-punctuation: first hangs it
exactly, with no magic number. Everywhere else, ship a negative first-line
indent of one glyph width — text-indent: calc(-1 * var(--drop-cap-hang,
0.35em)) — and revert it to 0 inside @supports (hanging-punctuation: first).
Negative text-indent also shrinks the float's shrink-to-fit width by the same
amount, so the letter still starts at the float's content edge.
- hangPunctuation={false} keeps the mark inside the cap, unhung: the classic
::first-letter look, worth having when the quote is the point.
- When nothing hangs, still set text-indent: 0 on the letter — text-indent is
inherited, and a book-style indented paragraph would otherwise shove it.
Accessibility and copy contract
- The letter is a plain inline span holding the real character. It is NOT
aria-hidden, NOT duplicated into a visually hidden copy, and NOT CSS generated
content with the letter also left in the text — the three ways this component
is usually built, and all three make a screen reader read the word twice or
read a letter that is not there.
- Only an element boundary is inserted; no character is added, removed or
substituted, so textContent, a selection and a real copy all return the
original string. Float changes layout, not DOM order.
- No role, no aria-*, no key handlers: this is body copy, not a control.
- The paragraph gets display: flow-root so it contains its own float. A
paragraph shorter than the cap can then never push the letter down into the
next one.
- Nothing to clean up: no timers, no rAF, no listeners, no observers, no state.
No animation either, so prefers-reduced-motion has nothing to switch off, and
the first server-rendered frame is already the final layout.
Rendering & styling
- Semantic tokens only, and in fact the component ships no colour at all: the
letter inherits currentColor and the caller supplies family, weight and colour
through letterClassName (text-primary, var(--chart-1), font-serif). Nothing to
retheme, nothing to un-theme.
- The gap between the letter and the text is margin-right:
var(--drop-cap-gap, 0.06em) of the CAP's em, so it scales with the letter.
- An accented capital (É, Å) pokes above the cap line, exactly as in print; the
box has no overflow rule, so nothing clips.
- float: left, not float: inline-start — swap both it and the margin for the
logical properties if you ship RTL.
Customization levers
- lines is the loudest knob: 2 for a quiet docs intro, 3 for an article, 4-5 for
a magazine opener. Everything else re-solves itself.
- lineHeight must match the paragraph you want; it is set for you, so change it
here rather than in a stylesheet.
- metrics is how the cap follows the typeface. Set letterClassName="font-serif"
and capHeight together — a serif with a shorter cap needs a taller letter to
reach the same baseline — and expect to touch nothing else.
- --drop-cap-gap and --drop-cap-hang are the two tunables that live in CSS: set
them on any ancestor (an article wrapper, a breakpoint) to tighten the gap or
to match the advance width of a different opening mark (« „ 「).
--drop-cap-hang has no correct default, because it is a fact about YOUR font,
not about this component: it must equal the advance width of the mark being
hung. The shipped 0.35em suits a typical serif quotation mark; Inter's " is
0.441em, so on the default shadcn sans stack the mark under-hangs by ~0.09em
until you set it. Measure once — render the mark in your body font and read
the advance — then set it on the article wrapper. Safari ignores all of this
and hangs the mark exactly, via hanging-punctuation: first.
- minLength is editorial policy, not layout: raise it if your columns are narrow,
set it to 0 if the author is always right.
- Style the letter from outside with [data-slot=drop-cap-letter] when
letterClassName is not reachable (a global stylesheet, a CMS theme).
- To cap a paragraph that opens with an element, hoist the leading text out of
it: the splitter only ever touches a leading string, on purpose.Concepts
- Solved, not guessed — asking for the cap-top on the first line's cap-top and the baseline on the Nth line's baseline is two constraints on one number, and they agree on exactly one font size:
1 + (N-1) x lineHeight / capHeight. Every magicfont-size: 3.5em; margin-top: -0.1emdrop cap on the web is an attempt to hit that number by eye. - The band is what wraps — text flows around the float's margin box, so the number of shortened lines is decided by the float's own
line-heightplus its top margin, not by the letter's size. Setting that line-height toascent - descentputs the box bottom on the baseline and makes the band land exactly on line N, clearing line N+1 by half oflineHeight - (ascent - descent). - Cap height is a fact about the font — Georgia caps are 0.692 of the em, Inter 0.727, Arial 0.716. Swap the family without swapping the metric and the letter sits a hair above or below the line it was measured for, which is precisely the failure people then try to fix with a fudge margin.
- Hang the punctuation, not the letter —
::first-letterswallows a leading quotation mark, blows it up to cap size and pushes the letter off the column edge. The mark is hung into the margin instead: exactly, viahanging-punctuation: firstin Safari, and by a negative first-line indent of one glyph width everywhere else. - Refusal is silence — a paragraph too short to wrap, a first character that is a dash rather than a letter, or a paragraph that opens with an element all render as ordinary body copy with
data-drop-cap="skipped". A drop cap with two words beside it is worse than no drop cap. - One text node, read once — the letter is a plain span holding the real character, never
aria-hiddenwith a hidden duplicate and never generated content: a screen reader reads the paragraph once, and a selection copies the original string because only an element boundary was inserted.
Ruby Text
Ruby annotations for CJK and phonetic glosses — parsed from an inline notation or paired arrays, with a parenthesised fallback, per-annotation alignment and leading reserved up front so hiding the readings never reflows the paragraph.
Text Diff
A word- or sentence-level diff of two prose versions — one unified flow or two columns, with insertions and deletions marked by underline, strikethrough and screen-reader wording instead of colour alone.