A variable font is a single font file that contains a continuous range of styles along one or more axes, such as weight and width. You declare the ranges in @font-face, then pick any value in CSS with font-weight, font-stretch, or font-variation-settings. One download replaces a family of separate files, and the axes can be animated.
The wordmark at the top of our homepage changes shape as you move the pointer across it. The letters get narrower toward the left of the screen and wider toward the right. Section headings further down widen as they scroll into view, and the accordion titles stretch out when you open them.
None of that is an image or an SVG trick. It is ordinary text set in one font file. The display typeface on this site, Anybody, is a variable font with a weight axis and a width axis, and nearly every heading you see is the same file at a different setting.
This post walks through the CSS and the small script that drive it.
What is a variable font?
A variable font is one font file that holds a whole range of styles instead of a single one. The designer defines axes, such as weight or width, and the browser can render any point along them. A static family needs a separate file for each style, while a variable font needs one.
That changes how you think about type on a page. With static files, each extra weight or width is another download, so you ration them. With a variable file, a heading at 62 percent width and another at 100 percent cost nothing extra. Our display font file is about 56 kB and covers every heading on the site.
| Static font files | One variable font | |
|---|---|---|
| Files for five widths | Five | One |
| In-between values | Not available | Any value in the declared range |
| Animating a change | Jumps from one file to another | Moves continuously |
| Worth it when | You use one or two styles | You use several styles or want motion |
That last row matters. If a site uses one weight of one typeface, a single static file is often smaller. A variable font earns its place when you would otherwise load three or more styles.
How do you declare a variable font in CSS?
You declare a variable font with a normal @font-face rule and give ranges instead of single values. font-weight: 100 900 tells the browser the file covers every weight in between, and font-stretch: 50% 150% does the same for width. Without the ranges, the browser treats the file as one fixed style.
This is the rule at the top of our stylesheet:
@font-face {
font-family: 'Anybody';
src: url('/fonts/anybody-1.woff2') format('woff2');
font-weight: 100 900;
font-stretch: 50% 150%;
font-display: swap;
}
After that, the standard properties address the axes. Every heading on the site starts from one rule:
h1, h2, h3 {
font-family: var(--display);
font-weight: 800;
font-stretch: 78%;
}
Individual components then pick their own width. Open the Regulars case page and you can see several at once: the page title inherits the 78 percent heading width, the values in the facts panel are set at 85 percent, and the project names in the pager at the bottom are back at 78. The large numbers on the homepage use 70. It is one file throughout.
How do you animate a font's width axis?
To animate a font axis from both CSS and JavaScript, register a custom property with @property and feed it to font-variation-settings. Registering the property gives it a type, here a number, which is what allows the browser to interpolate it in keyframes instead of flipping from one value to the other.
@property --wdth {
syntax: '<number>';
inherits: true;
initial-value: 100;
}
.wordmark {
font-weight: 900;
font-variation-settings: 'wdth' var(--wdth);
}
@keyframes open { from { --wdth: 50; opacity: 0; } 30% { opacity: 1; } to { --wdth: 100; } }
When the homepage loads, the wordmark runs the open animation and widens from 50 to 100. An unregistered custom property cannot do this: the browser has no idea --wdth is a number, so it has nothing to interpolate.
For simple cases you do not need any of it. font-stretch is animatable by itself, and our accordion headings use a plain transition:
.acc summary h3 { font-stretch: 62%; transition: font-stretch 0.5s cubic-bezier(0.2, 0.7, 0.2, 1), translate 0.4s; }
.acc summary:hover h3, .acc[open] summary h3 { font-stretch: 100%; }
Prefer the high-level properties when they are enough. font-variation-settings is the low-level escape hatch, and it overrides them. We use it on the wordmark only because a custom property gives the script and the keyframes one shared number to write to.
How does the wordmark follow the pointer?
The wordmark follows the pointer with a short script that maps the pointer's horizontal position to a width value and writes it to the --wdth custom property. It eases toward the target a little each frame, so the letters glide instead of snapping, and it stops its animation loop once it arrives.
let cur = 100, target = 100, raf = 0;
const tick = () => {
cur += (target - cur) * 0.09;
mark.style.setProperty('--wdth', cur.toFixed(2));
raf = Math.abs(target - cur) > 0.05 ? requestAnimationFrame(tick) : 0;
};
hero.addEventListener('pointermove', (e) => {
target = lo + (hi - lo) * (e.clientX / innerWidth);
if (!raf) raf = requestAnimationFrame(tick);
});
hero.addEventListener('pointerleave', () => { target = 100; if (!raf) raf = requestAnimationFrame(tick); });
lo and hi are read from data attributes on the heading, so the range is set in the markup. The far left of the screen maps to the low value and the far right to the high one.
Two guards matter more than the animation. The whole block only runs if the device has a hovering pointer and the visitor has not asked for reduced motion. And the loop is not permanent: once the current value is within 0.05 of the target, no further frame is requested, so a still pointer costs nothing.
How do headings widen as you scroll?
Headings widen on scroll by animating the same registered property with a scroll-driven animation. The keyframes run from narrow to wide, and animation-timeline: view() ties their progress to the heading's position in the viewport instead of to a clock. No scroll listener is involved.
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.h-stretch {
animation: stretch linear both;
animation-timeline: view();
animation-range: entry 0% cover 50%;
font-variation-settings: 'wdth' var(--wdth);
}
@keyframes stretch { from { --wdth: 50; } to { --wdth: 105; } }
}
}
Scroll-driven animations are not in every browser yet, which is why the rule sits inside @supports. Where they are missing, the heading simply renders at its normal width. We go deeper on that feature in our post on the pinned horizontal reel.
What should you check before using a variable font?
Before adopting a variable font, check that the file really contains the axes you want, that it is smaller than the static files it replaces, and that changing width does not break your layout. Animated width changes the length of a line, so text can wrap differently as it moves.
- Confirm which axes the font has and their ranges, and declare exactly those in
@font-face. - Compare the file size with the static styles you would otherwise load.
- Serve the file from your own domain as WOFF2, and preload it if it is used above the fold.
- Set
font-display: swapso text is readable while the font loads. - Test animated headings at narrow screen sizes for wrapping and jumps.
- Respect reduced motion for any animated axis.
Fonts are a real part of page weight, which we cover in setting a performance budget.
Frequently asked questions
Do all browsers support variable fonts?
Variable fonts and ranges in @font-face are supported by all current major browsers and have been for years. The newer pieces in this post are different: scroll-driven animations are not universal yet, so guard them with a feature query as we do.
Are variable fonts bad for page speed?
Not by themselves. A variable file is usually larger than one static file and smaller than several. If you use one style, load one static file. If you use many, the variable font is normally the lighter choice and saves requests.
Can you animate font weight the same way as width?
Yes. Weight is an axis like any other, and font-weight can be transitioned directly when the font declares a range. The same registered custom property approach works for any axis the font provides, including custom ones the designer has added.
Does changing letter width cause layout shift?
It can. Wider letters make a longer line, which may wrap or push neighbours. Our wordmark's pointer range tops out at its normal width, so it never grows past the size the layout was built for. Test animated headings at small widths.
Where do you find variable fonts?
Many open source typefaces are published as variable fonts, and most type foundries now sell them. Check the licence allows self-hosting on the web before you ship the file.
If you want a site where the type does some of the work, tell us about the project.