Text

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.

Preview in your theme

Loading preview…

import * as React from "react"

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

export type DropCapTag = "p" | "div"

/**
 * Metrics of the paragraph's typeface, as fractions of the em. These are facts
 * about the font, not taste — look them up once (capsize's metrics database, or
 * the font's own OS/2 table) and pass them in.
 *
 * Only `ascent - descent` is ever used: that difference is what puts the
 * baseline inside a line box, and it is the only reason a 5.6em letter and a 1em
 * line can be made to share a baseline without measuring anything at runtime.

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/drop-cap.json

Prompt

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 magic font-size: 3.5em; margin-top: -0.1em drop 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-height plus its top margin, not by the letter's size. Setting that line-height to ascent - descent puts the box bottom on the baseline and makes the band land exactly on line N, clearing line N+1 by half of lineHeight - (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-letter swallows 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, via hanging-punctuation: first in 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-hidden with 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.

On This Page