UXR SEO Analyzer documentation

Introduction

Resize Text

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

  1. Open your website in a browser
  2. Use Ctrl/Cmd + to zoom to 200%
  3. Verify all text is readable
  4. Check that no content is cut off or hidden
  5. Ensure all interactive elements are still usable
  6. 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

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 - G179 Ensuring no loss of content when scaling
  4. WebAIM - Accessible CSS
  5. W3C - C12 Using percent for font sizes
  6. W3C - C14 Using em units for font sizes
  7. MDN - CSS values and units

Related articles

Category hub

Hub

WCAG 2.2 Compliance Hub

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

Last updated: