Detailed guide

Resize Text Implementation Guide: Complete Patterns for Scalable Typography

View contents

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

References

  1. W3C - WCAG 2.2 SC 1.4.4 Resize Text
  2. W3C - C28 Specifying the size of text containers using em units
  3. W3C - C12 Using percent for font sizes
  4. W3C - C14 Using em units for font sizes
  5. MDN - CSS values and units

Related articles

Related version

Introduction

Resize Text: Ensuring Content Remains Usable at 200% Zoom

Many users with low vision need to enlarge text to read comfortably

Category hub

Hub

WCAG 2.2 Compliance Hub: Complete Web Accessibility Guide

Web accessibility ensures that websites and applications can be used by everyone, including people with disabilities