optical margin alignment


npm ↗
GitHub ↗
TypeScriptFont-metric measurementCross-browser

CSS hanging-punctuation is Safari-only and gives no control over how far a mark hangs. Optical Margin measures each punctuation character’s width in the rendered font and hangs a set fraction of it into the margin. Works in every browser, with every font.

Live demo — toggle the hangs, change the alignment, resize the column

Hang
Align
Threshold (px)0.5
Max Hang Ratio0.90
Column width100%

“The best typography,” wrote Jan Tschichold, “is invisible — it disappears into the reading.” That is the paradox of the craft: the more perfectly it is executed, the less it is noticed. Every margin matters. Every spacing decision carries weight. “A quotation mark at the start of a line should hang,” Bringhurst insists, “so that the letter, not the punctuation, holds the optical edge.” The same applies to commas, dashes, periods — any mark smaller than a full letter. Hung correctly, the margin reads as a clean vertical. Left flush, it creates a slight indent that the eye registers as misalignment, even when the reader cannot name what bothers them. “It is a small thing,” one might say — but in typography, every small thing is the thing.

Justified text aligns both edges, so punctuation hangs at both margins.

Why not CSS?

hanging-punctuation is incomplete

The CSS property is Safari-only. It doesn’t let you control hang amount, threshold, or which characters hang: a fixed list of marks hangs by its whole width, in every font.

Font-metric measurement

A mark that starts or ends a line is measured where it sits on the page, in the rendered font — size, variation settings and letter-spacing included. A set fraction of that width hangs into the margin: dashes fully, quotes and periods at 80%, commas at 60%. The fractions are adjustable per character.

The text stays one flow

Lines are never locked. Words that begin or end with a hangable mark are wrapped in place in a plain span, and the browser keeps breaking lines as usual — so hyphenation and ­ keep working, a link that wraps stays one link, and copy-paste gives the original text. Resize the column and the hangs move to the marks that now start or end a line.

Aligned edges only

Opening marks hang at the start edge of left-aligned and justified text; closing marks hang at the end edge of right-aligned and justified text. A ragged edge has nothing to align, so it is left alone. A mark whose hang would pull its word up to the line above is left flush.

Usage

TypeScript + React · Vanilla JS

Drop-in component

import { OpticalMarginText } from '@overpunch/opticalmargin'

<OpticalMarginText hangStart={true} hangEnd={true}>
  "Your paragraph text here..."
</OpticalMarginText>

Hook

import { useOpticalMargin } from '@overpunch/opticalmargin'

const ref = useOpticalMargin({ hangStart: true, hangEnd: true })
<p ref={ref}>{children}</p>

Vanilla JS

import { applyOpticalMargin, removeOpticalMargin, getCleanHTML } from '@overpunch/opticalmargin'

const el = document.querySelector('p')
const original = getCleanHTML(el)
applyOpticalMargin(el, original, { hangStart: true, hangEnd: true })

// Later — restore original:
removeOpticalMargin(el, original)

Options

applyOpticalMargin API options
OptionDefaultDescription
hangStarttrueHang opening punctuation at line starts. Shows on start-aligned and justified text.
hangEndtrueHang closing punctuation and sentence-end marks at line ends. Shows on justified and end-aligned text.
threshold0.5Minimum computed hang value in px. Characters whose hang falls below this are left flush.
maxHangRatio0.9Max proportion of advance width to hang (0–1).
hangFractionssee desc.Per-character hang fraction overrides (0–1). Built-in defaults: hyphens & dashes 1.0; quotes, periods, !, ?, …, ), ] 0.8; (, [, commas, semicolons, colons 0.6. Pass your own map to override any character.

no-code

Use it in Webflow, Framer & Figma

The same effect, no build step — drop it straight into your design tool.

Webflow

One script tag, then mark any element with data-opticalmargin. Configure it with data-* attributes.

<!-- Site Settings → Custom Code → Footer, or an Embed element -->
<script src="https://cdn.jsdelivr.net/npm/@overpunch/opticalmargin/dist/opticalmargin.webflow.min.js"></script>

<!-- Then add data-opticalmargin to any text element -->
<h1 data-opticalmargin>Your headline</h1>

Framer

Insert → Code → New Component, then paste OpticalMargin.tsx ↗. It imports the core from esm.sh and exposes every option in the property panel — no build step.

import { /* core */ } from "https://esm.sh/@overpunch/opticalmargin"

Figma · beta

Part of the Type Tools Figma plugin ↗ — Plugins → Development → Import plugin from manifest, run Type Tools, and pick this tool. Here it works with compromises — tracking or named-instance swaps (Figma can't set variable axes).