Death to typewriters

Technical supplement

Marcin Wichary
Feb 9, 2015 · 8 min read

This is a technical counterpart to the article about details of Medium typography. If you haven’t read that one, you should start there.

Below are some of the technical (CSS, JavaScript, Closure) details in Medium’s typography and typesetting. Note: we use LESS, so you will see some light LESS features (mostly variables).

I. Typography is for everyone

Em dashes, en dashes, quotes, primes, ellipses, and other replacements

For copy and paste, we serialize paste into a sequence of keystrokes, and replay it using the same rules as above.

What is causing problems is external circumstances, for example an open issue where Chrome’s spellcheck is suggesting bad quotation marks instead of good ones.

Hanging quotes

We detect whether a paragraph starts with an opening single quote or double quote, and mark it with a CSS class. We support these opening quotes so far:


Then, we use text-indent CSS property, with values measured to offset the width of the respective quotation mark. This needs to be done per font:

.graf--p.graf--startsWithSingleQuote { // Freight Text 400 values
text-indent: -0.24em;
.graf--p.graf--startsWithDoubleQuote {
text-indent: -0.43em;
.graf--h2.graf--startsWithSingleQuote, // Bernino Sans 700 values
.graf--h3.graf--startsWithSingleQuote {
text-indent: -0.28em;
.graf--h3.graf--startsWithDoubleQuote {
text-indent: -0.47em;
.graf--h4.graf--startsWithSingleQuote { // Bernino Sans 300 values
text-indent: -0.25em;
.graf--h4.graf--startsWithDoubleQuote {
text-indent: -0.37em;

Centered paragraphs and headlines should not be indented, since that would just throw them off center:

.graf--startsWithDoubleQuote[data-align="center"] {
text-indent: 0;

There are a number of other ways this could be achieved, but this works relatively nicely for both reading and, very importantly, also for writing.

However, it is problematic when the hanging quotation mark is kerned with the next character. (Ideally, we would just absolute-position, but that wouldn’t work well while editing.)

Image for post
Image for post

Non-breakable spaces

var NBSP = goog.string.Unicode.NBSP_.NBSP_PUNCTUATION_START = /([«¿¡]) /g
_.NBSP_PUNCTUATION_END = / ([\!\?:;\.,‽»])/g

And then when we render paragraphs, we decide which plain spaces turn into non-breakable spaces with this:

text = text.replace(_.NBSP_PUNCTUATION_START, '$1' + NBSP)
.replace(_.NBSP_PUNCTUATION_END, NBSP + '$1')

The side effect of this method is that we can upgrade our rendering without having to change the underlying text.

II. Making type read well and look good

Font smoothing

-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;

Line height and tracking

line-height: 1.5;
letter-spacing: 0.01rem;

Kerning and ligatures

text-rendering: optimizeLegibility;

Firefox needed to turn the ligatures on independently:

-moz-font-feature-settings: “liga” on;

In some places where optimizeLegibility is kicking our ass (things unnecessarily breaking to a new line even though there is room), we just override it back:

text-rendering: auto;

Using different fonts for specific glyphs

@font-face {
font-family: 'Cambria';
src: local('Arial'), local('Helvetica');
unicode-range: U+2500–259F;


@media print {
body.template-flex-article * {
visibility: hidden;
body.template-flex-article .postContent,
body.template-flex-article .postContent * {
visibility: visible;

We are setting up top and bottom margins:

@media print {
@page {
margin-top: .75in;
margin-bottom: .75in;

We’re changing the width of the page to match the different font size:

@media print {
body.template-flex-article .layoutSingleColumn {
max-width: 4.95in;
margin: 0 auto;

We’re overriding the font colour to be black, plus ensure that orphans and widows don’t exist (2 below means we don’t display one line by itself if it begins or ends the page). Alas, Firefox and Safari don’t support this yet.

@media print {
body {
color: black;
orphans: 2;
widows: 2;

We’re hiding both default underlines and our custom ones:

@media print {
.markup—pre-anchor {
text-decoration: none;
background: none;

We’re hiding actionable elements in the footer, and make sure it travels to a new page together, rather than being cut in the middle:

@media print {
.postFooter--simple {
page-break-inside: avoid;
.infoCard-actions {
display: none;

III. Punctuation binds the words together

Bullet points and ordered lists

.postList > li:before {
position: absolute;
display: inline-block;
box-sizing: border-box;
// The list gutter content width needs to be the image
// gutter, or the list will bleed back into a floating
// image. We align the text right so that large numbers
// can bleed out further.
width: 58px;
margin-left: -58px;
text-align: right;
ol.postList > li:before {
padding-right: 12px;
counter-increment: post;
content: counter(post) ".";

And here are our custom bullet points:

ul.postList > li:before {
padding-top: 6px;
padding-right: 15px;
font-size: @fontSize-base--post * 0.65;
content: '•';


.postArticle[lang] .graf--p,
.postArticle[lang] .graf--blockquote {
-webkit-hyphens: auto;
-webkit-hyphenate-limit-before: 2;
-webkit-hyphenate-limit-after: 3;
-webkit-hyphenate-limit-lines: 2;

Then we specify the proper language for the article:

<article class='postArticle' lang='en'>


// Position of the underline for a certain font
@backgroundPosition—underlineSerif: 0.72;
@backgroundPosition—underlineSansSerif: 0.90;
// How thick the underlines are as multiplied by font size
@width—underlineRatio: 0.1;
// Underline mixin
.m-underlinePosition(@font-size, @line-height, @m-underlinePosition) {
background-position: 0 ceil(@font-size * @line-height * @m-underlinePosition);

The modifier ceil was picked specifically so our underlines looked good under certain circumstances (your mileage may vary).

// Underline under regular text
.tier-1 .markup--anchor {
text-decoration: none;
background-image: linear-gradient(to bottom, @color-transparentBlack 50%, @color-transparentBlackDark 50%);
background-repeat: repeat-x;
background-size: 2px floor(@fontSize-base--post * @width--underlineRatio);
.m-underlinePosition(@fontSize-base--post, @lineHeight-base--post, @backgroundPosition--underlineSerif);
// Underline under an H1
.tier-1 .markup--h2-anchor {
background-image: linear-gradient(to bottom, @color-transparentBlackDarker, @color-transparentBlackDarker);
background-repeat: repeat-x;
background-size: 2px floor(@fontSize-larger * @width--underlineRatio);
.m-underlinePosition(@fontSize-jumbo--post, @lineHeight-tight, @backgroundPosition--underlineSansSerif);

We specify overrides for retina displays to have sharp 1-pixel underlines:

// Retina override@media only screen and (min-device-pixel-ratio: 2),
only screen and (min-resolution: 2dppx),
only screen and (-webkit-min-device-pixel-ratio: 2) {
.tier-1 .markup--anchor {
background-image: linear-gradient(to bottom, @color-transparentBlack 75%, @color-transparentBlackDarker 75%);
background-repeat: repeat-x;

Possible improvement is to specify positions in ems (which we avoid in our codebase), so that browsers with minimal font size would still have proper underlines.

We only do custom underlines in the body copy, not in the UI — they are pretty expensive to maintain.


.pilcrow {
font-family: "Arial", sans-serif;
font-size: .7em;
padding: 0 .25em;
position: relative;
top: -.15em;
opacity: .4;

And we wrap it around where it appears:

text = text.replace(/¶/g, '<span class=”pilcrow”>¶</span>')

IV. Typography is more than just letters

Old-style numerals

Tabular figures

We fake it by wrapping each individual digit and comma with specially styled wrappers:

.tabularNumeral {
display: inline-block;
width: .56em;
text-align: center;
.tabularNumeral—comma {
width: .35em;
text-align: left;

And this is how we separate them: = function (numString) {
var tabularString = ''
for (var i = 0; i < numString.length; i++) {
var className = 'tabularNumeral'
if (numString[i] == ',') {
className += ' tabularNumeral--comma'
tabularString += '<span class="' +
goog.string.htmlEscape(className) + '">' +
numString[i] + '</span>'
return soydata.VERY_UNSAFE.ordainSanitizedHtml(tabularString)

Side note: This is what happened when I screwed up the above function:

Image for post
Image for post

Unicode is fun!

Thousand separators = function (numString) {
return numString.replace(/\B(?=(\d{3})+(?!\d))/g, ",")

(You might notice a lot of these typographical enhancements are implemented as Closure templates compiler plugins, which do a lot to protect us from XSS security holes.)

V. Whitespace is as important as content

Multiple spaces

Vertical spaces

.graf--h2 + .graf--p.graf--empty,
.graf--h3 + .graf--p.graf--empty,
.graf--h4 + .graf--p.graf--empty {
margin-bottom: -7px;
margin-top: -7px;
.graf--h2 + .graf--p.graf--empty + .graf--h2,
.graf--h3 + .graf--p.graf--empty + .graf--h2,
.graf--h4 + .graf--p.graf--empty + .graf--h2 {
margin-top: -5px;

Side note: why graf and not paragraph? Read on

Headline alignment

.m-fontSizeWithLeftAlignmentFix(@size) {
font-size: @size;
margin-left: -@size / 20;

I personally implemented only a fraction of the above. Thank you to our tireless engineers who spend time caring about words and typography. In no particular order: Nick Santos (with extra thanks for reviewing this article), Daryl “Koop” Koopersmith, Jacob “Fat” Thornton, Kyle Hardgrave, and Gianni Chen.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch

Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore

Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store