View contents
Introduction
Many users with low vision need to enlarge text to read comfortably. WCAG 1.4.4 (Resize Text) requires that text can be resized up to 200% without losing content or functionality. This means your website must remain usable when users zoom in using browser controls or assistive technology.
When websites use fixed pixel sizes or don’t account for text scaling, users may encounter cut-off content, overlapping text, or broken layouts that make the site unusable at larger text sizes.
What WCAG Requires
1.4.4 Resize Text (Level AA)
Text can be resized without assistive technology up to 200 percent without loss of content or functionality.
Exceptions:
- Captions (for video)
- Images of text
This means:
- Users must be able to zoom to 200% using browser controls
- All text must remain readable at this zoom level
- No content should be clipped, cut off, or hidden
- All functionality must remain operable
- Users shouldn’t need to scroll horizontally on standard viewports
Who Benefits
| User Type | Benefit |
|---|---|
| Users with low vision | Can enlarge text to comfortable reading size |
| Older users | May need larger text for readability |
| Users with cognitive disabilities | Larger text can improve comprehension |
| Mobile users | Can zoom on small screens |
| All users | Flexibility to adjust text size to preference |
Common Problems
1. Fixed Pixel Font Sizes
/* BAD: Fixed pixels don't scale with user preferences */
body {
font-size: 14px;
}
h1 {
font-size: 24px;
}
/* GOOD: Relative units scale with user settings */
body {
font-size: 1rem; /* 16px default, scales with user preference */
}
h1 {
font-size: 1.5rem; /* 24px at default, scales proportionally */
}
2. Fixed Height Containers
/* BAD: Fixed height cuts off enlarged text */
.card-title {
height: 48px;
overflow: hidden;
}
/* GOOD: Flexible height accommodates larger text */
.card-title {
min-height: 48px;
overflow: visible;
}
3. Text in Fixed-Width Containers
/* BAD: Fixed width causes overflow at larger text sizes */
.sidebar {
width: 200px;
}
/* GOOD: Flexible width or responsive design */
.sidebar {
width: 100%;
max-width: 200px;
}
@media (min-width: 768px) {
.sidebar {
width: 25%;
}
}
4. Viewport Units for Font Size
/* BAD: vw units don't respond to text-only zoom */
h1 {
font-size: 5vw;
}
/* BETTER: Clamp with rem for fallback */
h1 {
font-size: clamp(1.5rem, 5vw, 3rem);
}
Testing Resize Text
Browser Zoom Test
- Open your website in a browser
- Use Ctrl/Cmd + to zoom to 200%
- Verify all text is readable
- Check that no content is cut off or hidden
- Ensure all interactive elements are still usable
- Test horizontal scrolling (should be minimal on typical content)
Text-Only Zoom Test
Some browsers offer text-only zoom:
- Firefox: View → Zoom → Zoom Text Only
- This tests if your layout handles larger text specifically
What to Check
- [ ] All text is readable at 200% zoom
- [ ] No text is cut off or truncated
- [ ] Interactive elements are still clickable/tappable
- [ ] Forms remain functional
- [ ] Navigation is still usable
- [ ] No excessive horizontal scrolling
- [ ] Images don’t overlap text
Good Practices
Use Relative Units
/* Font sizes with rem */
body { font-size: 1rem; }
h1 { font-size: 2rem; }
h2 { font-size: 1.5rem; }
p { font-size: 1rem; }
small { font-size: 0.875rem; }
/* Spacing with em (relative to font size) */
.card {
padding: 1em;
margin-bottom: 1.5em;
}
/* Line height (unitless for best scaling) */
body {
line-height: 1.5;
}
Flexible Containers
/* Use min-height instead of fixed height */
.content-box {
min-height: 200px;
/* Not: height: 200px; */
}
/* Allow text to wrap */
.button {
white-space: normal;
/* Not: white-space: nowrap; */
}
Responsive Design
/* Breakpoints for different screen sizes */
.container {
padding: 1rem;
}
@media (min-width: 768px) {
.container {
padding: 2rem;
}
}
/* Fluid typography */
h1 {
font-size: clamp(1.5rem, 4vw + 1rem, 3rem);
}
Impact of Poor Text Scaling
When text resize isn’t supported:
| Issue | Impact |
|---|---|
| Cut-off text | Users can’t read important information |
| Overlapping elements | Content becomes illegible |
| Hidden interactive elements | Users can’t complete tasks |
| Horizontal scrolling | Navigation becomes frustrating |
| Broken layouts | Professional appearance is damaged |
Tools for Testing
- Browser DevTools: Simulate zoom levels
- Zoom browser extension: Test various zoom percentages
- WAVE: Identifies potential zoom issues
- axe DevTools: Reports resize text failures
- Manual testing: Essential for comprehensive verification
Best Practices Summary
| Do | Don’t |
|---|---|
| Use rem/em for font sizes | Use fixed pixel font sizes |
| Use min-height for containers | Use fixed heights that clip content |
| Allow text to wrap naturally | Force nowrap on text |
| Test at 200% zoom regularly | Assume pixel-perfect designs scale |
| Use fluid typography | Rely solely on viewport units |
| Design for flexibility | Design only for default text size |
Implementation
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
Related Articles
- Color Contrast Guide - Visual accessibility
- WCAG Compliance Hub - All accessibility evaluators