Let's take the mystery out of sizing type. Typography is both foundational to any stylesheet and the quickest way to elevate an otherwise minimal layout from drab to fab. If you're looking for type design theory or how to select a font, that's outside the scope of this article. The goal for today is to give you a foundation for developing essential type styles in CSS, and terms to use if you wish to explore any topics deeper.
This episode covers:
- recommended units for
font-size
- generating ratio-based
font-size
values with Sass - recommended properties to prevent overflow from long words/names/URLs
- defining viewport-aware fluid type scale rules with
clamp()
- additional recommendations for dealing with type
Defining "Type Scale"
#The simplified definition: "type scale" for the web refers to properties such as font-size
, line-height
, and often margin
, that work together to create vertical rhythm in your design. These can be arbitrarily selected ("it just looks good"), or be based on the idea of a "modular scale" that employs ratio-based sizing.
At a minimum, it involves setting a base font-size
and line-height
on body
which elements such as paragraphs and list items will inherit by default.
Then, set font-size
and line-height
on heading elements, particularly levels h1
-h4
for general use.
What about h5
and h6
?
Certain scenarios might make it beneficial to care about levels 5 and 6 as well, but it's important to not use a heading tag when it's really a visual style that's desired. Overuse of heading tags can cause noise or generally impart poor information hierarchy for assistive tech when another element would be better suited for the context.
Selecting a Unit for font-size
#px
, %
, rem
, and em
, oh my!
The first upgrade is to forget about px
when defining typography. It is not ideal due to failure to scale in proportion to the user's default font-size
that they may have set as a browser preference or by using zoom.
Instead, it's recommended that your primary type scale values are set with rem
.
Unless a user changes it, or you define it differently with font-size
on an html
rule, the default rem
value is 16px with the advantage of responding to changes in operating system zoom level.
In addition, the value of rem
will not change no matter how deeply it is nested, which is largely what makes it the preferred value for typography sizing.
A few years ago, you might have started to switch your font-size
values to em
. Let's learn the difference.
em
will stay proportionate to the element or nearest ancestor's font-size
rule. One em
is equal to the font-size
, so by default, this is equal to 1rem
.
Compared to rem
, em
can compound from parent to child, causing adverse results. Consider the following example of a list where the font-size
is set in em
and compounds for the nested lists:
By Stephanie Eckles (@5t3ph)
Learn more about units including
rem
andem
in my Guide to CSS Units for Relative Spacing
em
shines when the behavior of spacing relative to the element is the desired effect.
One use case is for buttons when sizing an icon relative to the button text. Use of em
will scale the icon proportionate to the button text without writing custom icon sizes if you have variants in the button size available.
Percentages have nearly equivalent behavior to em
but typically em
is still preferred when relative sizing is needed.
Calculating px
to rem
#I've spent my career in marketing and working with design systems, so I can relate to those of you being given px-based design specs :)
You can create calculations by assuming that 1rem
is 16px
- or use an online calculator to do the work for you!
Baseline Type Styles
#A solid starting point is to define:
body {
line-height: 1.5;
}
Older recommendations may say to use 100%
, and this article previously recommended 1rem
. However, the only element the body
can inherit from is html
which is where the rem
unit takes its value and so defining it here is redundant.
So, we'll only add one rule which is for accessibility. It is recommended to have a minimum of 1.5 line-height
for legibility. This can be affected by various factors, particularly font in use, but is the recommended starting value.
Preventing Text Overflow
#We can add some future-proof properties to help prevent overflow layout issues due to long words, names, or URLs.
This is optional, and you may prefer to scope these properties to component-based styles or create a utility class to more selectively apply this behavior.
We'll scope these to headings as well as p
and li
for our baseline:
p,
li,
h1,
h2,
h3,
h4 {
// Help prevent overflow of long words/names/URLs
overflow-wrap: break-word;
// Optional, not supported for all languages
hyphens: auto;
}
As of testing for this episode, overflow-wrap: break-word;
seemed sufficient, whereas looking back on articles over the past few years seem to recommend more properties for the same effect.
The hyphens
property is still lacking in support, particularly when you may be dealing with multi-language content. However, it gracefully falls back to simply no hyphenation in which case overflow-wrap
will still help. More testing may be required for certain types of content where long words are the norm, ex. scientific/medical content.
This CSS-Tricks article covers additional properties in-depth if you do find these two properties aren't quite cutting it.
Ratio-based Type Scales
#While I introduced this episode by saying we wouldn't cover type design theory, we will use the concept of a "type scale" to efficiently generate font-size
values.
Another term for ratio-based is "modular", and here's a great article introducing the term by Tim Brown on A List Apart.
There are some named ratios available, and our Codepen example creates a Sass map of them for ease of reference:
$type-ratios: (
"minorSecond": 1.067,
"majorSecond": 1.125,
"minorThird": 1.2,
"majorThird": 1.25,
"perfectFourth": 1.333,
"augmentedFourth": 1.414,
"perfectFifth": 1.5,
"goldenRatio": 1.618,
);
These ratios were procured from the really slick online calculator Type Scale
Join my newsletter for article updates, CSS tips, and front-end resources!
Generating font-size
Using a Selected Ratio
#Stick with me - I don't super enjoy math, either.
The good news is we can use Sass to do the math and output styles dynamically in relation to any supplied ratio 🙌
Unfamiliar with Sass? It's a preprocessor that gives your CSS superpowers - like variables, array maps, functions, and loops - that compile to regular CSS. Learn more about Sass >
There are two variables we'll define to get started:
// Recommended starting point
$type-base-size: 1rem;
// Select by key of map, or use a custom value
$type-size-ratio: type-ratio("perfectFourth");
The $type-size-ratio
is selecting the perfectFourth
ratio from the map previewed earlier, which equals 1.333
.
The CodePen demo shows how the type-ratio()
custom Sass function is created to retrieve the ratio value by key. For use in a single project, you can skip adding the map entirely and directly assign your chosen ratio decimal value to $type-size-ratio
.
Next, we define the heading levels that we want to build up our type scale from. As discussed previously, we will focus on h1
-h4
.
We create a variable to hold a list of these levels so that we can loop through them in the next step.
// List in descending order to prevent extra sort function
$type-levels: 4, 3, 2, 1;
These are listed starting with 4
because h4
should be the smallest - and closest to the body size - of the heading levels.
Time to begin our loop and add the math.
Before I lose you, I want to let you know that in an updated tutorial we use custom properties and no Sass to create this same result. Plus, we upgrade to using container query units for fluid typography that is independent of the viewport!
First, we create a variable that we will update on each iteration of the loop. To start with, it uses the value of $type-base-size
:
$level-size: $type-base-size;
If you are familiar with Javascript, we are creating this as essentially a let
scoped variable.
Next, we open our @each
loop and iterate through each of the $type-levels
. We compute the font-size
value / re-assign the $level-size
variable. This compounds $level-size
so that is scales up with each heading level and is then multiplied by the ratio for the final font-size
value.
@each $level in $type-levels {
$level-size: $level-size * $type-size-ratio;
// Output heading styles
// Assign to element and create utility class
h#{$level} {
font-size: $level-size;
}
Given the perfectFourth
ratio, this results in the following font-size
values:
h4: 1.333rem
h3: 1.776889rem
h2: 2.368593037rem
h1: 3.1573345183rem
Example phrase shamelessly borrowed from Google fonts 🙃
h/t to this David Greenwald article on Modular Scale Typography which helped connect the dots for me on getting the math correct for ratio-based sizing. He also shows how to accomplish this with CSS var()
and calc()
line-height
and Vertical Spacing
#At a minimum, it would be recommended to include a line-height
update within this loop. The preview image already included this definition, as without it, large type generally doesn't fare well from inherits the 1.5
rule.
A recent article by Jesús Ricarte is very timely from our use case, which proposes the following clever calculation:
line-height: calc(2px + 2ex + 2px);
The ex
unit is intended to be equivalent to the x
height of a font. Jesús tested a few solutions and devised the 2px
buffers to further approach an appropriate line-height
that is able to scale. It even scales with fluid - aka "responsive" type - which we will create next.
As for vertical spacing, if you are using a CSS reset it may include clearing out all or one direction of margin on typography elements for you.
Check via Inspector to see if your type is still inheriting margin styles from the browser. If so, revisit the rule where we handled overflow and add margin-top: 0
.
Then, in our heading loop, my recommendation is to add:
margin-bottom: 0.65em;
As we learned, em
is relative to the font-size
, so by using it as the unit on margin-bottom
we will achieve space that is essentially 65% of the font-size
. You can experiment with this number to your taste, or explore the vast sea of articles that go into heavier theory on vertical rhythm in type systems to find your preferred ideal.
Fluid Type - aka Responsive Typography
#If you choose a ratio that results in rather large font-size
on the upper end, you are likely to experience overflow issues on small viewports despite our earlier attempt at prevention.
This is one reason techniques for "fluid type" have come into existence.
Fluid type means defining the font-size
value in a way that responds to the viewport size, resulting in a "fluid" reduction of size, particularly for larger type.
There is a singular modern CSS property that will handle this exceptionally well: clamp
.
The clamp()
function takes three values. Using it, we can set a minimum allowed font size value, a scaling value, and a max allowed value. This effectively creates a range for each font-size
to transition between, and it will work thanks to viewport units.
Learn more about
clamp()
on MDN, and check browser support (currently 90.84%)
We'll leave our existing loop in place because we still want the computed ratio value. And, the font-size
we've set will become the fallback for browsers that don't yet understand clamp()
.
But - we have to do more math 😊
In order to correctly perform the math, we need to do a bit of a hack (thanks, Kitty at CSS-Tricks!) to remove the unit from our $level-size
value:
// Remove unit for calculations
$level-unitless: $level-size / ($level-size * 0 + 1);
Next, we need to compute the minimum size that's acceptable for the font to shrink to.
// Set minimum size to a percentage less than $level-size
// Reduction is greater for large font sizes (> 4rem) to help
// prevent overflow due to font-size on mobile devices
$fluid-reduction: if($level-size > 4, 0.5, 0.33);
$fluid-min: $level-unitless - ($fluid-reduction * $level-unitless);
You can adjust the if/else values for the $fluid-reduction
variable to your taste, but this defines that for $level-size
greater than 4rem
, we'll allow a reduction of 0.5
(50%) - and smaller sizes are allowed a 0.33
(33%) reduction.
In pseudo-math, here's what's happening for the h4
using the perfectFourth
ratio:
$fluid-min: 1.33rem - (33% of 1.33) = 0.89311;
The result is a 33% allowed reduction from the base $level-size
value.
The pseudo-math actually exposes an issue: this means that the h4
could shrink below the $type-base-size
(reminder: this is the base body
font size).
Let's add one more guardrail to prevent this issue. We'll double-check the result of $fluid-min
and if it's going to be below 1
- the unitless form of 1rem
- we just set it to 1
(adjust this value if you have a different $type-base-size
):
// Prevent dropping lower than 1rem (body font-size)
$fluid-min: if($fluid-min > 1, $fluid-min, 1);
We're missing one value which I have taken to calling the "scaler" - as in, the value that causes the fluid scaling to occur. It needs to be a value that by it's nature will change in order to trigger the transition between our min and max values.
So, we'll be incorporating viewport units - specifically vw
, or "viewport width". When the viewport width changes, then this value will also update it's computed value. When it approaches our minimum value, it won't shrink further, and the true in the opposite direction for our max value. This creates the "fluid" type sizing effect.
In order to retain accessible sizing via zooming, we'll also add 1rem
alongside our vw
value. This helps alleviate (but not entirely rule out) side effects of using viewport units only. This is because as was mentioned earlier, the rem
unit will scale with the user's zoom level as set via either their operating system or with in-browser zoom. To meet WCAG Success Criterion 1.4.4: Resize text, a user must be able to increase font-size by up to 200%.
Let's create our scaler value:
$fluid-scaler: ($level-unitless - $fluid-min) + 4vw;
The logic applied here is to get the difference between the upper and lower limit, and add that value to a viewport unit of choice, in this case 4vw
. A value of 4 or 5 seems to be common in fluid typography recommendations, and testing against the $type-ratios
seemed to surface 4vw
as keeping the most definition between heading levels throughout scaling. Please get in touch if you have a more formulaic way to arrive at the viewport value!
Altogether, our fluid type font-size
rule becomes:
font-size: clamp(
#{$fluid-min}rem,
#{$fluid-scaler} + 1rem,
#{$level-size}
);
In Closing...
#If you really read this whole episode, thank you so much for sticking with it. Typography has so many angles and the "right way" is very project-dependent. It may be the set of properties with the most stakeholders and the most impact on any given layout.
Consider reviewing the upgraded solution that drops Sass to use custom properties, and uses container query units for fluid typography that flows easily into multiple layout locations.
Demo
#The demo includes all things discussed, and an extra bit of functionality which is that a map is created under the variable $type-styles
to hold each generated value with h[x]
as the key.
Following the loop is the creation of the type-style()
function that can retrieve values from the map based on a key such as h3
. This can be useful for things like design systems where you may want to reference the h3
font-size on the component level for visual consistency when perhaps a heading is semantically incorrect.
By Stephanie Eckles (@5t3ph)