This is the twelfth post in a series examining modern CSS solutions to problems I've been solving over the last 13+ years of being a frontend developer. Visit ModernCSS.dev to view the whole series and additional resources.
Note: This version is now outdated. Visit the updated version on ModernCSS.dev.
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
- 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 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:
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 used to work in marketing, 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 {
font-size: 1rem;
line-height: 1.5;
}
As mentioned in the type scale section, this ensures general typography elements like <p>
and <li>
are defaulted to at least 1rem
due to the CSS cascade. So, if you wanted to bump your base font size, this would be the location to do that, for example to 1.125rem
which would typically correspond to 18px
.
Older recommendations may say 100%
vs. 1rem
- which in terms of the body
element is equivalent since the only element the body
can inherit from is html
which is where the rem
unit takes its value.
In addition, 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 as a baseline it is
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
word-break: break-word;
// Optional, not supported for all languages
hyphens: auto;
}
As of testing for this episode, word-break: 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 word-break
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
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.
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 up and coming property that will handle this exceptionally well: clamp
.
However, at the time of writing, the two properties it essentially uses under the hood have better support, particularly for mobile device browsers.
Those properties are min
and max
which we can use simultaneously to achieve the result of clamp
- and I look forward to updating this method in the near future! You can learn about clamp
from CSS-Tricks.
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 min
/max
.
But - we have to do more math 😊
In order to correctly perform the math, we need to do a bit of a hack (thanks, Hugo 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);
If we stopped here and used just min()
we would miss out on the fluid scaling because the browser would always use the $fluid-min
value:
font-size: min(#{$fluid-min}, #{$level-size)};
Instead, we need to nest the max()
function, but we're missing one value which I have taken to calling the "scaler" - as in, the value that causes the fluid scaling to occur.
I'd like to pause to acknowledge that min()
and max()
are a little bit mind-bending to understand.
For max()
, MDN says:
The
max()
function takes one or more comma-separated expressions as its parameter, with the largest (most positive) expression value used as the value of the property to which it is assigned.
What this means to our fluid typography is that we can integrate viewport units into a size option, and as long as the computed viewport-unit-based font-size
is larger, it will be selected by max()
. Combining this with min()
to set an upper limit of the $level-size
, this creates the fluid effect.
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: unquote("min(max(#{$fluid-min}rem, #{$fluid-scaler}), #{$level-size})");
Unfortunately with Sass, we have to use the unquote
function due to built-in Sass min/max functions incorrectly assuming the intent is to select the min value during compilation versus output the CSS definition using min/max.
In Closing...
If you really read this whole episode, thank you so much for sticking with it. I look forward to your feedback, please reach out on DEV or Twitter. 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.
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.
Top comments (1)
Hi Stephanie! Thanks so very much for this article, it's helped quite a lot. I have one question/clarification/point of confusion though...
Using your final Codepen demo and adding h5 or h6 or both, the other headings become nearly identical in size at smaller viewport widths. See the h2 and h3 sizes:
Why is this?
Is this expected?
Is there a way to avoid this?
Here's my updated pen where you can see this in action: codepen.io/danlewski/pen/zYZNoWP
(Please suggest how I can clean up my JS too, if you don't mind, I'm a noob when it comes to that stuff but trying to learn more.)
As a side-note, I've been using this technique from Fred Simmons with a lot of success: gist.github.com/fsimmons/e9e64dc2f...
It allows you to make any attribute fluid and I haven't found any drawbacks from it yet.
Thanks and really look forward to hearing back!
~Danny