Generating `font-size` CSS Rules and Creating a Fluid Type Scale

Originally posted May 27, 2020 on DEV Written by Stephanie Eckles

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.

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:

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:

By Stephanie Eckles (@5t3ph)

Learn more about units including rem and em 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

preview of 'perfectFourth' type sizes

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.

By Stephanie Eckles (@5t3ph)