Typeset

A complete typesetting system for product UI, docs, and streaming chat. Semantic roles for meaning, a numeric scale for fine control, fluid display type, a 65ch reading measure, and rhythm presets you own in CSS.

Markdown and CMS HTML arrive unstyled: headings, paragraphs, lists, tables. You can chase sizes and spacing per surface - blog, docs, chat - or share one rhythm that follows Cubix theme tokens.

Cubix Typeset is that shared layer. Use semantic roles (text-headline, text-body) when the meaning is clear. Use the numeric scale (text-sm) for chrome. Wrap long-form HTML in typeset when you need measure, heading steps, and block rhythm. This docs site uses docs-prose as its preset.

Principles

Rhythm stays on four controls. Heading steps, list indent, optical tracking, and space under a title derive from them:

ControlRoleNotes
--typeset-sizeBase text sizeDefaults to 1rem (16px). Chat may tighten; docs should not go below 16px.
--typeset-leadingLine height1.5 minimum for body. Typeset uses 1.6; docs prose uses 1.7.
--typeset-flowBlock spacingGap between paragraphs, lists, and other blocks. One-direction only.
--typeset-stackTitle to supporting lineGap from a title to its supporting line. 0.125rem in every locale.
--typeset-measureLine length65ch by default. Chat sets this to none so the parent owns width.
  • Readable by default - body stays at 16px with at least 1.5 line-height. Display type tracks tighter. Persian on [lang=fa] uses IRANSans XV with a baseline-corrected font face, a 0.9375 size scale, 0 heading tracking, and tuned prose leading.
  • Fits the container - relative sizing follows surrounding UI; chat bubbles stay compact, articles can open up.
  • Uses your theme - colors, radius, and fonts come from Cubix tokens. Dark mode flips with them.
  • Owned CSS - presets live in your project so you can edit them without a plugin API.
  • Streaming-friendly - prefer one-direction spacing so new blocks do not restyle earlier ones.

Font families

Cubix loads Geist for UI and headings, Geist Mono for code, and IRANSans XV for Persian. Variables are set on <html> via next/font.

TokenFamilyUsage
--font-sansGeist - The quick brown fox jumps over the lazy dog.UI text, body copy, and most interface chrome.
--font-headingGeist - The quick brown fox jumps over the lazy dog.Page titles and section headings via font-heading.
--font-monoGeist Mono - The quick brown fox jumps over the lazy dog.Code blocks, inline code, paths, and terminal snippets.
--font-iran-sansIRANSans XV - The quick brown fox jumps over the lazy dog.Persian / RTL (variable, 100-900). Baseline-corrected via font metric overrides so fixed-height controls center like Latin; Latin stays on Geist.

Type scale

Product UI uses the Tailwind-aligned scale from @theme in app/globals.css. Type tokens stay as live CSS variables so [lang=fa] can scale them. Prefer these utilities for buttons, labels, and marketing layouts. Pixel sizes assume a 16px root:

TokenSizeLine heightUsage
text-xs0.75rem / 12pxcalc(1 / 0.75)Fine print, badges, dense tables
text-sm0.875rem / 14pxcalc(1.25 / 0.875)Controls, labels, secondary copy
text-base1rem / 16pxcalc(1.5 / 1)Default body text
text-lg1.125rem / 18pxcalc(1.75 / 1.125)Lead paragraphs
text-xl1.25rem / 20pxcalc(1.75 / 1.25)Small headings
text-2xl1.5rem / 24pxcalc(2 / 1.5)Subsection headings
text-3xl1.875rem / 30pxcalc(2.25 / 1.875)Section headings
text-4xl2.25rem / 36pxcalc(2.5 / 2.25)Page titles
text-5xl3rem / 48px1.15Hero titles
text-6xl3.75rem / 60px1.12Marketing displays
text-7xl4.5rem / 72px1.1Large displays
text-8xl6rem / 96px1.08Poster-scale type

Type roles

Prefer roles when the content has a job. Pair headings with font-heading. Display and headline are fluid from a 320px viewport to 1280px so marketing type scales without extra breakpoints. Caption and label keep size and tracking; set weight with font-medium on controls.

Cubix primitives use these roles by default: text-label on Badge and Kbd; text-caption on Button, Field, Card copy, and dialog descriptions; text-body on Card and Dialog titles. Numeric utilities remain for code, charts, and one-off layout.

RoleSizeLine heightTrackingWeightUsage
text-display36-60px1.12-0.03em600Hero and campaign titles
text-headline30-36px1.2-0.025em600Page titles
text-title24px1.333-0.02em600Section headings
text-lead18px1.5560400Intro paragraphs
text-body16px1.60400Readable body copy
text-caption14px1.4290.005em400Controls, descriptions, menu items
text-label12px1.3330.02em500Badges, kbd, overlines, compact chrome

Measure

Line length is a first-class token. Keep body copy near 65 characters. Shorter for asides, never much past 75:

TokenValueUsage
max-w-prose65chIdeal reading column (45-75ch).
max-w-prose-narrow45chShort asides, captions, callouts.
max-w-prose-wide75chUpper bound before lines get hard to scan.

Optical tracking

Large type needs tighter letter-spacing; small labels need a little air. Persian and RTL headings reset tracking to 0 on [lang=fa]:

TokenValueUsage
tracking-display-0.03emLargest display type
tracking-headline-0.025emPage and section titles
tracking-title-0.02emSmaller headings
tracking-body0emBody, lead, and UI copy
tracking-label0.02emCompact labels and overlines

Persian

Locale metrics live in Typeset, not in each component. Set lang="fa" on a root or subtree. Utilities such as text-caption read var(--text-caption), so IRANSans XV, --text-fa-scale, tuned prose leading, and heading flow all follow. Title-to-description stack stays the same as English.

Vertical centering is solved once, in the font face. Geist places its baseline exactly half a cap-height below the middle of a leading-none line box, which is why Latin labels look centered in fixed-height controls. IRANSans XV does not, so Cubix corrects it with ascent-override / descent-override on the @font-face itself. The total stays at 150%, so only the baseline moves - prose leading is unchanged, and no component needs per-button padding, a label wrapper, or a translate offset.

app/fonts/iran-sans.ts
import localFont from "next/font/local";

// IRANSans XV ships ascent 100% / descent 50%, which leaves its baseline ~0.068em
// too high, so Persian reads about 1px high in leading-none controls. Moving the
// baseline down while keeping the 150% total leaves prose leading untouched.
export const iranSans = localFont({
  src: "./IRANSansXV.woff2",
  weight: "100 900",
  variable: "--font-iran-sans",
  display: "swap",
  declarations: [
    { prop: "ascent-override", value: "107%" },
    { prop: "descent-override", value: "43%" },
    { prop: "line-gap-override", value: "0%" },
  ],
});
app/globals.css
[lang="fa"] {
  --text-fa-scale: 0.9375;
  --font-sans: var(--font-iran-sans);
  --font-heading: var(--font-iran-sans);
  font-family: var(--font-iran-sans), ui-sans-serif, sans-serif;
  --text-body: calc(1rem * var(--text-fa-scale));
  --text-caption: calc(0.875rem * var(--text-fa-scale));
  --leading-body: 1.75;
  --leading-prose: 1.85;
}

[lang="fa"] .typeset {
  --typeset-leading: 1.75;
  --typeset-flow: 1.45em;
  --typeset-after-heading: 0.75em;
}

Building a typeset

Long-form HTML uses the shipped .typeset class. It reads the rhythm variables, Cubix font tokens, and a 65ch measure:

app/globals.css
.typeset {
  --typeset-font-body: var(--font-sans);
  --typeset-font-heading: var(--font-heading);
  --typeset-font-mono: var(--font-mono);
  --typeset-size: var(--text-body);
  --typeset-leading: var(--leading-body);
  --typeset-flow: 1.25em;
  --typeset-after-heading: 0.5em;
  --typeset-measure: var(--container-prose);
  --typeset-tracking: var(--tracking-body);

  max-width: var(--typeset-measure);
  font-family: var(--typeset-font-body);
  font-size: var(--typeset-size);
  line-height: var(--typeset-leading);
  letter-spacing: var(--typeset-tracking);
  text-wrap: pretty;
}

Keep tokens and utilities in the same CSS entry you already use for Cubix:

app/globals.css
@import "tailwindcss";
/* Cubix tokens and utilities */
@import "./globals.css";

This documentation shell already seeds rhythm on .docs-prose:

app/globals.css
.docs-prose {
  --typeset-size: var(--text-body);
  --typeset-leading: var(--leading-prose);
  --typeset-flow: 1.35em;
  letter-spacing: var(--tracking-body);
}

Presets

A typeset is a small preset class. Keep more than one in the same app - tighter for chat, roomier for docs:

app/globals.css
.typeset-docs {
  --typeset-size: var(--text-body);
  --typeset-leading: var(--leading-prose);
  --typeset-flow: 1.35em;
}

.typeset-chat {
  --typeset-size: var(--text-caption);
  --typeset-leading: 1.6;
  --typeset-flow: 1em;
  --typeset-measure: none;
}

.typeset-large {
  --typeset-size: var(--text-lg);
  --typeset-leading: 1.75;
  --typeset-flow: 1.5em;
}
Example
<h1 className="font-heading text-display">Page title</h1>
<p className="text-lead text-muted-foreground">Intro copy.</p>
<article className="typeset typeset-docs">{page}</article>
<div className="typeset typeset-chat">{message}</div>

For a one-off tweak, set a variable on the container:

Example
<article className="typeset [--typeset-flow:1.75em] [--typeset-measure:75ch]">
  ...
</article>

Accessibility and dark mode

Offer a larger preset for readers who need more space. Dark mode already follows Theming tokens; loosen leading on dark surfaces if copy feels tight:

app/globals.css
.typeset-large {
  --typeset-size: var(--text-lg);
  --typeset-leading: 1.75;
  --typeset-flow: 1.5em;
}

.dark .typeset {
  --typeset-leading: 1.7;
}

.dark .typeset-docs {
  --typeset-leading: 1.8;
}

.dark .typeset-large {
  --typeset-leading: 1.85;
}

Overrides and opt-out

Prefer low-specificity selectors so Tailwind utilities win without !important:

Example
<div className="typeset typeset-docs">
  <p className="text-lead">Utility wins over typeset defaults.</p>
</div>

Keep interactive Cubix components out of prose styling with not-typeset:

Example
<div className="typeset typeset-docs">
  <p>Styled prose.</p>
  <Card className="not-typeset">Untouched component.</Card>
</div>

In practice

The quick brown fox jumps over the lazy dog

Headline for a product page

Section title

Lead copy uses text-lead at 18px with relaxed leading so the first paragraph can carry the page.

Body copy sits at 16px and 1.6 line-height, capped near 65 characters. That is the reading column WCAG and classic print practice both point at.

A second paragraph gets flow spacing from the typeset, not from ad-hoc margins on each tag.

Captions and metadata use text-caption.

Status label

Next: Theming, CLI, or Components.