HTML and CSS course ยท Module 9: Modern CSS

Cross-Browser Testing

16 min read ยท
In this lesson7

Your page looks perfect in Chrome on your laptop, but on the client's iPhone the grid falls apart, because every browser is a different team of stonemasons reading the same plan in its own way. Cross-browser testing is a process that ensures web applications work correctly across all supported browsers and on various devices. Differences in the implementation of web standards can lead to inconsistencies in appearance and functionality.

Cross-Browser Testing Basics

Main Browsers to Test

Modern browsers are built on three engines: Blink (Chrome, Edge, Opera, Samsung Internet), WebKit (Safari) and Gecko (Firefox):

// Browser usage statistics (2024)
const browserStats = {
  chrome: "65%",      // Blink engine
  safari: "19%",      // WebKit engine
  edge: "5%",         // Blink engine (Chromium-based)
  firefox: "3%",      // Gecko engine
  opera: "2%",        // Blink engine
  others: "6%"
};

// Mobile browsers
const mobileBrowsers = {
  chrome: "65%",
  safari: "25%",
  samsung: "4%",
  opera: "2%",
  firefox: "1%",
  others: "3%"
};

The numbers in the example are approximate, and you will find current shares on services such as StatCounter. On the iPhone, browsers had to use WebKit for years, so when you test the Safari engine, you largely test Chrome on iOS as well.

Key Differences Between Browsers

1. CSS Support

New properties used to reach browsers with vendor prefixes. We write the prefixed versions first and the standard one last:

/* Vendor prefixes for newer features */
.element {
  -webkit-transform: rotate(45deg);  /* Safari, Chrome */
  -moz-transform: rotate(45deg);     /* Firefox */
  -ms-transform: rotate(45deg);      /* Internet Explorer */
  transform: rotate(45deg);          /* Standard */
}

/* Grid implementation differences */
.grid-container {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
  gap: 20px; /* IE doesn't support gap in Grid */
}

/* Fallback for IE */
@supports not (gap: 20px) {
  .grid-container > * {
    margin: 10px;
  }
}

The order matters: a browser that knows the standard uses the last declaration. Today transform needs no prefixes, and Autoprefixer adds the ones that are still needed.

2. JavaScript API Differences

In JavaScript, before using a new API we check whether it exists (feature detection):

// Fetch API - not supported in IE
function fetchDataCompatible(url) {
  if (window.fetch) {
    // Modern browsers
    return fetch(url).then(response => response.json());
  } else {
    // Fallback for older browsers
    return new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest();
      xhr.open('GET', url);
      xhr.onload = () => {
        if (xhr.status === 200) {
          resolve(JSON.parse(xhr.responseText));
        } else {
          reject(new Error('Request failed'));
        }
      };
      xhr.onerror = () => reject(new Error('Network error'));
      xhr.send();
    });
  }
}

// Intersection Observer - requires polyfill in IE
function observeElementsCompatible(elements, callback) {
  if ('IntersectionObserver' in window) {
    const observer = new IntersectionObserver(callback);
    elements.forEach(el => observer.observe(el));
  } else {
    // Fallback - scroll listener
    function checkVisibility() {
      elements.forEach(element => {
        const rect = element.getBoundingClientRect();
        const isVisible = rect.top < window.innerHeight && rect.bottom > 0;
        if (isVisible) callback([{ target: element, isIntersecting: true }]);
      });
    }

    window.addEventListener('scroll', checkVisibility);
    window.addEventListener('resize', checkVisibility);
    checkVisibility();
  }
}

When fetch or IntersectionObserver does not exist, the code falls back to an older solution. We check the feature, not the browser's name.

Cross-Browser Testing Tools

1. BrowserStack

Cloud device farms provide real browsers and systems for automated tests:

// BrowserStack configuration for automated tests
const browserStackConfig = {
  user: process.env.BROWSERSTACK_USERNAME,
  key: process.env.BROWSERSTACK_ACCESS_KEY,

  capabilities: [
    {
      browserName: 'Chrome',
      browserVersion: 'latest',
      os: 'Windows',
      osVersion: '10'
    },
    {
      browserName: 'Safari',
      browserVersion: 'latest',
      os: 'OS X',
      osVersion: 'Big Sur'
    },
    {
      browserName: 'Firefox',
      browserVersion: 'latest',
      os: 'Windows',
      osVersion: '10'
    }
  ]
};

The login credentials come from environment variables, never from the code.

2. Sauce Labs

Sauce Labs works similarly, and the YAML configuration points to the Cypress test suites and browsers:

# .sauce/config.yml
apiVersion: v1alpha
kind: cypress
sauce:
  region: eu-central-1

suites:
  - name: "Cross Browser Tests"
    browser: "chrome"
    browserVersion: "latest"
    platformName: "Windows 10"

  - name: "Safari Tests"
    browser: "safari"
    browserVersion: "latest"
    platformName: "macOS 11.00"

cypress:
  configFile: "cypress.config.js"
  version: "12.0.0"

Each suite starts in the cloud on its own browser.

3. Playwright

Playwright runs a single test suite in three engines, Chromium, Firefox and WebKit, and emulates phones as well:

// playwright.config.js - cross-browser testing configuration
const { defineConfig, devices } = require('@playwright/test');

module.exports = defineConfig({
  testDir: './tests',

  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
    {
      name: 'Mobile Chrome',
      use: { ...devices['Pixel 5'] },
    },
    {
      name: 'Mobile Safari',
      use: { ...devices['iPhone 12'] },
    },
  ],
});

// Example cross-browser test
// tests/cross-browser.spec.js
const { test, expect } = require('@playwright/test');

test.describe('Cross-browser compatibility', () => {
  test('CSS Grid layout works correctly', async ({ page }) => {
    await page.goto('/grid-layout');

    // Check if grid is displayed correctly
    const gridContainer = page.locator('.grid-container');
    await expect(gridContainer).toBeVisible();

    // Check number of columns
    const gridItems = page.locator('.grid-item');
    const count = await gridItems.count();
    expect(count).toBeGreaterThan(0);

    // Check responsiveness
    await page.setViewportSize({ width: 768, height: 1024 });
    await expect(gridContainer).toBeVisible();
  });

  test('JavaScript features work across browsers', async ({ page }) => {
    await page.goto('/js-features');

    // Test Fetch API or XHR fallback
    const result = await page.evaluate(async () => {
      const response = await fetchDataCompatible('/api/test');
      return response.status === 'ok';
    });

    expect(result).toBe(true);
  });
});

The projects are named chromium, firefox and webkit, and the npx playwright test --project=webkit command runs only one of them. For a start I recommend Playwright because it runs locally and for free.

4. Local Testing Environments

You can set up your own farm in Docker. Selenium Grid consists of a hub and nodes with browsers:

# Selenium Grid setup for local testing
# docker-compose.yml
version: '3'
services:
  selenium-hub:
    image: selenium/hub:4.0.0
    container_name: selenium-hub
    ports:
      - "4444:4444"

  chrome:
    image: selenium/node-chrome:4.0.0
    shm_size: 2gb
    depends_on:
      - selenium-hub
    environment:
      - HUB_HOST=selenium-hub
      - HUB_PORT=4444

  firefox:
    image: selenium/node-firefox:4.0.0
    shm_size: 2gb
    depends_on:
      - selenium-hub
    environment:
      - HUB_HOST=selenium-hub
      - HUB_PORT=4444

The hub distributes the tests between the Chrome and Firefox nodes. There is no Safari in these images, for it you need a Mac or the cloud.

Compatibility Strategies

1. Progressive Enhancement

Progressive enhancement means that we start from a working base for everyone and add enhancements for the browsers that support them:

/* Basic styles for all browsers */
.card {
  border: 1px solid #ddd;
  padding: 16px;
  margin-bottom: 16px;
  background: white;
}

/* Enhancements for modern browsers */
@supports (display: grid) {
  .card-container {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
    gap: 20px;
  }

  .card {
    margin-bottom: 0;
  }
}

/* Further enhancements */
@supports (backdrop-filter: blur(10px)) {
  .card--glass {
    background: rgba(255, 255, 255, 0.8);
    backdrop-filter: blur(10px);
  }
}

Everyone gets readable cards, browsers with Grid get the grid, and those with backdrop-filter get a glass effect. You apply the same strategy in JavaScript:

// JavaScript Progressive Enhancement
class FeatureDetector {
  static supportsIntersectionObserver() {
    return 'IntersectionObserver' in window;
  }

  static supportsWebP() {
    const canvas = document.createElement('canvas');
    canvas.width = 1;
    canvas.height = 1;
    return canvas.toDataURL('image/webp').indexOf('data:image/webp') === 0;
  }

  static supportsCustomProperties() {
    return CSS.supports && CSS.supports('(--foo: red)');
  }

  static init() {
    // Lazy loading with fallback
    if (this.supportsIntersectionObserver()) {
      this.initLazyLoadingModern();
    } else {
      this.initLazyLoadingFallback();
    }

    // WebP support detection
    if (this.supportsWebP()) {
      document.documentElement.classList.add('webp');
    } else {
      document.documentElement.classList.add('no-webp');
    }
  }

  static initLazyLoadingModern() {
    const images = document.querySelectorAll('img[data-src]');
    const imageObserver = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          const img = entry.target;
          img.src = img.dataset.src;
          img.classList.remove('lazy');
          imageObserver.unobserve(img);
        }
      });
    });

    images.forEach(img => imageObserver.observe(img));
  }

  static initLazyLoadingFallback() {
    const images = document.querySelectorAll('img[data-src]');

    function loadImagesInViewport() {
      images.forEach(img => {
        if (img.dataset.src) {
          const rect = img.getBoundingClientRect();
          if (rect.top < window.innerHeight + 100) {
            img.src = img.dataset.src;
            delete img.dataset.src;
            img.classList.remove('lazy');
          }
        }
      });
    }

    window.addEventListener('scroll', loadImagesInViewport);
    window.addEventListener('resize', loadImagesInViewport);
    loadImagesInViewport();
  }
}

// Initialize on page load
document.addEventListener('DOMContentLoaded', () => {
  FeatureDetector.init();
});

The webp and no-webp classes on the html element let you choose images in CSS. For lazy loading images, the loading="lazy" attribute is enough today.

2. Graceful Degradation

Graceful degradation goes the other way: you design for modern browsers and make sure older ones still give an acceptable result:

/* Modern styles with fallbacks */
.hero-section {
  /* Fallback for old browsers */
  background: linear-gradient(to right, #667eea, #764ba2);

  /* Modern styles */
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
}

/* Fallback for flexbox */
.hero-section {
  text-align: center;
  padding-top: 20vh;
}

@supports (display: flex) {
  .hero-section {
    padding-top: 0;
    text-align: left;
  }
}

/* CSS Variables with fallback */
.theme-primary {
  color: #007bff; /* Fallback */
  color: var(--primary-color, #007bff);
}

A simple CSS rule is at work here: a browser that does not understand the second declaration keeps the first one. That is why a plain color stands before the version with var().

3. Polyfills and Shims

A polyfill is code that provides missing functionality in older browsers that do not support it natively:

// Polyfill for Array.includes (IE)
if (!Array.prototype.includes) {
  Array.prototype.includes = function(searchElement, fromIndex) {
    return this.indexOf(searchElement, fromIndex) !== -1;
  };
}

// Polyfill for Object.assign
if (!Object.assign) {
  Object.assign = function(target, ...sources) {
    if (target == null) {
      throw new TypeError('Cannot convert undefined or null to object');
    }

    const to = Object(target);

    for (let index = 0; index < sources.length; index++) {
      const nextSource = sources[index];
      if (nextSource != null) {
        for (const nextKey in nextSource) {
          if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) {
            to[nextKey] = nextSource[nextKey];
          }
        }
      }
    }

    return to;
  };
}

// Intersection Observer Polyfill
(function() {
  if ('IntersectionObserver' in window) return;

  // Basic polyfill implementation
  window.IntersectionObserver = class {
    constructor(callback, options = {}) {
      this.callback = callback;
      this.options = options;
      this.observedElements = new Set();
      this.checkIntersections = this.checkIntersections.bind(this);

      this.startListening();
    }

    observe(element) {
      this.observedElements.add(element);
      this.checkIntersections();
    }

    unobserve(element) {
      this.observedElements.delete(element);
    }

    disconnect() {
      this.observedElements.clear();
      this.stopListening();
    }

    startListening() {
      window.addEventListener('scroll', this.checkIntersections);
      window.addEventListener('resize', this.checkIntersections);
    }

    stopListening() {
      window.removeEventListener('scroll', this.checkIntersections);
      window.removeEventListener('resize', this.checkIntersections);
    }

    checkIntersections() {
      const entries = [];

      this.observedElements.forEach(element => {
        const rect = element.getBoundingClientRect();
        const isIntersecting = (
          rect.top < window.innerHeight &&
          rect.bottom > 0 &&
          rect.left < window.innerWidth &&
          rect.right > 0
        );

        entries.push({
          target: element,
          isIntersecting,
          boundingClientRect: rect,
          intersectionRatio: isIntersecting ? 1 : 0
        });
      });

      if (entries.length > 0) {
        this.callback(entries);
      }
    }
  };
})();

Every polyfill first checks whether the feature already exists and, if it does, overwrites nothing. Internet Explorer retired in 2022, so many such patches are unnecessary today.

Cross-Browser Test Automation

1. GitHub Actions Workflow

You can run Playwright tests automatically on every push, and the matrix creates a job for each engine:

# .github/workflows/cross-browser-tests.yml
name: Cross-Browser Tests

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        browser: ['chromium', 'firefox', 'webkit']

    steps:
    - uses: actions/checkout@v4

    - name: Setup Node.js
      uses: actions/setup-node@v4
      with:
        node-version: '22'
        cache: 'npm'

    - name: Install dependencies
      run: npm ci

    - name: Install Playwright
      run: npx playwright install --with-deps

    - name: Run tests
      run: npx playwright test --project=${{ matrix.browser }}

    - name: Upload test results
      uses: actions/upload-artifact@v4
      if: failure()
      with:
        name: test-results-${{ matrix.browser }}
        path: test-results/

The names in the matrix match the Playwright projects, so three parallel runs are created.

2. Jest Configuration for Different Environments

Unit tests in Jest run in jsdom, a browser simulation in Node.js without a real rendering engine:

// jest.config.js
module.exports = {
  projects: [
    {
      displayName: 'jsdom',
      testEnvironment: 'jsdom',
      testMatch: ['<rootDir>/src/**/*.test.js'],
      setupFilesAfterEnv: ['<rootDir>/src/setupTests.js']
    },
    {
      displayName: 'node',
      testEnvironment: 'node',
      testMatch: ['<rootDir>/server/**/*.test.js']
    }
  ],

  // Global setup for cross-browser tests
  globalSetup: '<rootDir>/test/global-setup.js',
  globalTeardown: '<rootDir>/test/global-teardown.js'
};

// setupTests.js - polyfills for tests
import 'intersection-observer';
import 'whatwg-fetch';

// Mock for matchMedia (not supported in jsdom)
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: jest.fn().mockImplementation(query => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: jest.fn(),
    removeListener: jest.fn(),
    addEventListener: jest.fn(),
    removeEventListener: jest.fn(),
    dispatchEvent: jest.fn(),
  })),
});

jsdom does not calculate page layout and does not know matchMedia, which is why we mock it, and you check the CSS appearance in real browsers.

Debugging Cross-Browser Issues

1. DevTools Differences

When debugging, a safe logger and a table of the features the browser supports come in handy:

// Universal debugging
const Logger = {
  log: (...args) => {
    if (console && console.log) {
      console.log(...args);
    }
  },

  warn: (...args) => {
    if (console && console.warn) {
      console.warn(...args);
    } else if (console && console.log) {
      console.log('WARNING:', ...args);
    }
  },

  error: (...args) => {
    if (console && console.error) {
      console.error(...args);
    } else if (console && console.log) {
      console.log('ERROR:', ...args);
    }
  },

  table: (data) => {
    if (console && console.table) {
      console.table(data);
    } else {
      Logger.log('TABLE:', JSON.stringify(data, null, 2));
    }
  }
};

// Browser detection for debugging
const BrowserDetector = {
  isIE: () => navigator.userAgent.indexOf('MSIE') !== -1 || navigator.userAgent.indexOf('Trident') !== -1,
  isEdge: () => navigator.userAgent.indexOf('Edge') !== -1,
  isChrome: () => navigator.userAgent.indexOf('Chrome') !== -1 && navigator.userAgent.indexOf('Edge') === -1,
  isFirefox: () => navigator.userAgent.indexOf('Firefox') !== -1,
  isSafari: () => navigator.userAgent.indexOf('Safari') !== -1 && navigator.userAgent.indexOf('Chrome') === -1,

  getInfo: () => ({
    userAgent: navigator.userAgent,
    vendor: navigator.vendor,
    platform: navigator.platform,
    language: navigator.language,
    cookieEnabled: navigator.cookieEnabled,
    onLine: navigator.onLine
  })
};

// Debugging across different browsers
function debugCompatibility() {
  Logger.log('Browser Info:', BrowserDetector.getInfo());

  // Test CSS features
  const cssFeatures = {
    flexbox: CSS.supports('display', 'flex'),
    grid: CSS.supports('display', 'grid'),
    customProperties: CSS.supports('--foo', 'red'),
    backdropFilter: CSS.supports('backdrop-filter', 'blur(10px)'),
    objectFit: CSS.supports('object-fit', 'cover')
  };

  Logger.table(cssFeatures);

  // Test JavaScript features
  const jsFeatures = {
    fetch: typeof fetch !== 'undefined',
    intersectionObserver: 'IntersectionObserver' in window,
    customElements: 'customElements' in window,
    serviceWorker: 'serviceWorker' in navigator,
    webp: (() => {
      const canvas = document.createElement('canvas');
      return canvas.toDataURL('image/webp').indexOf('data:image/webp') === 0;
    })()
  };

  Logger.table(jsFeatures);
}

// Call debugging in development mode
if (process.env.NODE_ENV === 'development') {
  debugCompatibility();
}

CSS.supports() is the most reliable test. Detection by userAgent is fragile: the new Edge introduces itself as "Edg", so isEdge will not recognize it.

2. Remote Debugging

Errors from phones are hard to see, which is why this script captures them and sends them to a server:

// Remote debugging setup for mobile devices
class RemoteDebugger {
  constructor() {
    this.logs = [];
    this.errors = [];
    this.init();
  }

  init() {
    // Capturing JavaScript errors
    window.addEventListener('error', (event) => {
      const error = {
        message: event.message,
        filename: event.filename,
        lineno: event.lineno,
        colno: event.colno,
        stack: event.error ? event.error.stack : null,
        timestamp: new Date().toISOString(),
        userAgent: navigator.userAgent
      };

      this.errors.push(error);
      this.sendToRemoteDebugger('error', error);
    });

    // Capturing rejected promises
    window.addEventListener('unhandledrejection', (event) => {
      const error = {
        reason: event.reason.toString(),
        stack: event.reason.stack,
        timestamp: new Date().toISOString(),
        userAgent: navigator.userAgent
      };

      this.errors.push(error);
      this.sendToRemoteDebugger('rejection', error);
    });

    // Override console methods
    this.interceptConsole();
  }

  interceptConsole() {
    const originalLog = console.log;
    const originalError = console.error;
    const originalWarn = console.warn;

    console.log = (...args) => {
      originalLog.apply(console, args);
      this.sendToRemoteDebugger('log', args);
    };

    console.error = (...args) => {
      originalError.apply(console, args);
      this.sendToRemoteDebugger('error', args);
    };

    console.warn = (...args) => {
      originalWarn.apply(console, args);
      this.sendToRemoteDebugger('warn', args);
    };
  }

  sendToRemoteDebugger(type, data) {
    // Send data to remote debugging service
    if (navigator.sendBeacon) {
      navigator.sendBeacon('/api/debug', JSON.stringify({
        type,
        data,
        timestamp: new Date().toISOString(),
        url: window.location.href,
        userAgent: navigator.userAgent
      }));
    }
  }

  getDeviceInfo() {
    return {
      screen: {
        width: screen.width,
        height: screen.height,
        availWidth: screen.availWidth,
        availHeight: screen.availHeight,
        pixelRatio: window.devicePixelRatio
      },
      viewport: {
        width: window.innerWidth,
        height: window.innerHeight
      },
      browser: navigator.userAgent,
      language: navigator.language,
      platform: navigator.platform,
      touchSupport: 'ontouchstart' in window,
      orientation: screen.orientation ? screen.orientation.angle : 'unknown'
    };
  }
}

// Initialize only in development
if (process.env.NODE_ENV === 'development') {
  new RemoteDebugger();
}

For manual debugging you also have built-in tools: you connect Safari on a Mac to an iPhone, and Chrome to Android via chrome://inspect.

Best Practices

1. Testing on Real Devices

An emulator does not reproduce touch, performance or system fonts, so test on real devices too:

// Device farm setup
const testDevices = [
  { name: 'iPhone 13', browser: 'Safari', os: 'iOS 15' },
  { name: 'Samsung Galaxy S21', browser: 'Chrome', os: 'Android 11' },
  { name: 'iPad Pro', browser: 'Safari', os: 'iPadOS 15' },
  { name: 'Desktop', browser: 'Chrome', os: 'Windows 10' },
  { name: 'Desktop', browser: 'Firefox', os: 'Windows 10' },
  { name: 'Desktop', browser: 'Safari', os: 'macOS Big Sur' }
];

// Automated testing on device farm
async function runDeviceFarmTests() {
  for (const device of testDevices) {
    try {
      await runTestOnDevice(device);
      console.log(`Tests passed on ${device.name} - ${device.browser}`);
    } catch (error) {
      console.error(`Tests failed on ${device.name} - ${device.browser}:`, error);
    }
  }
}

Choose the list of devices based on data about your own users.

2. Performance Considerations

Performance can differ too, and this monitor collects load times and Core Web Vitals metrics:

// Cross-browser performance monitoring
class PerformanceMonitor {
  constructor() {
    this.metrics = {};
    this.init();
  }

  init() {
    if ('performance' in window) {
      this.collectNavigationMetrics();
      this.collectResourceMetrics();
      this.observeLargestContentfulPaint();
      this.observeFirstInputDelay();
    }
  }

  collectNavigationMetrics() {
    if (performance.getEntriesByType) {
      const navigation = performance.getEntriesByType('navigation')[0];
      if (navigation) {
        this.metrics.navigation = {
          domContentLoaded: navigation.domContentLoadedEventEnd - navigation.domContentLoadedEventStart,
          loadComplete: navigation.loadEventEnd - navigation.loadEventStart,
          domInteractive: navigation.domInteractive - navigation.fetchStart,
          firstByte: navigation.responseStart - navigation.requestStart
        };
      }
    }
  }

  collectResourceMetrics() {
    if (performance.getEntriesByType) {
      const resources = performance.getEntriesByType('resource');
      this.metrics.resources = resources.map(resource => ({
        name: resource.name,
        duration: resource.duration,
        size: resource.transferSize,
        type: resource.initiatorType
      }));
    }
  }

  observeLargestContentfulPaint() {
    if ('PerformanceObserver' in window) {
      try {
        const observer = new PerformanceObserver((list) => {
          const entries = list.getEntries();
          const lastEntry = entries[entries.length - 1];
          this.metrics.lcp = lastEntry.startTime;
        });
        observer.observe({ entryTypes: ['largest-contentful-paint'] });
      } catch (e) {
        // Fallback for browsers that don't support LCP
        console.log('LCP not supported');
      }
    }
  }

  observeFirstInputDelay() {
    if ('PerformanceObserver' in window) {
      try {
        const observer = new PerformanceObserver((list) => {
          const entries = list.getEntries();
          entries.forEach(entry => {
            this.metrics.fid = entry.processingStart - entry.startTime;
          });
        });
        observer.observe({ entryTypes: ['first-input'] });
      } catch (e) {
        // Fallback for browsers that don't support FID
        console.log('FID not supported');
      }
    }
  }

  report() {
    return {
      ...this.metrics,
      browser: navigator.userAgent,
      timestamp: new Date().toISOString()
    };
  }
}

// Usage
const monitor = new PerformanceMonitor();

window.addEventListener('load', () => {
  setTimeout(() => {
    const report = monitor.report();
    // Send report to analytics
    console.log('Performance Report:', report);
  }, 1000);
});

Since March 2024, INP (Interaction to Next Paint) has replaced FID in Core Web Vitals.

Summary

Cross-browser testing involves:

  • A systematic process ensuring application compatibility
  • A combination of tools for automated and manual testing
  • A progressive enhancement strategy for better UX
  • Monitoring real users across different platforms
  • A continuous process requiring regular updates

The key to success is early planning for browser support, test automation and performance monitoring across different environments. My advice: before you use a new feature, check it on caniuse.com and test it in all three engines.

Remember: a good temple stands just as firmly in every city, and a good website works in every browser.

Code for this lesson: index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Scroll Snap Gallery</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <h1>Scroll Snap - Gallery of Egyptian Chambers</h1>

    <section>
        <h2>Horizontal gallery (x mandatory)</h2>
        <div class="gallery-horizontal">
            <div class="gallery-item" style="background: linear-gradient(135deg, #d4af37, #8b6914)">
                <h3>The Pharaoh's Chamber</h3>
                <p>Golden treasures and hieroglyphs</p>
            </div>
            <div class="gallery-item" style="background: linear-gradient(135deg, #1e3a5f, #30d5c8)">
                <h3>Throne Room</h3>
                <p>Lapis lazuli and turquoise</p>
            </div>
            <div class="gallery-item" style="background: linear-gradient(135deg, #b87333, #d4a574)">
                <h3>Treasury</h3>
                <p>Copper ritual vessels</p>
            </div>
            <div class="gallery-item" style="background: linear-gradient(135deg, #8b6914, #c9a66b)">
                <h3>Papyrus Library</h3>
                <p>Scrolls of Thoth's wisdom</p>
            </div>
            <div class="gallery-item" style="background: linear-gradient(135deg, #1a1a2e, #3d2914)">
                <h3>Crypt</h3>
                <p>Secrets of antiquity</p>
            </div>
        </div>
    </section>

    <section>
        <h2>Vertical gallery (y mandatory)</h2>
        <div class="gallery-vertical">
            <div class="v-item" style="background: #d4af37">Level 1 - Entrance</div>
            <div class="v-item" style="background: #8b6914">Level 2 - Corridor</div>
            <div class="v-item" style="background: #1e3a5f; color: #d4af37">Level 3 - Chamber</div>
            <div class="v-item" style="background: #1a1a2e; color: #d4af37">Level 4 - Sarcophagus</div>
        </div>
    </section>
</body>
</html>

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. Which rendering engine does the Safari browser use?

  2. 2. What does the progressive enhancement strategy involve?

These are 2 of 3 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Vertical ordering

    Arrange the transform declarations with prefixes so the standard version is last (fallbacks before the standard):

Useful articles