aboutsummaryrefslogtreecommitdiffstats
path: root/blog/2024-04-05/variable-css-font-features.mdx
blob: 01c83eff93f82861ad933d4eb8510dcfd9cd5bb2 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
---
SPDX-FileCopyrightText: 2024 Kristóf Marussy
SPDX-License-Identifier: CC-BY-4.0
slug: variable-css-font-features
title: Variable CSS font features
description: How to set up and use font variation axes in CSS, create a custom subset with OpenType font features, and make the font features switch based on variation settings automatically.
tags:
  - css
  - design
  - frontend
authors: kris
---

import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';

import { LicenseLink, MITLicenseLink } from '../../src/components/licenses';

export { default as default } from './VariableAxisProvider';
import VariableAxisSettings from './VariableAxisSettings';
import VariableAxisSettingsWithFontFeaturesPreview from './VariableAxisSettingsWithFontFeaturesPreview';
import VariableAxisSettingsWithFontFeaturesToggle from './VariableAxisSettingsWithFontFeaturesToggle';
import VariableAxisSettingsWithToggle from './VariableAxisSettingsWithToggle';

As the first post on my blog, I decided to write something a bit more practical than "Hello World!" In particular, I'll show you how I **styled the text on this website.**

I selected [Recursive] as the font family for this site, which is an open source font available under the <LicenseLink to="https://openfontlicense.org/">OFL-1.1</LicenseLink>. It is a [variable font](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide), which means it defines a number of 'variation axes' (parameters) to seamlessly change the shape of the letters. This saved me a lot of headache about selecting a matching font for headings or for code, since all is included in a single font family.

[Recursive]: https://www.recursive.design/

In this post, I'll explain how I set up and use the **variation axes** of the font in CSS, how I created a custom subset with OpenType **font features,** and how I made the font features **automatically switch on and off** based on the variation axes' settings.

You can play around with the settings below to get a feel for the effect. You can also edit the text if you want, but I selected the most interesting characters already.

<VariableAxisSettingsWithToggle />

{/* truncate */}

I tried to make the post helpful for beginners and add a bit of exposition about using variable fonts. [Click here if you're only interested in the punchline!](#automatic-feature-switching)

You can grab [Recursive][recursive] from [Fontsource][fontsource] via the [`@fontsource-variable/recursive`](https://www.npmjs.com/package/@fontsource-variable/recursive) npm package.
We will [create our own subsets](#intermission-font-subsetting) from the original font files later to play with some advanced features.
The font comes in relatively heavy at almost 307 kB for the basic latin subset.
I decided to splurge and use it anyway, because it covers all the font styles I'd possibly need for this website -- it still beats loading 12 separate web fonts of 30 kB each, for example :smirk:.

[fontsource]: https://fontsource.org/fonts/recursive

## Variation axes

Recursive defines **5 variation axes:**

- The `"wght"` axis controls the weight of the text.
  We don't have to do anything special to use this axis, because it is mapped to the `font-weight` CSS property by browsers automatically.
- The `"slnt"` axis controls the slant of the font[^oblique].
  I couldn't get the browser to map this to `font-style: italic` properly, so we'll have to take care of this axis manually[^italic].
- The `"MONO"` axis blends the font between proportional and monospaced. Browsers don't support this out of the box.
- The `"CASL"` axis blends the font between serious and casual. Browsers don't support this out of the box either.
- The `"CRSV"` axis is the most interesting. At 0, you get normal letters, while at 1 you get <span style={/** @type {import('react').CSSProperties} */ ({ '--crsv': 1 })}>cursive</span> ones. At 0.5, you get **automatic switching:** normal letters for upright type and italic ones for text slanted more than 14 degrees[^slant].

[^oblique]: It goes in the direction opposite to the [`font-style: oblique`][font-style-prop] property.

[^italic]: The mapping of `font-style` to variable axes looks [quite complicated](https://github.com/w3c/csswg-drafts/issues/514): the [font matching algorithm](https://www.w3.org/TR/css-fonts-4/#font-style-matching) for `font-style: italic` will fall back to setting `"slnt" -14` (the default angle for [`font-style: oblique`][font-style-prop]) if the variable font has no `"ital"` axis. This is just off the mark for us, because we can only take advantage of the `"CRSV" 0.5` auto-switching for slants **bigger** than 14 degrees :disappointed:.

[font-style-prop]: https://www.w3.org/TR/css-fonts-4/#font-style-prop

[^slant]: Actually the slant will still be -14 degrees (the maximum), but the letters will be italic instead of just slanted.

The preview below illustrates the variation axes in action. Don't worry about the custom CSS properties; we'll get to the CSS for achieving this in the next section.

<VariableAxisSettings />

### Custom properties

I generate this static site with [Docusaurus][docusaurus], which uses the [Infima](https://infima.dev/) design system for styling. Therefore, I had to integrate the Recursive font with Infima's CSS. However, the CSS techniques in this post should apply to other design systems or just plain CSS as well.

[docusaurus]: https://docusaurus.io/

The [Recursive] documentation recommends using [CSS custom properties](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties) to take advantage of the `"slnt"`, `"MONO"`, `"CASL"`, and `"CRSV"` axes. This trick is originally from [Pixel Ambacht](https://pixelambacht.nl/2019/fixing-variable-font-inheritance/). We can apply it like this:

```css
:root {
  --ifm-font-family-base: 'Recursive Variable', sans-serif;
  --ifm-font-family-monospace: var(--ifm-font-family-base);
  --slnt: 0;
  --mono: 0;
  --casl: 0;
  --crsv: 0.5;
}

* {
  font-variation-settings:
    'slnt' var(--slnt),
    'MONO' var(--mono),
    'CASL' var(--casl),
    'CRSV' var(--crsv) !important;
}
```

Both the base font family (`--ifm-font-family-base`) and the monospace font family (`--ifm-font-family-monospace`) of Infima should be set to Recursive. We'll blend between proportional and monospace text with the `--mono` custom property later.

We set `"CRSV"` to 0.5 by default to take advantage of the auto-switching behavior for italic text. Thus, we'll be able to format italic text with just the `--slnt` custom property.

:::info

There is an issue here with CSS cascading and shorthand properties. Infima occasionally uses the `font` CSS shorthand property to set font size and weight. This overwrites `font-variation-settings` and erases our customizations unless we make them `!important`.

:::

### Styling text

In the absence of support for `font-style: italic`, we have to map the `<i>` and `<em>` HTML tags to slanted type manually:

```css
i, em { --slnt: -15; }
```

Make sure to format anything you also want to have italic with both `--slnt: -15` and `font-style: italic` so that the text gets properly displayed even if the browser loads a [fallback font](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display).

We also make `<code>` and `<kbd>` fully monospaced:

```css
code, kbd { --mono: 1; }
```

Similarly to the [Recursive] documentation, I decided to use the fully casual style for large headings (`<h1>`, `<h2>`). For smaller headings (`<h3>`), I use a blend between the normal and casual styles, because the fully casual one looked a bit too complex at a small point size:

```css
h1, h2, .markdown > h3 { --casl: 1; }
h3, .markdown > h4 { --casl: 0.25; }
```

:::info

Docusarus shifts the sizes of headings up by one for text generated from Markdown. Thus, we need for the `.markdown > h3` and `.markdown > h4` styles.

:::

## Font features

Some letters now look a little awkward: the two-story letter '<span style={/** @type {import('react').CSSProperties} */ ({ '--casl': 0.01 })}>g</span>' can be hard to read in small body text. Also, the '@' gets squished horizontally in monospace text way too much. There's even a [GitHub issue](https://github.com/arrowtype/recursive/issues/509) for that in the Recursive repository!

Luckily, Recursive has a number of **OpenType font features** that can change the shape of the characters. You can take a look at them in Recursive's [README page](https://github.com/arrowtype/recursive?tab=readme-ov-file#opentype-features). I also reproduced the image below, but it's a bit large, so you'll need to click to reveal it.

import recursiveFeatures from './recursive-v1.064-opentype_features.png?placeholder=true&sizes[]=360&sizes[]=559&sizes[]=930&sizes[]=1504&rl';
import recursiveFeaturesOriginal from './recursive-v1.064-opentype_features.png?url';

<details>
<summary>OpenType font features of Recursive</summary>

<div
  style={{
    backgroundImage: `url("${recursiveFeatures.placeholder}")`,
    backgroundSize: 'cover',
    width: '100%',
    aspectRatio: `${recursiveFeatures.width} / ${recursiveFeatures.height}`,
  }}
>
  <a href={recursiveFeaturesOriginal} target="_blank">
    <img
      src={recursiveFeatures.src}
      srcSet={recursiveFeatures.srcSet}
      width={recursiveFeatures.width}
      height={recursiveFeatures.height}
      loading="lazy"
      sizes="(min-width: 1440px) 704px, (min-width: 1140px) 559px, (min-width: 996px) calc(58.333vw - 66px), calc(100vw - 66px)"
      alt="Table of OpenType font features of Recursive"
      style={{ width: '100%', height: 'auto' }}
    />
  </a>
</div>

Image from [Recursive][recursive] is <LicenseLink to="https://openfontlicense.org/">OFL-1.1</LicenseLink>.

</details>

### Intermission: font subsetting

If you're using Recursive from [Fontsource][fontsource] or even Google fonts :cold_sweat:, the OpenType font features won't work at all: they've  been stripped away from the web font files to make them smaller. There's an open [GitHub issue](https://github.com/fontsource/fontsource/issues/39) in the Fontsource repository to preserve some features, but the problem is highly non-trivial at their scale.

We can solve this problem by grabbing the full [Recursive_VF_1.085.ttf](https://github.com/arrowtype/recursive/blob/6d491202cea5cf6a493ef710cbef2527b9b08939/fonts/ArrowType-Recursive-1.085/Recursive_Desktop/Recursive_VF_1.085.ttf) ourselves and creating our own web font. The full font comes in at a whopping 2.4 MB, so I recommend creating a subset with only the glyphs you use.

We can use the [pyftsubset](https://fonttools.readthedocs.io/en/latest/subset/index.html) tool from the [fonttols](https://github.com/fonttools/fonttools) Python package to create our own web font subset like this:

```
pyftsubset Recursive_VF_1.085.ttf
  --unicodes=U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD \
  --output-file=recursive-latin.woff2 \
  --flavor=woff2 \
  --layout-features+=ss02,ss05,ss10,ss12,titl \
  --desubroutinize \
  --obfuscate-names
```

I copied the list of `--unicodes` to include in the basic latin subset from [Fontsource](https://github.com/fontsource/font-files/blob/fd9fe9317a4ab7042845961a60b1cace22dc14d6/fonts/variable/recursive/full.css#L38).

You can specify the OpenType features (in addition to the standard ones) to include in the output font after `--layout-features+=`.

We're creating a WOFF2 web font for maximum compression, which has [over 97% browser support](https://caniuse.com/woff2). The `--desubroutinize` option should also help with compression.

:::warning

The [SIL Open Font License 1.1](https://openfontlicense.org/) has an infamous [Reserved Font Name&nbsp;(RFN)](https://openfontlicense.org/ofl-reserved-font-names/) clause. If you create a Modified Version of a font that has an RFN, you must also change its name. SIL considers a subset a Modified Version.

The `--obfuscate-names` option should take care of changing the font name in the font file. If you use a font that declares an RFN, you must also make sure not to refer to it with its original name outside the font file, e.g., in your CSS files.

Recursive doesn't declare any RFN, so we can keep referring to our custom subset as 'Recursive' without getting into any trouble. :relieved:

:::

Now we can include our custom subset in our CSS:

```css
@font-face {
  font-family: 'Recursive Variable';
  font-style: oblique 0deg 15deg;
  font-display: swap;
  font-weight: 300 1000;
  src: url(recursive-latin.woff2) format('woff2-variations');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191,
    U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
```

For this website, I actually wrote a [small Python script](https://git.marussy.com/blog/tree/scripts/subset_fonts.py) to generate the font subsets. I also use a [template](https://git.marussy.com/blog/tree/scripts/fonts.css.jinja2) for generating the corresponding CSS. Both files can be found in the [Git repository](https://git.marussy.com/blog/) of this website with <MITLicenseLink />.

### Selecting features

I decided to take the features `ss02`, `ss05`, `ss10`, `ss12`, and `titl` for a spin. You can also try them yourself below.

<VariableAxisSettingsWithFontFeaturesToggle />

After a bit of experimentation, I decided to use the features in the following way:

* The simplified look of the letter 'g' with the `ss02` feature is suitable for body text, but I prefer the two-story '<span style={/** @type {import('react').CSSProperties} */ ({'--casl': 0.01})}>g</span>' for headings.
* The `ss05` feature does nothing for proportional text, but makes the letter `l` more similar to that in my favorite coding font, [Fira Code](https://github.com/tonsky/FiraCode) in monospace text. We can safely set this for all text.
* The `ss10` (dotted zero) and `ss12` (simplified `@`) features affect proportional text, too, so we shouldn't set them for all text. But, for me, they make text more readable once the monospace letter shapes kick in after `--mono: 0.5`.
* The alternative '<span style={/** @type {import('react').CSSProperties} */ ({'--casl': 0.01})}>Q</span>' with `titl` is nice for headings, but I prefer the default 'Q' with descender for body text.

### Automatic feature switching

Introducing CSS custom properties for these font features -- like we did for the [variable axes](#custom-properties) -- would be a major pain when writing CSS by hand.

We can take some inspiration from the behavior of the `"CRSV"` axis: let's set the font feature _automatically_ based on the value of some variable axis!

My headings use the `"CASL"` axis, so I decided to switch `ss02` off and `titl` on if `--casl` is larger than 0.
For `ss10` and `ss12`, `--mono` larger than 0.5 gives a natural switching point, since this is where the other monospace letter shapes appear as well.

In short, we want something like

```math
\begin{aligned}
\texttt{ss02} &= \begin{cases}
1 & \text{if $\texttt{casl} = 0$,} \\
0 & \text{if $\texttt{casl} > 0$,}
\end{cases} \\
\texttt{titl} &= \begin{cases}
0 & \text{if $\texttt{casl} = 0$,} \\
1 & \text{if $\texttt{casl} > 0$,}
\end{cases} \\
\texttt{ss10} = \texttt{ss12} &= \begin{cases}
0 & \text{if $\texttt{mono} \le 0.5$,} \\
1 & \text{if $\texttt{mono} > 0.5$.}
\end{cases}
\end{aligned}
```

We will use [`calc()`](https://developer.mozilla.org/en-US/docs/Web/CSS/calc) to determine the values of [`font-feature-settings`](https://developer.mozilla.org/en-US/docs/Web/CSS/font-feature-settings).
However, looking through the list of [CSS math function](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Functions#math_functions), we find few options for creating discontinuous expressions. The [`round()`](https://developer.mozilla.org/en-US/docs/Web/CSS/round) function sounds promising, but it isn't supported by Chromium.

The trick is to replace our expressions with _continuous_ ones that we can build from widely supported CSS functions [`min()`](https://developer.mozilla.org/en-US/docs/Web/CSS/min) and [`clamp()`](https://developer.mozilla.org/en-US/docs/Web/CSS/clamp):

<Tabs>
  <TabItem value="code" label="Code" default>
    ```css
    * {
      font-feature-settings:
        'ss02' calc(min(var(--casl) * 1000, 1)),
        'ss05' 1,
        'ss10' calc(clamp(0, var(--mono) * 1000 - 500, 1)),
        'ss12' calc(clamp(0, var(--mono) * 1000 - 500, 1)),
        'titl' calc(1 - min(var(--casl) * 1000, 1)) !important;
    }
    ```
  </TabItem>
  <TabItem value="math" label="Math">
    ```math
    \begin{aligned}
    \texttt{ss02} = 1 - \mathit{min}(1000 \cdot \texttt{casl}, 1) &= \begin{cases}
    1 & \text{if $\texttt{casl} = 0$,} \\
    1 - 1000 \cdot \texttt{casl} & \text{if $0 < \texttt{casl} \le 0.001$,} \\
    0 & \text{if $\texttt{casl} > 0.001$,}
    \end{cases} \\
    \texttt{titl} = \mathit{min}(1000 \cdot \texttt{casl}, 1) &= \begin{cases}
    0 & \text{if $\texttt{casl} = 0$,} \\
    1000 \cdot \texttt{casl} & \text{if $0 < \texttt{casl} \le 0.001$,} \\
    1 & \text{if $\texttt{casl} > 0.001$,}
    \end{cases} \\
    \texttt{ss10} = \texttt{ss12} = \mathit{clamp}(0, 1000 \cdot \texttt{mono} - 500, 1) &= \begin{cases}
    0 & \text{if $\texttt{mono} \le 0.5$,} \\
    1000 \cdot \texttt{mono} - 500 & \text{if $0.5 < \texttt{mono} \le 0.501$,} \\
    1 & \text{if $\texttt{mono} > 0.501$.}
    \end{cases}
    \end{aligned}
    ```

    Each CSS expression agrees with the desired discontinous expression everywhere except a tiny interval of width 0.001 around the decision boundary. We position the intervals so that commonly used variable axis values (like 0, 1, and 0.5) don't cause any problems.
  </TabItem>
</Tabs>

This works as long as we only set `--casl` and `--mono` with up to 0.001 precision. Values like 0.0005 or 0.5005 will likely cause problems, because they try to set a non-integer value for a font feature. In my testing, this made both Chromium and Firefox to ignore the `font-feature-settings` rule completely.

In practice, we hardly set a value with more than 0.1 precision, because very slight changes in the letter shapes are barely perceptible.

You can set the variables axes below with up to 0.01 precision:

<VariableAxisSettingsWithFontFeaturesPreview />

## Conclusion

I've shown you how to set variable axes and font features for the [Recursive][recursive] font with some CSS custom property and function magic.
I also managed to include every Markdown feature and the kitchen sink in this post to try out [Docusarus][docusaurus] as a static site generator.