In print, space was never a number of its own. It was measured in ems, the size of the type being set. The spaces between words were fractions of it. Change the type size, and the space changed with it.
On screen we lost that. Designs arrived as pixel comps, so we wrote pixels: 16px body, 24px gap, 32px heading. Then screens multiplied, and every value got a table per breakpoint:
type: {
sizes: {
mobile: { base: '16px', l: '22px' },
desktop: { base: '18px', l: '32px' },
},
}
Nothing in that table says the heading is a step above the body, or that a gap belongs to the text it separates. On mobile l is 1.375 times base; on desktop it's 1.78 times. Nobody decided that. It's just what the numbers happen to be.
Version 4.0 of my design system package, @pindakaasman/design-system, replaces those tables with one relation.
One scale
Every font size and every spacing step is a step on one modular scale. base is 1rem, each step up multiplies by one ratio, and each step down divides by it. The ratio is the token author's choice; the template uses 1.333, a perfect fourth.
type: {
ratio: 1.333,
floor: 0.75,
base: {
mobile: '100%',
tablet: '100%',
tabletLandscape: '106.25%',
desktop: '112.5%',
},
}
Spacing uses the same scale as the type, as print did. There is no spacing table to tune. Type has five steps (s to xl); spacing has the same five plus xxs and xs below them.
| Step | Font size | Spacing |
|---|---|---|
| xxs | 0.4222rem | |
| xs | 0.5628rem | |
| s | 0.7502rem | 0.7502rem |
| base | 1rem | 1rem |
| m | 1.333rem | 1.333rem |
| l | 1.7769rem | 1.7769rem |
| xl | 2.3686rem | 2.3686rem |
Type stops at s because of type.floor: no font size resolves below 0.75rem. Anything smaller would be raised to the floor and merge with s. Spacing has no floor, so it can go further down.
One root
Per breakpoint, one value changes: the root font size. Everything else is a constant rem value, so it grows or shrinks with the root and keeps its proportions.
This is the CSS the package generates from the tokens above (rootCss()):
:root { font-size: 100%; }
@media (min-width: 768px) {
:root { font-size: 100%; }
}
@media (min-width: 1024px) {
:root { font-size: 106.25%; }
}
@media (min-width: 1440px) {
:root { font-size: 112.5%; }
}
:root {
--ds-font-size-s: 0.7502rem;
--ds-font-size-base: 1rem;
--ds-font-size-m: 1.333rem;
--ds-font-size-l: 1.7769rem;
--ds-font-size-xl: 2.3686rem;
--ds-spacing-xxs: 0.4222rem;
--ds-spacing-xs: 0.5628rem;
/* … the remaining spacing steps */
}
Here is the same page at 390px and at 1440px. The sizes are measured in the browser, not typed in.
| Step | 390px (root 16px) | 1440px (root 18px) |
|---|---|---|
| s | 12px | 13.5px |
| base | 16px | 18px |
| m | 21.3px | 24px |
| l | 28.4px | 32px |
| xl | 37.9px | 42.6px |
Different scale, same relation: at both widths, each step is 1.333 times the one below it.
Using it
Components never see a number. The accessors return CSS variables, so a component describes which step it is on, not how big that is:
const Card = styled.div`
background: ${({ theme }) => theme.color('background')};
padding: ${({ theme }) => theme.spacing('base')};
font-size: ${({ theme }) => theme.fontSize('base')};
`;
Because the values are CSS variables and the root changes through real @media rules, the browser resolves the breakpoint itself. Nothing is measured in JavaScript, so the server's HTML and the client's first render can't disagree about which breakpoint they are on.
The global CSS goes in once, next to the theme provider:
const RootStyle = createGlobalStyle`
${({ theme }) => theme.rootCss()}
`;
Rules that keep it honest
The root is a percentage, never px. font-size: 18px on the root overrides the text size the user set in their browser (WCAG 1.4.4). 112.5% scales with it.
The smallest screen is the reference. It stays at 100%, and wider screens grow from there. A phone is held closer than a laptop, so body text shouldn't shrink on it. There is also a practical reason: iOS Safari zooms in when you focus an input with text under 16px.
Touch targets are the exception, and they stay in px. minTarget() is 24px for a mouse or trackpad and 44px when there's a touch input. A finger doesn't shrink with the type. It's meant as a floor: min-block-size: max(${theme.minTarget()}, …).
The trade-off
Everything scales by the same factor. A heading can't shrink more than body text does on a small screen. When one component genuinely needs a different size at some breakpoint, it picks a different step with a media query. It doesn't get a second scale:
const Title = styled.h1`
font-size: ${({ theme }) => theme.fontSize('l')};
${({ theme }) => theme.mq.greaterThan('desktop')`
font-size: ${theme.fontSize('xl')};
`}
`;
The relation is still defined once. The exception is visible in the one component that needs it.
Try it
npm install @pindakaasman/design-system styled-components@^6
The package is on npm, and the source is in RamonGebben/mise.


Top comments (0)