DEV Community

Cover image for Responsive type and spacing by changing only the root font size
Ramon Gebben
Ramon Gebben

Posted on AI-assisted

Responsive type and spacing by changing only the root font size

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' },
  },
}
Enter fullscreen mode Exit fullscreen mode

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%',
  },
}
Enter fullscreen mode Exit fullscreen mode

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 */
}
Enter fullscreen mode Exit fullscreen mode

Here is the same page at 390px and at 1440px. The sizes are measured in the browser, not typed in.

The specimen page at 1440px wide: root 18px, xl 42.6px

The specimen page at 390px wide: root 16px, xl 37.9px

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')};
`;
Enter fullscreen mode Exit fullscreen mode

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()}
`;
Enter fullscreen mode Exit fullscreen mode

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')};
  `}
`;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The package is on npm, and the source is in RamonGebben/mise.

Top comments (0)