# Optical balance

> Measured on Mona Sans: center letters by cap height, align big headings to the text edge and hang opening quotes.

Utility · [Docs](https://ui.ssntl.app/docs/components/optical) · `npx shadcn@latest add @ssntl/optical`

Installs `styles/ssntl/optical.css`. Read this file before you use, change or review it.

## When to use it

- For big headings (`optical-edge`), pull quotes (`hang-quote`) and centered letters in badges (`text-trim`).

## When not to

- At body sizes, where the corrections are under a pixel.

## Usage

```css
/* app/globals.css */
@import "../styles/ssntl/optical.css";

/* In your JSX */
<h1 class="optical-edge text-headline">Good work</h1>
<blockquote class="hang-quote">“Make it simple, then make it right.”</blockquote>
<span class="text-trim">OS</span>
```

## Rules for Optical balance

- Values measured on Mona Sans, in em, so they scale with any size.
- **Letter alignment.** Optical-edge pulls display, headline and title sizes back 0.04em, the side bearing of the letters headings start with.
- **Hanging marks.** Hang-quote indents the first line −0.39em, the width of “ at weight 500, so the letters line up with the text around it.
- **Glyph overshoot.** Text-trim trims the line box to cap height and baseline, so a badge or avatar centers the letters, not the box. Round letters still overshoot about 1%, by design.
- Heading in Typography applies `optical-edge` for you; Blockquote applies `hang-quote`; Avatar applies `text-trim`.

## How it is built

Optical balance: what looks right beats what measures right. The values below were measured on Mona Sans, the system's typeface, so they are proportions of the font (em), never fixed pixels, and scale with any size.

- `text-trim`: centers text by its cap height, not its line box
- `optical-edge`: pulls a large heading back to the text edge
- `hang-quote`: hangs an opening quote mark outside the text block

Icon area and case scale are handled by components: Avatar (a square is drawn smaller than a circle) and Eyebrow (uppercase one step smaller).

Glyph overshoot: round letters (O, S, G) rise about 1% above the flat ones, so the line box is taller than the letters. Trimming it to the cap height and the baseline lets a pill, a badge or an avatar center the letters themselves. Browsers without text-box keep the normal box.

Letter alignment: every glyph carries side bearing, about 0.04em on the letters headings start with in Mona Sans (H 0.079, G 0.043, T 0.025), so a big heading looks indented next to the body text. Use on display, headline and title sizes; at body sizes the bearing is under a pixel.

Hanging marks: the opening quote is 0.39em wide in Mona Sans at weight 500, so a negative first-line indent of the same width puts it outside the block and the letters line up with the text around it.

## UI foundations it applies

- **Glyph overshoot** (Optical balance): Center letters by their cap height, letting round letters overshoot as drawn.
- **Letter alignment** (Optical balance): Pull large headings back by their side bearing so they align with the text below.
- **Hanging marks** (Optical balance): Hang an opening quote outside the text block so the letters line up.

## Typography

- Map each text role to a step of the scale; supporting text never outsizes its title.
- Headings balance (`text-balance`); paragraphs avoid orphans (`text-pretty`) and stay under about 70 characters (`max-w-prose`).
- Align paragraphs to the start edge; center only a short single line.
- Two sizes on one line share a baseline (`items-baseline`).
- Codes, IDs and keys use the mono face (Code), where every character is unambiguous.
- Light weights (100–300) only at 28px and up.
- `user-select`: none only on drag surfaces and on choice labels (a quick double click must toggle, not select); never on content people may copy.

## Design tokens only

- Use the system's tokens for every design value: type roles (`text-caption` 13, `text-label` 15, `text-body` 17, `text-lead` 20, `text-heading` 22, `text-title` 32, `text-headline` 48, `text-display` 72), radii (`rounded-xs` 8 … xl 44), spacing steps, colors, shadows and motion. If a value doesn't exist, take the nearest token. Never write text-[15px], rounded-[24px] or bg-[#…]; `pnpm lint` rejects them.
- Type roles already carry line height, tracking and weight. Pick the role; never add `leading-*` or `tracking-*` on top.
- **Nested corners are concentric.** Inner radius = outer radius − padding − border width (`rounded-outer`-* p-concentric-* `rounded-inner`). Past 24px of padding, or with deliberately uneven padding, each layer keeps its own radius token.
- Separate surfaces by tone, not borders. A border is for structure (a divider) or state (an input, a selection).

## Accessibility baseline

- Text meets 4.5:1 against its surface (3:1 at 24px and up, or 18.67px bold); input borders, focus rings and chart marks meet 3:1, in light and dark. Text on a translucent color (`white/80`, /50, color-mix) is measured as rendered over its background; the theme pairs are checked in lint.
- Meaning never rides on color alone: a state carries a word, an icon or a pattern as well.
- Every page fits a 320px screen at 200% zoom with no horizontal scroll; a grid that holds content uses grid-cols-[minmax(0,1fr)] and flex children get min-w-0. Never set maximum-scale=1 or user-scalable=no.
- A focused element is never hidden under sticky chrome: set scroll-padding to the header's height (and to footers or banners at the bottom).
- On a client-side route change, update the title and move focus to the new page's main content.
- **Respect prefers-reduced-motion.** Keep opacity and color, drop movement, scale and blur.
- Content is never truncated without a way to read all of it; long names wrap. A hover-only title tooltip doesn't count as a way back.

## Writing

- Sentence case everywhere; no ALL CAPS except the Eyebrow role.
- One concept, one word, across navigation, headings and buttons.
- Buttons are a verb and its object and say what happens (Delete product, not OK).
- An error says what went wrong and how to fix it, in the user's language.
- **Status copy follows one pattern.** In progress is the verb in -ing (Saving changes…), done names the object and the past tense with no “successfully” (Changes saved), a failure gives the next step, partial results give counts. Cut “Please”.
- A confirmation names the action and the object (Delete Ceramic cup?), never “Are you sure?”. Deleting an account, a workspace or a store asks for its name to be typed.
- Show known requirements as helper text before input, not only as an error after it. Links never say “Click here”, and no two links on a page say “Learn more”.
- Build each sentence as one translatable message, never concatenated around a variable; join lists with Intl.`ListFormat`.
- **Use real characters.** An en dash for ranges (09:30–17:45), × for dimensions, curly quotes, an ellipsis (…).

## Fixing

- Take the cheapest fix that works, in this order: delete (a separator space would carry, an animation on a frequent action, an ARIA attribute a native element makes redundant), use the platform (the native element or control, the browser's focus ring), reuse what the system has (a token, a spacing step, a motion curve), correct the value, and only then add something new.
- Code that adds what a deletion would fix is a problem in itself; fix it by removing.
- A documented convention settles matters of taste, but never excuses a release blocker.

## Worst-case content

- Build and test with the worst real values, not the happy ones: long hyphenated names with diacritics, unbreakable emails and URLs, German compounds, translations about 30% longer, emoji and right-to-left names.
- Unbreakable strings wrap anywhere; nothing pushes its siblings off a row or out of a container.
- Every image has a fallback and a box: a failed avatar shows initials, a frame keeps its ratio, so loading never shifts the layout.
- **Show data as data.** Escape HTML, never render null, undefined or NaN, and round floating point before display.
- **Check the environment as well.** A 280px column, a 2560px screen, 200% zoom, dark mode, `dir="rtl"` and touch.

## Icons

- One icon set (Lucide) at one stroke: 1.6, the Essential Design spec, beside regular and medium text; a heavier stroke only where a small glyph must read (a 16px check). (better-ui proposes rendered strokes of 1.5–2px with `absoluteStrokeWidth`; the system's documented stroke takes precedence as a matter of style.)
- Inline icons are 1em–1.25em of the text beside them.
- Icons are 20px beside labels and 16px in dense controls, at the size they were drawn; a well or a button, not a bigger glyph, gives them room.
- One SVG per icon, colored with `currentColor`; hover, selected and disabled states come from color and opacity, never from separate assets.
- Decorative icons are `aria-hidden`; an icon that is the only content of a control gets its name from the control.

## Light, dark and languages

- Design both themes from the tokens; dark mode is chosen, not inverted. Check every new pair in both.
- Pass every visible and accessible string as a prop or child, so the component works in any language; never hard-code copy inside it.
- Use logical properties (ms-, me-, start-, end-) so layouts mirror in right-to-left languages; `translateX` and background-position need a right-to-left override. Mirror only icons whose meaning follows reading direction (arrows, chevrons), and wrap mixed-direction values in `<bdi>`.
- **Visual order matches DOM order.** No order, row-reverse or grid placement that reorders content.
- Switching themes pauses transitions for the switch, so every surface changes at once.

## Before you ship

- [ ] Every interactive control has an accessible name.
- [ ] Every keyboard-reachable control shows a visible focus indicator.
- [ ] Nothing is reachable by pointer but not by keyboard.
- [ ] Motion and auto-playing content respect prefers-reduced-motion.
- [ ] Nothing is clipped, overlapped or unreachable at 320px width or 200% zoom.
- [ ] Body and control text pass their contrast ratio.
- [ ] No state or meaning is carried by color alone.
- [ ] Destructive actions have a confirmation, an undo, or a distinct treatment.
- [ ] No content is truncated without a way to reach the full value.
- [ ] No content or control hides past a scroll edge or disclosure without a visible cue.
- [ ] Every error names a way to recover.
- [ ] Semantic colors are used for their meaning only (danger never on a safe action).
- [ ] No state change is carried by motion alone.
- [ ] Nothing focused hides under sticky chrome, and overlays contain their own scroll.
- [ ] Worst-case values (long, empty, many, missing, right-to-left) don't break the layout.

## Review with these skills

Every interface built with ssntl/ui is reviewed with the same skills the library is. Install them once and keep them current:

```bash
npx skills@latest add https://github.com/jakubkrehel/skills --skill better-interface
npx skills@latest add jakubkrehel/make-interfaces-feel-better
npx skills@latest add emilkowalski/skills
```

- **better-interface** (Jakub Krehel): The full review: accessibility, layout, writing, typography, color and polish. Its escalation triggers block a release.
- **make-interfaces-feel-better** (Jakub Krehel): The details: concentric radii, optical alignment, press scale, interruptible and subtle motion, hit areas.
- **emilkowalski/skills** (Emil Kowalski): Design engineering: easing, durations, springs and gestures (emil-design-eng, review-animations, animation-performance) and the worst-case values that break a UI (break-ui).

## Related

- [Typography](typography.md): Heading, Text and Eyebrow on the system's type roles, each with its own line height and tracking.
- [Blockquote](blockquote.md): A pull quote that adds its own curly quotes and hangs the opening one outside the text.
- [Avatar](avatar.md): A circle for people, a smaller square for workspaces, with initials centered by cap height.

---

Generated from the ssntl/ui source by `scripts/build-rules.mjs`. The whole rulebook is [ssntl-ui.md](ssntl-ui.md); every component's rules are at https://ui.ssntl.app/rules.
