View contents
Resize Text Implementation Guide: Complete Patterns for Scalable Typography
Introduction
Creating scalable typography that works at 200% zoom requires careful attention to CSS units, container sizing, and layout flexibility. This guide covers complete implementation patterns for resize-friendly text that meets WCAG 1.4.4 requirements while maintaining visual quality at any size.
Beyond compliance, well-implemented scalable typography improves user experience across devices and accommodates diverse user preferences.
CSS Unit System for Scalable Text
Understanding Relative Units
/*
rem = relative to root element (html) font size
em = relative to parent element font size
% = relative to parent element
vw/vh = relative to viewport width/height
Default browser font size: 16px
1rem = 16px (by default)
1em = parent font size
*/
/* ROOT SIZING: Set base on html for predictable rem calculations */
html {
font-size: 100%; /* Respects user browser settings */
/* Avoid: font-size: 16px; - overrides user preferences */
}
/* BODY DEFAULTS */
body {
font-size: 1rem; /* 16px default */
line-height: 1.5; /* Unitless for best scaling */
letter-spacing: 0.01em; /* Scales with text */
}
Typography Scale with rem
/* Type scale using rem for consistent sizing */
:root {
/* Modular scale (ratio 1.25 - Major Third) */
--text-xs: 0.64rem; /* 10.24px */
--text-sm: 0.8rem; /* 12.8px */
--text-base: 1rem; /* 16px */
--text-lg: 1.25rem; /* 20px */
--text-xl: 1.563rem; /* 25px */
--text-2xl: 1.953rem; /* 31.25px */
--text-3xl: 2.441rem; /* 39px */
--text-4xl: 3.052rem; /* 48.83px */
}
/* Apply to elements */
h1 { font-size: var(--text-4xl); }
h2 { font-size: var(--text-3xl); }
h3 { font-size: var(--text-2xl); }
h4 { font-size: var(--text-xl); }
h5 { font-size: var(--text-lg); }
h6 { font-size: var(--text-base); }
p, li, td { font-size: var(--text-base); }
small, .caption { font-size: var(--text-sm); }
Fluid Typography with clamp()
/* Fluid typography that scales smoothly */
:root {
/* clamp(minimum, preferred, maximum) */
--fluid-sm: clamp(0.8rem, 0.75rem + 0.25vw, 0.875rem);
--fluid-base: clamp(1rem, 0.9rem + 0.5vw, 1.125rem);
--fluid-lg: clamp(1.25rem, 1.1rem + 0.75vw, 1.5rem);
--fluid-xl: clamp(1.5rem, 1.25rem + 1.25vw, 2rem);
--fluid-2xl: clamp(2rem, 1.5rem + 2.5vw, 3rem);
--fluid-3xl: clamp(2.5rem, 2rem + 2.5vw, 4rem);
}
/* Usage */
h1 {
font-size: var(--fluid-3xl);
/* Scales between 2.5rem and 4rem based on viewport */
/* Always respects 200% zoom */
}
body {
font-size: var(--fluid-base);
}
Container Patterns
Flexible Height Containers
/* WRONG: Fixed height clips text at larger sizes */
.card-header {
height: 60px;
overflow: hidden;
}
/* CORRECT: Minimum height with growth */
.card-header {
min-height: 60px;
padding: 1em;
/* Content can expand */
}
/* CORRECT: Auto height with padding */
.card-content {
padding: 1.5em;
/* Height determined by content */
}
Flexible Width Patterns
/* Text containers should be flexible */
.text-container {
width: 100%;
max-width: 65ch; /* Optimal reading length */
padding: 1em;
}
/* Sidebar that doesn't break at zoom */
.sidebar {
flex: 0 0 auto;
width: clamp(200px, 25%, 300px);
min-width: min-content; /* Never smaller than content */
}
/* Main content area */
.main-content {
flex: 1 1 auto;
min-width: 0; /* Allows flex shrinking */
}
Grid Layout for Zoom
/* Grid that adapts to content size */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr));
gap: 1.5rem;
}
/* This grid:
- Cards have minimum 280px width
- Or 100% if viewport smaller than 280px
- Automatically reflows at zoom
*/
React Implementation
Scalable Typography Component
import React from 'react';
interface TextProps {
as?: 'p' | 'span' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'small';
size?: 'xs' | 'sm' | 'base' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl';
children: React.ReactNode;
className?: string;
}
const sizeClasses = {
xs: 'text-xs', // 0.64rem
sm: 'text-sm', // 0.8rem
base: 'text-base', // 1rem
lg: 'text-lg', // 1.25rem
xl: 'text-xl', // 1.563rem
'2xl': 'text-2xl', // 1.953rem
'3xl': 'text-3xl', // 2.441rem
'4xl': 'text-4xl', // 3.052rem
};
export function Text({
as: Component = 'p',
size = 'base',
children,
className = ''
}: TextProps) {
return (
<Component className={`${sizeClasses[size]} ${className}`}>
{children}
</Component>
);
}
// Flexible container component
interface ContainerProps {
children: React.ReactNode;
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | 'prose';
className?: string;
}
const maxWidthClasses = {
sm: 'max-w-sm', // 24rem
md: 'max-w-md', // 28rem
lg: 'max-w-lg', // 32rem
xl: 'max-w-xl', // 36rem
prose: 'max-w-prose' // 65ch
};
export function Container({
children,
maxWidth = 'prose',
className = ''
}: ContainerProps) {
return (
<div className={`w-full ${maxWidthClasses[maxWidth]} px-4 ${className}`}>
{children}
</div>
);
}
Responsive Card Component
interface CardProps {
title: string;
description: string;
children?: React.ReactNode;
}
export function Card({ title, description, children }: CardProps) {
return (
<article
className="
bg-white rounded-lg shadow-md
p-4 sm:p-6
/* Flexible sizing */
min-h-0
/* Allow content to determine height */
"
>
<h3
className="
text-lg sm:text-xl
font-semibold
mb-2
/* No truncation - let text wrap */
"
>
{title}
</h3>
<p
className="
text-base
text-gray-600
mb-4
/* Natural line wrapping */
leading-relaxed
"
>
{description}
</p>
{children}
</article>
);
}
Vue 3 Implementation
Typography System
<!-- composables/useTypography.ts -->
<script setup lang="ts">
import { computed } from 'vue'
type TextSize = 'xs' | 'sm' | 'base' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl'
const sizeMap: Record<TextSize, string> = {
xs: '0.64rem',
sm: '0.8rem',
base: '1rem',
lg: '1.25rem',
xl: '1.563rem',
'2xl': '1.953rem',
'3xl': '2.441rem',
'4xl': '3.052rem'
}
export function useTypography(size: TextSize = 'base') {
const fontSize = computed(() => sizeMap[size])
const style = computed(() => ({
fontSize: fontSize.value,
lineHeight: 1.5
}))
return { fontSize, style }
}
</script>
<!-- components/ScalableText.vue -->
<script setup lang="ts">
import { computed } from 'vue'
type TextSize = 'xs' | 'sm' | 'base' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl'
interface Props {
as?: string
size?: TextSize
}
const props = withDefaults(defineProps<Props>(), {
as: 'p',
size: 'base'
})
const sizeClasses: Record<TextSize, string> = {
xs: 'text-xs',
sm: 'text-sm',
base: 'text-base',
lg: 'text-lg',
xl: 'text-xl',
'2xl': 'text-2xl',
'3xl': 'text-3xl',
'4xl': 'text-4xl'
}
const textClass = computed(() => sizeClasses[props.size])
</script>
<template>
<component :is="as" :class="textClass">
<slot />
</component>
</template>
<style scoped>
.text-xs { font-size: 0.64rem; }
.text-sm { font-size: 0.8rem; }
.text-base { font-size: 1rem; }
.text-lg { font-size: 1.25rem; }
.text-xl { font-size: 1.563rem; }
.text-2xl { font-size: 1.953rem; }
.text-3xl { font-size: 2.441rem; }
.text-4xl { font-size: 3.052rem; }
</style>
Tailwind CSS Configuration
Custom Typography Scale
// tailwind.config.js
module.exports = {
theme: {
fontSize: {
'xs': ['0.64rem', { lineHeight: '1.5' }],
'sm': ['0.8rem', { lineHeight: '1.5' }],
'base': ['1rem', { lineHeight: '1.5' }],
'lg': ['1.25rem', { lineHeight: '1.4' }],
'xl': ['1.563rem', { lineHeight: '1.3' }],
'2xl': ['1.953rem', { lineHeight: '1.2' }],
'3xl': ['2.441rem', { lineHeight: '1.2' }],
'4xl': ['3.052rem', { lineHeight: '1.1' }],
},
extend: {
// Fluid typography utilities
fontSize: {
'fluid-sm': 'clamp(0.8rem, 0.75rem + 0.25vw, 0.875rem)',
'fluid-base': 'clamp(1rem, 0.9rem + 0.5vw, 1.125rem)',
'fluid-lg': 'clamp(1.25rem, 1.1rem + 0.75vw, 1.5rem)',
'fluid-xl': 'clamp(1.5rem, 1.25rem + 1.25vw, 2rem)',
'fluid-2xl': 'clamp(2rem, 1.5rem + 2.5vw, 3rem)',
'fluid-3xl': 'clamp(2.5rem, 2rem + 2.5vw, 4rem)',
}
}
}
}
Automated Testing
Playwright Tests for Text Resize
import { test, expect } from '@playwright/test';
test.describe('Text Resize Accessibility', () => {
test('content remains visible at 200% zoom', async ({ page }) => {
await page.goto('/');
// Get initial viewport
const viewportSize = page.viewportSize();
// Simulate 200% zoom by halving viewport
await page.setViewportSize({
width: viewportSize.width / 2,
height: viewportSize.height / 2
});
// Check that key content is still visible
const heading = page.locator('h1').first();
await expect(heading).toBeVisible();
// Check no horizontal scrollbar needed for main content
const hasHorizontalScroll = await page.evaluate(() => {
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
});
// Some horizontal scroll may be acceptable, but excessive is not
// We check if main content area requires scroll
const mainContent = page.locator('main');
const mainBox = await mainContent.boundingBox();
expect(mainBox.width).toBeLessThanOrEqual(viewportSize.width / 2 + 20);
});
test('text is not clipped in containers', async ({ page }) => {
await page.goto('/');
// Zoom to 200%
await page.setViewportSize({
width: 640,
height: 480
});
// Find all text containers
const textContainers = page.locator('p, h1, h2, h3, h4, h5, h6, li, td, th');
const count = await textContainers.count();
for (let i = 0; i < Math.min(count, 20); i++) {
const container = textContainers.nth(i);
const isVisible = await container.isVisible();
if (isVisible) {
// Check overflow is not hidden with content clipping
const hasOverflowHidden = await container.evaluate(el => {
const style = window.getComputedStyle(el);
const parent = el.parentElement;
const parentStyle = parent ? window.getComputedStyle(parent) : null;
// Check if content is clipped
return (style.overflow === 'hidden' || parentStyle?.overflow === 'hidden') &&
(el.scrollHeight > el.clientHeight);
});
expect(hasOverflowHidden).toBe(false);
}
}
});
test('font sizes use relative units', async ({ page }) => {
await page.goto('/');
// Check that body and main text use relative units
const textElements = page.locator('body, p, h1, h2, h3, h4, h5, h6');
const count = await textElements.count();
for (let i = 0; i < count; i++) {
const element = textElements.nth(i);
const fontSize = await element.evaluate(el => {
return window.getComputedStyle(el).fontSize;
});
// Font size should be in px (computed) but should scale with zoom
// We verify by checking it's not an absolute pixel declaration in stylesheet
const inlineStyle = await element.getAttribute('style');
if (inlineStyle) {
// Check no fixed px font-size in inline styles
expect(inlineStyle).not.toMatch(/font-size:\s*\d+px/);
}
}
});
test('interactive elements remain usable at zoom', async ({ page }) => {
await page.goto('/');
// Zoom to 200%
await page.setViewportSize({ width: 640, height: 480 });
// Check buttons are still visible and clickable
const buttons = page.locator('button, [role="button"], a');
const count = await buttons.count();
for (let i = 0; i < Math.min(count, 10); i++) {
const button = buttons.nth(i);
const isVisible = await button.isVisible();
if (isVisible) {
// Button should be in viewport or scrollable to
const box = await button.boundingBox();
expect(box).not.toBeNull();
expect(box.width).toBeGreaterThan(0);
expect(box.height).toBeGreaterThan(0);
}
}
});
});
Summary Checklist
- [ ] All font sizes use rem or em units
- [ ] Root font size respects user preferences (use % or nothing)
- [ ] Line heights are unitless for proper scaling
- [ ] Containers use min-height instead of fixed height
- [ ] Layouts use flexible widths (%, flex, grid)
- [ ] No overflow:hidden on text containers
- [ ] Content remains visible at 200% zoom
- [ ] No horizontal scrolling required for main content
- [ ] Interactive elements remain accessible at zoom
- [ ] Tested with browser zoom and text-only zoom