Detailed guide

Resize Text Implementation Guide

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

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 Explained

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

Category hub

Hub

Wcag Compliance Hub

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