defuss-shadcn / Primitives / typography
TypographyATM
Styles for headings, paragraphs, lists, blockquotes, and inline code. Built on native HTML text elements with utility classes. No JavaScript required.
On this page (30)
§Headings
Four heading levels - h1 is extrabold, h2 includes a bottom border, h3 and h4 are semibold.
§Prose Composition
All typography classes combined in a full prose layout - matching the shadcn/ui demo.
§RTL support
Blockquotes use logical properties (border-inline-start, padding-inline-start) - they flip automatically in RTL contexts.
§Chinese
lang='zh-Hans' switches the paragraph to CJK rules: justified between characters, strict line breaks (no 。or , at a line start) and a line-end 。 or , kept inside a straight right edge (Safari hangs it, Chromium trims its blank half). Latin words and digits get their thin gap automatically (text-autospace); headings drop the Latin tracking, emphasis uses dots under the characters instead of italics.
§Chinese: aligning the line ends
The same paragraph twice in a narrow column. Left: start-aligned, the lines end ragged and a comma may be pushed to the next line's start. Right: the .p rules - justified, strict breaking, the line-end 。/, hung (Safari) or half-width (Chromium) - a straight edge.
§Japanese, top to bottom
text-vertical sets tategaki: columns run top to bottom, right to left. Ruby (furigana) sits beside the kanji, .text-upright stands a two-digit number upright in one cell (tate-chu-yoko), emphasis marks become sesame dots, and the heading, paragraph gap and blockquote rule turn with the text - they are logical properties.
§Traditional Chinese, vertical
The same vertical rules for zh-Hant: punctuation takes its vertical form (。、「」 rotate or reposition), bopomofo / pinyin ruby sits to the right.
§Typesetting (Satz)
Continuous text set like a book or a journal page: one .typeset block, plain paragraphs inside, and each classic rule of the compositor's craft an opt-in attribute. The examples show every rule on its own first, then captions, separators, leading and figure groups, and only then pages that combine them. The --typeset-* properties tune each block; they are this component's knobs, not theme tokens, so a theme stays the tweakcn token set.
§Columns
data-columns='2' sets two columns, data-rule a hairline between them. No column is narrower than --typeset-column-width (18rem): switch the preview to Phone and the block sets one column - no media query. A .typeset-span heading or figure crosses both columns, and the columns below it start level.
§Columns in a sans and a monospace face
The setting is not tied to the serif: the same two ruled columns in the theme's sans (left) and monospace (right) stacks - a technical note or a listing's commentary. The monospace column hyphenates less (hyphens: manual) and keeps the figures lining.
§Blocksatz (justified)
data-align='justify' justifies the lines and hyphenates long words by the rules of the block's language (lang='en'); the last line of each paragraph stays flush to the start. Headings are never justified: they stay flush start, unhyphenated.
§Einzug (indent)
data-indent: paragraphs follow without a gap and indent their first line by --typeset-indent (1em). The first paragraph after a heading starts flush (stumpf) - the heading has already opened it.
§Initiale: drop
data-initial='drop' sinks the first letter three lines deep (initial-letter); its top is level with the top of the first line, and --typeset-initial-gap keeps it clear of the text to its right.
§Initiale: raised
data-initial='raised' stands the letter on the first baseline and lets it rise above the line - here two lines tall (--typeset-initial-lines) in chart colour 1 (--typeset-initial-color).
§Opening line: small capitals or capitals
data-lead='smallcaps' sets the first line in small capitals (Kapitälchen); data-lead='caps' in letterspaced capitals (Versalien). Either opens a chapter without an initial.
§Classic ligatures and old-style figures
data-ligatures='classic' turns on discretionary and historical ligatures (ct, st) and old-style figures (1450, 1469) - where the font carries the features; a font without them sets the plain forms.
§Leading: tight, normal, loose
data-leading moves the line height and the tracking together: tight (1.45, -0.01em) for dense columns, the default (1.65), loose (1.9, +0.01em) for airy reading. Tracking that follows the leading keeps the grey of the column even.
§Separators
A pause in the text is the Separator component - a short rule, a glyph between lines, or a bare glyph (data-line='none'): asterism, dinkus, fleuron. aria-hidden marks the ones that only decorate. Inside .typeset each keeps --typeset-gap from the text, like a figure.
§Numbered captions: picture, table, video, chart
.typeset-numbered numbers every .typeset-caption in order - figures (pictures, videos, charts) and tables on their own counters. Captions are flush start (picture), centred (table, data-align='center') or justified (video, chart, data-align='justify'). Every figure keeps --typeset-gap from the text.
§Figure groups: two charts; a chart, a picture and a table; cards
figure.typeset-group sets two or three figures side by side with lettered sub-captions and one numbered caption; subgrid rows keep the captions of a row level. First: two charts, divided by a hairline, media centred (data-divided, data-align='middle'). Second: a chart, a picture and a table in one box. Third: data-variant='cards' - each figure its own card. Narrow the preview: they stack, and the hairlines run between the rows instead.
§Combined
The rules compose. A book page puts them all together; the same page without the initial is the quieter setting the technical report on the Modern Paper page uses.
§Book page
All rules at once: two ruled columns, Blocksatz, a drop initial, the opening line in capitals (beside an initial the small-caps lead sets capitals, so the line's top meets the initial's), Einzug, classic ligatures. Narrow the preview: below two columns of 18rem the block sets one.
§Book page without an initial
The same rules without data-initial: Blocksatz, Einzug, the opening line in small capitals, classic ligatures - a quieter opening for a report or a paper, where a large letter would compete with the title. The Modern Paper's technical report sets its sections this way.
§CSS view file
/* -- Typography component --------------------------------------- *//* -- Base text ---------------------------------------------- *//* Sets the default font size for the entire page. *//* All typography classes are sized relative to this baseline. */@layer base { body { font-size: 0.875rem; line-height: 1.5; }}@layer components { /* -- Headings ---------------------------------------------- */ .h1 { scroll-margin-top: 5rem; font-size: 2.25rem; font-weight: 800; letter-spacing: -0.025em; line-height: 1.15; text-wrap: balance; color: var(--foreground); } .h2 { scroll-margin-top: 5rem; font-size: 1.875rem; font-weight: 600; letter-spacing: -0.02em; line-height: 1.25; text-wrap: balance; color: var(--foreground); border-block-end: 1px solid var(--border); padding-block-end: 0.5rem; &:first-child { margin-block-start: 0; } } .h3 { scroll-margin-top: 5rem; font-size: 1.5rem; font-weight: 600; letter-spacing: -0.01em; line-height: 1.3; text-wrap: balance; color: var(--foreground); } .h4 { scroll-margin-top: 5rem; font-size: 1.25rem; font-weight: 600; line-height: 1.4; text-wrap: balance; color: var(--foreground); } /* -- Paragraph --------------------------------------------- */ .p { font-size: 0.875rem; line-height: 1.75; text-wrap: pretty; color: var(--foreground); &:not(:first-child) { margin-block-start: 1.5rem; } } /* -- Lead --------------------------------------------------- */ .lead { font-size: 1.25rem; line-height: 1.6; color: var(--muted-foreground); text-wrap: pretty; } /* -- Large -------------------------------------------------- */ .large { font-size: 1.125rem; font-weight: 600; color: var(--foreground); } /* -- Small -------------------------------------------------- */ .small { font-size: 0.875rem; font-weight: 500; line-height: 1; color: var(--foreground); } /* -- Muted -------------------------------------------------- */ .muted { font-size: 0.875rem; color: var(--muted-foreground); } /* -- Blockquote --------------------------------------------- */ .blockquote { margin-block-start: 1.5rem; border-inline-start: 2px solid var(--border); padding-inline-start: 1.5rem; font-style: italic; hanging-punctuation: first last; color: var(--foreground); & p { margin: 0; } } /* -- Inline code -------------------------------------------- */ .inline-code { position: relative; border-radius: var(--radius-sm); background: var(--muted); padding: 0.2rem 0.3rem; font-family: var(--font-mono); font-size: 0.875em; font-weight: 600; color: var(--foreground); } /* -- CJK: Chinese, Japanese, Korean ------------------------------ Keyed on the lang attribute, so one set of classes serves every script. Latin tuning is undone (negative tracking, faux italics); paragraphs justify between characters with strict line breaking (kinsoku: no 。 or , at a line start), and sentence-end punctuation may hang past the edge (Safari: hanging-punctuation) or trims to half width (Chromium: text-spacing-trim) so the justified edge stays straight. text-autospace adds the thin gap between CJK and Latin / digits that authors used to type by hand. */ :is(.h1, .h2, .h3, .h4, .lead, .large):is(:lang(zh), :lang(ja), :lang(ko)), :is(:lang(zh), :lang(ja), :lang(ko)) :is(.h1, .h2, .h3, .h4, .lead, .large) { letter-spacing: 0; } /* Japanese headings break between phrases (bunsetsu), not mid-word */ :is(.h1, .h2, .h3, .h4, .lead):lang(ja), :lang(ja) :is(.h1, .h2, .h3, .h4, .lead) { word-break: auto-phrase; } :is(.p, .lead, .blockquote):is(:lang(zh), :lang(ja), :lang(ko)), :is(:lang(zh), :lang(ja), :lang(ko)) :is(.p, .lead, .blockquote) { line-height: 1.9; line-break: strict; text-autospace: normal; } :is(.p, .blockquote):is(:lang(zh), :lang(ja)), :is(:lang(zh), :lang(ja)) :is(.p, .blockquote) { text-align: justify; text-justify: inter-character; /* the line-end 。,: Safari hangs it past the edge; Chromium trims the fullwidth glyph's blank half (text-spacing-trim) - either way the justified edge stays straight */ hanging-punctuation: allow-end; text-spacing-trim: normal; } /* CJK has no italics - a slanted Han character is a synthetic oblique */ .blockquote:is(:lang(zh), :lang(ja), :lang(ko)), :is(:lang(zh), :lang(ja), :lang(ko)) .blockquote { font-style: normal; } /* -- Vertical text (tategaki / 直排) ---------------------------------- .text-vertical sets top-to-bottom lines flowing right to left. Latin runs lie sideways (text-orientation: mixed); .text-upright stands a short run (a two-digit number) upright in one cell - tate-chu-yoko. The spacing above uses logical properties, so headings, paragraphs and the blockquote rule turn with the text. Give the block a height. */ .text-vertical { writing-mode: vertical-rl; text-orientation: mixed; } .text-upright { text-combine-upright: all; } /* -- Emphasis marks (圈点 / 傍点) --------------------------------------- Dots beside each character instead of italics: over the text in Japanese (right of it when vertical), under it in Chinese. */ .text-emphasis { font-style: normal; text-emphasis: filled dot; text-emphasis-position: over right; } .text-emphasis:lang(ja) { text-emphasis-style: filled sesame; } .text-emphasis:lang(zh) { text-emphasis-position: under right; } /* ruby annotations (furigana, pinyin, zhuyin) read as small, muted type */ :is(.p, .lead, .blockquote, .h1, .h2, .h3, .h4) rt { font-size: 0.5em; color: var(--muted-foreground); letter-spacing: 0; } /* -- Typesetting (Satz): book and journal setting ---------------------- .typeset holds continuous text set like a book or a journal page. Each classic rule is an opt-in data attribute, tuned by a --typeset-* knob - component properties, not theme tokens (the theme set stays the tweakcn shape): data-columns="2|3" columns never narrower than --typeset-column-width: a phone gets one data-rule a hairline between the columns data-align="justify" Blocksatz: justified and hyphenated (set lang=""), the last line flush start data-indent Einzug: paragraphs follow without a gap and indent their first line; the first one after a heading starts flush (stumpf) data-initial="drop|raised" Initiale over --typeset-initial-lines lines data-lead="smallcaps|caps" the opening line in small capitals or in letterspaced capitals (Versalien) data-ligatures="classic" discretionary and historical ligatures, old-style figures - where the font has them data-leading="tight|loose" compact or airy lines, the tracking matched .typeset-span spans every column (a heading, a figure). Figures, tables, quotes, groups and Separator breaks keep --typeset-gap from the text - the Separator component draws the break, this block only spaces it. VERIFIED: (typography.e2e, the typeset checks) every rule as a computed style, the gaps, the captions' counters, 44rem two columns vs 20rem one. */ .typeset { --typeset-measure: 68ch; --typeset-column-width: 18rem; --typeset-column-gap: 2.5em; --typeset-indent: 1em; --typeset-leading: 1.65; --typeset-tracking: 0em; --typeset-gap: 1.5em; --typeset-initial-lines: 3; --typeset-initial-gap: 0.3em; --typeset-initial-font: var(--font-serif); --typeset-initial-color: var(--primary); max-inline-size: var(--typeset-measure); line-height: var(--typeset-leading); letter-spacing: var(--typeset-tracking); text-wrap: pretty; hanging-punctuation: first allow-end last; font-kerning: normal; orphans: 2; widows: 2; & > p { margin-block: 0 1em; } /* every block keeps the same distance from the text around it */ & > :is(figure, table, blockquote, pre, hr, .separator, .typeset-group, .chart, .diagram, .mockup-code, .table-container) { margin-block: var(--typeset-gap); } /* a figure spans the measure (the browser indents it 40px by default) */ & > :is(figure, blockquote) { margin-inline: 0; } & > :is(h1, h2, h3, h4, .h1, .h2, .h3, .h4) { margin-block: var(--typeset-gap) 0.5em; } & > :first-child { margin-block-start: 0; } & :is(figure, blockquote, table, pre, .typeset-group) { break-inside: avoid; } & :is(h1, h2, h3, h4, .h1, .h2, .h3, .h4) { break-after: avoid; } & .typeset-span { column-span: all; } /* the columns below a spanning heading or figure start level: the first block keeps no top margin - across the column break it cannot collapse into the spanner's, so a subheading sat lower than the text beside it */ &[data-columns] > .typeset-span + :not(.typeset-span) { margin-block-start: 0; } &[data-columns] { max-inline-size: none; column-gap: var(--typeset-column-gap); } &[data-columns="2"] { columns: 2 var(--typeset-column-width); } &[data-columns="3"] { columns: 3 var(--typeset-column-width); } &[data-rule] { column-rule: 1px solid var(--border); } &[data-align="justify"] { text-align: justify; text-align-last: start; hyphens: auto; hyphenate-limit-chars: 6 3 2; /* text-align-last inherits: a centered caption inside a justified setting kept its last line flush left (a two-line figure caption looked off-center) - what centers itself takes its last line from its own alignment */ & :where(figcaption, caption, .typeset-caption) { text-align-last: auto; } } /* leading and tracking move together: tight lines close up a little, airy lines open up a little, so the grey of the column stays even */ &[data-leading="tight"] { --typeset-leading: 1.45; --typeset-tracking: -0.01em; } &[data-leading="loose"] { --typeset-leading: 1.9; --typeset-tracking: 0.01em; } &[data-indent] > p { margin-block: 0; } &[data-indent] > p + p { text-indent: var(--typeset-indent); } /* text-indent inherits into an inline-block, which then indents ITS first line: a badge, a key cap or an epistemic tag at the start of an indented paragraph grew a blank inside its left edge */ &[data-indent] > p :where(.badge, kbd, .paper-tag) { text-indent: 0; } /* the gap is a margin: Chromium ignores logical padding on ::first-letter (VERIFIED: probe 2026-10-07 - padding-inline-end computed 0, margin-inline-end 2.52px) */ &[data-initial] > p:first-of-type::first-letter { margin-inline-end: var(--typeset-initial-gap); font-family: var(--typeset-initial-font); font-weight: 600; color: var(--typeset-initial-color); } &[data-initial="drop"] > p:first-of-type::first-letter { -webkit-initial-letter: var(--typeset-initial-lines); initial-letter: var(--typeset-initial-lines); } &[data-initial="raised"] > p:first-of-type::first-letter { -webkit-initial-letter: var(--typeset-initial-lines) 1; initial-letter: var(--typeset-initial-lines) 1; } &[data-lead="smallcaps"] > p:first-of-type::first-line { font-variant-caps: all-small-caps; letter-spacing: 0.04em; } /* letterspaced capitals - and the small-caps lead beside an initial: a first line in small capitals moves the initial down a line (Chromium), capitals keep its top level with the line's */ &:is([data-lead="caps"], [data-initial][data-lead="smallcaps"]) > p:first-of-type::first-line { font-variant-caps: normal; text-transform: uppercase; letter-spacing: 0.06em; } &[data-ligatures="classic"] { font-variant-ligatures: common-ligatures discretionary-ligatures historical-ligatures contextual; font-variant-numeric: oldstyle-nums proportional-nums; } } /* a heading is never justified: a wrapping heading stretched to the measure opened wide gaps between its words. Flush start and unhyphenated - zero specificity, so an alignment the heading sets itself still wins */ :where(.typeset[data-align="justify"]) :where(h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4) { text-align: start; text-align-last: auto; hyphens: manual; } /* no initial-letter (Firefox): a floated letter as tall as the lines */ @supports not ((initial-letter: 3) or (-webkit-initial-letter: 3)) { .typeset[data-initial] > p:first-of-type::first-letter { float: inline-start; font-size: calc(var(--typeset-initial-lines) * var(--typeset-leading) * 1em * 0.82); line-height: 0.8; padding-block-start: 0.08em; } } /* -- Captions (Bildunterschriften), numbered in order ------------------ .typeset-caption on a figcaption (a picture, a video, a chart) or a table's caption. Inside .typeset-numbered, figures and tables count on their own: "Figure 1.", "Table 1." - the words are --typeset-figure-label and --typeset-table-label ("" leaves the bare number: "1."). A caption is flush start; data-align="justify" or "center" sets it otherwise. */ .typeset-numbered { --typeset-figure-label: "Figure "; --typeset-table-label: "Table "; counter-reset: typeset-figure typeset-table; } .typeset-caption { margin-block-start: 0.6em; color: var(--muted-foreground); font-size: 0.875em; line-height: 1.5; text-align: start; text-align-last: auto; text-wrap: pretty; caption-side: top; &[data-align="justify"] { text-align: justify; text-align-last: start; hyphens: auto; } &[data-align="center"] { text-align: center; text-wrap: balance; } table > & { margin-block: 0 0.6em; } } .typeset-numbered :is(figcaption.typeset-caption)::before { counter-increment: typeset-figure; content: var(--typeset-figure-label) counter(typeset-figure) ". "; color: var(--foreground); font-weight: 600; } .typeset-numbered :is(caption.typeset-caption, figure:has(> table) > figcaption.typeset-caption, figure:has(> .table-container) > figcaption.typeset-caption)::before { counter-increment: typeset-table; content: var(--typeset-table-label) counter(typeset-table) ". "; } /* -- Figure groups: several figures in one box --------------------------- .typeset-group sets two or three figures (charts, pictures, tables) side by side; each figure's caption is lettered "(a)", "(b)" (.typeset-subcaption), the group's own figcaption numbers the whole. Every figure spans two subgrid rows - media, then caption - so the captions of a row sit at one height whatever the media's height. data-align="middle" centres the media in their row; data-divided draws hairlines between the figures - the 1px grid gap over a border-coloured background, so the lines run between columns and, once the figures stack, between rows, with no container query (a size container would scope the caption counters to the group); data-variant="cards" gives each figure its own card instead of one box. Narrow pages stack them. VERIFIED: (typography.e2e "figure groups") captions level, the 1px lines between columns and between stacked rows, the cards' borders. */ .typeset-group { --typeset-group-min: 12rem; --typeset-group-gap: 1.25rem; display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--typeset-group-min)), 1fr)); gap: var(--typeset-group-gap); margin-inline: 0; padding: 1rem; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--card); counter-reset: typeset-sub; & > figure { display: grid; grid-row: span 2; grid-template-rows: subgrid; row-gap: 0.4em; align-content: start; margin: 0; min-inline-size: 0; } & > figcaption { grid-column: 1 / -1; margin-block-start: 0.25em; } & img, & video { display: block; inline-size: 100%; block-size: auto; border-radius: var(--radius-md); } &[data-align="middle"] > figure > :first-child { align-self: center; } &[data-variant="cards"] { padding: 0; border: 0; background: none; & > figure { padding: 0.875rem; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--card); } } &[data-divided] { gap: 1px; padding: 0; overflow: hidden; background: var(--border); & > figure { padding: 1rem; background: var(--card); } & > figcaption { margin: 0; padding: 0.75rem 1rem 1rem; background: var(--card); } } } .typeset-subcaption { margin: 0; color: var(--muted-foreground); font-size: 0.8125em; line-height: 1.45; &::before { counter-increment: typeset-sub; content: "(" counter(typeset-sub, lower-alpha) ") "; color: var(--foreground); font-weight: 600; } } /* -- Accessibility ------------------------------------------ */ @media (prefers-contrast: more) { .h1, .h2, .h3, .h4 { letter-spacing: 0; } .typeset { --typeset-initial-color: var(--foreground); } .blockquote { border-inline-start-width: 3px; } .inline-code { outline: 1px solid var(--border); } .muted { color: var(--foreground); } } @media (forced-colors: active) { .blockquote { border-inline-start-color: CanvasText; } .typeset { --typeset-initial-color: CanvasText; } .typeset[data-rule] { column-rule-color: CanvasText; } .inline-code { background: Canvas; outline: 1px solid CanvasText; color: CanvasText; } }}Comments, ideas or improvements? Edit this page's source on GitHub