Death to typewriters

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

We do most of the automatic type replacements the moment you type, often taking the characters happening before into consideration. Here’s a full list of all the replacements we do.

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.

Unfortunately, the CSS property hanging-punctuation isn’t supported by any browser.

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.)

We define what punctuation characters should be followed by a non-breakable space, and what should be preceded by one:

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

As far as I understand, this is only necessary for Macs:

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

Relatively straightforward:

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

We had a few little issues with using optimizeLegibility, but we are using it overall:

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;

Single one so far, for ██████ on Mac OS (but see pilcrows below).

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

We are hiding everything not in the main content area, so that it doesn’t print by default. In many cases we will still have to use display: none to hide individual screen-only UI objects, since the below alone still reserves the space… but this is still better than any new UI elements “leaking” into print view if we don’t pay attention.

@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

This is the style we use for 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: '•';

We only apply it to articles with a specified language, to avoid drive-by hyphenation:

.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'>

We wrote an article about designing the custom underlines. Here is how we define them in the codebase:

// 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.

Pretty straightforward styling of the pilcrow itself:

.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

We are lucky enough that the fonts we use come with proper defaults. Freight Text Pro (serif font you’re reading now) has default old-style numerals built in. Bernino Sans (sans serif headline font we use) has lining numerals.

There is a CSS property (tabular-nums) living under font-variant. We cannot use it since the font we’re serving doesn’t support this OpenType property yet.

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:

Unicode is fun!

This is a quick function that puts a comma every third number: = 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 is less important in HTML (browsers de-dup them), but we still need to remove them for when we render on other platforms, for example iOS.

At writing, we apply special class .graf--empty to a paragraph without any contents. Then we tighten things up with negative margins:

.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

Very simple. Could conceivably be better expressed with ems as units.

.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.

« Go back to the main article

Designer/typographer · Writing a book on the history of keyboards:

Designer/typographer · Writing a book on the history of keyboards: