APG Patterns
日本語 GitHub
日本語 GitHub

Meter

A graphical display of a numeric value within a defined range.

🤖 AI Implementation Guide

Demo

Native HTML

Use Native HTML First

Before using this custom component, consider using native <meter> elements. They provide built-in semantics, work without JavaScript, and require no ARIA attributes.

<label for="battery">Battery Level</label>
<meter id="battery" value="75" min="0" max="100">75%</meter>

Use custom implementations only when you need custom styling that native elements cannot provide, or when you need programmatic control over the visual appearance.

Use Case Native HTML Custom Implementation
Basic value display Recommended Not needed
JavaScript disabled support Works natively Requires fallback
low/high/optimum thresholds Built-in support Manual implementation
Custom styling Limited (browser-dependent) Full control
Consistent cross-browser appearance Varies by browser Consistent
Dynamic value updates Works natively Full control

The native <meter> element supports low, high, and optimum attributes for automatic color changes based on value thresholds. This functionality requires manual implementation in custom components.

Accessibility Features

WAI-ARIA Role

Role Element Description
meter Container element Identifies the element as a meter displaying a scalar value within a known range.

The meter role is used for graphical displays of numeric values within a defined range. It is not interactive and should not receive focus by default.

WAI-ARIA Properties

aria-valuenow

Indicates the current numeric value of the meter. Required for all meter implementations.

Type Number
Required Yes
Range Must be between aria-valuemin and aria-valuemax

aria-valuemin

Specifies the minimum allowed value for the meter.

Type Number
Required Yes
Default 0

aria-valuemax

Specifies the maximum allowed value for the meter.

Type Number
Required Yes
Default 100

aria-valuetext

Provides a human-readable text alternative for the current value. Use when the numeric value alone doesn't convey sufficient meaning.

Type String
Required No (recommended when value needs context)
Example "75% complete", "3 out of 4 GB used"

Keyboard Support

Not applicable. The meter pattern is a display-only element and is not interactive. It should not receive keyboard focus unless explicitly made focusable for specific use cases.

Accessible Naming

Meters must have an accessible name. This can be provided through:

  • Visible label - Using the label prop to display a visible label
  • aria-label - Provides an invisible label for the meter
  • aria-labelledby - References an external element as the label

Visual Design

This implementation follows WCAG guidelines for accessible visual design:

  • Visual fill bar - Proportionally represents the current value
  • Numeric display - Optional text showing the current value
  • Visible label - Optional label identifying the meter's purpose
  • Forced colors mode - Uses system colors for accessibility in Windows High Contrast Mode

References

Source Code

Meter.astro
---
/**
 * APG Meter Pattern - Astro Implementation
 *
 * A graphical display of a numeric value within a defined range.
 * Uses Web Components for dynamic value updates.
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/meter/
 */

export interface Props {
  /** Current value */
  value: number;
  /** Minimum value (default: 0) */
  min?: number;
  /** Maximum value (default: 100) */
  max?: number;
  /** Clamp value to min/max range (default: true) */
  clamp?: boolean;
  /** Show value text (default: true) */
  showValue?: boolean;
  /** Visible label text */
  label?: string;
  /** Human-readable value text for aria-valuetext */
  valueText?: string;
  /** Format pattern for dynamic value display (e.g., "{value}%", "{value} of {max}") */
  format?: string;
  /** Meter id */
  id?: string;
  /** Additional CSS class */
  class?: string;
  /** Accessible label when no visible label */
  'aria-label'?: string;
  /** Reference to external label element */
  'aria-labelledby'?: string;
  /** Reference to description element */
  'aria-describedby'?: string;
}

const {
  value,
  min = 0,
  max = 100,
  clamp = true,
  showValue = true,
  label,
  valueText,
  format,
  id,
  class: className = '',
  'aria-label': ariaLabel,
  'aria-labelledby': ariaLabelledby,
  'aria-describedby': ariaDescribedby,
} = Astro.props;

// Clamp value to min/max range
const clampNumber = (val: number, minVal: number, maxVal: number, shouldClamp: boolean): number => {
  if (!Number.isFinite(val) || !Number.isFinite(minVal) || !Number.isFinite(maxVal)) {
    return val;
  }
  return shouldClamp ? Math.min(maxVal, Math.max(minVal, val)) : val;
};

const normalizedValue = clampNumber(value, min, max, clamp);

// Calculate percentage for visual display
const percentage =
  max === min ? 0 : Math.max(0, Math.min(100, ((normalizedValue - min) / (max - min)) * 100));

// Format value helper
const formatValueText = (val: number, formatStr?: string): string => {
  if (!formatStr) return String(val);
  return formatStr
    .replace('{value}', String(val))
    .replace('{min}', String(min))
    .replace('{max}', String(max));
};

// Display text (valueText takes priority, then format, then raw value)
const displayText = valueText ?? formatValueText(normalizedValue, format);

// aria-valuetext (valueText or format, not raw value)
const ariaValueText = valueText ?? (format ? formatValueText(normalizedValue, format) : undefined);
---

<apg-meter data-value={value} data-min={min} data-max={max} data-clamp={clamp} data-format={format}>
  <div
    role="meter"
    aria-valuenow={normalizedValue}
    aria-valuemin={min}
    aria-valuemax={max}
    aria-valuetext={ariaValueText}
    aria-label={label || ariaLabel}
    aria-labelledby={ariaLabelledby}
    aria-describedby={ariaDescribedby}
    id={id}
    class={`apg-meter ${className}`.trim()}
  >
    {
      label && (
        <span class="apg-meter-label" aria-hidden="true">
          {label}
        </span>
      )
    }
    <div class="apg-meter-track" aria-hidden="true">
      <div class="apg-meter-fill" style={`width: ${percentage}%`}></div>
    </div>
    {
      showValue && (
        <span class="apg-meter-value" aria-hidden="true">
          {displayText}
        </span>
      )
    }
  </div>
</apg-meter>

<script>
  class ApgMeter extends HTMLElement {
    private meter: HTMLElement | null = null;
    private fill: HTMLElement | null = null;
    private valueDisplay: HTMLElement | null = null;

    connectedCallback() {
      this.meter = this.querySelector('[role="meter"]');
      this.fill = this.querySelector('.apg-meter-fill');
      this.valueDisplay = this.querySelector('.apg-meter-value');
    }

    private get format(): string | undefined {
      return this.dataset.format;
    }

    private formatValue(value: number, min: number, max: number): string {
      const fmt = this.format;
      if (!fmt) return String(value);
      return fmt
        .replace('{value}', String(value))
        .replace('{min}', String(min))
        .replace('{max}', String(max));
    }

    // Method to update value dynamically
    updateValue(newValue: number) {
      if (!this.meter) return;

      const min = Number(this.dataset.min) || 0;
      const max = Number(this.dataset.max) || 100;
      const clamp = this.dataset.clamp !== 'false';

      // Clamp if needed
      let normalizedValue = newValue;
      if (clamp && Number.isFinite(newValue)) {
        normalizedValue = Math.min(max, Math.max(min, newValue));
      }

      // Update ARIA attributes
      this.meter.setAttribute('aria-valuenow', String(normalizedValue));

      // Update aria-valuetext if format is provided
      const formattedValue = this.formatValue(normalizedValue, min, max);
      if (this.format) {
        this.meter.setAttribute('aria-valuetext', formattedValue);
      }

      // Update visual fill
      if (this.fill) {
        const percentage = max === min ? 0 : ((normalizedValue - min) / (max - min)) * 100;
        this.fill.style.width = `${Math.max(0, Math.min(100, percentage))}%`;
      }

      // Update value display
      if (this.valueDisplay) {
        this.valueDisplay.textContent = formattedValue;
      }

      // Dispatch event
      this.dispatchEvent(
        new CustomEvent('valuechange', {
          detail: { value: normalizedValue },
          bubbles: true,
        })
      );
    }
  }

  if (!customElements.get('apg-meter')) {
    customElements.define('apg-meter', ApgMeter);
  }
</script>

Usage

Example
---
import Meter from './Meter.astro';
---

<!-- Basic usage with aria-label -->
<Meter value={75} aria-label="CPU Usage" />

<!-- With visible label -->
<Meter value={75} label="CPU Usage" />

<!-- With format pattern -->
<Meter
  value={75}
  label="Progress"
  format="{value}%"
/>

<!-- Custom range -->
<Meter
  value={3.5}
  min={0}
  max={5}
  label="Rating"
  format="{value} / {max}"
/>

<!-- With valueText for screen readers -->
<Meter
  value={-10}
  min={-50}
  max={50}
  label="Temperature"
  valueText="-10°C"
/>

<!-- Dynamic updates via Web Component API -->
<Meter value={50} id="my-meter" label="Download" format="{value}%" />
<script>
  const meter = document.querySelector('#my-meter').closest('apg-meter');
  meter.updateValue(75);
</script>

API

Prop Type Default Description
value number required Current value of the meter
min number 0 Minimum value
max number 100 Maximum value
clamp boolean true Whether to clamp value to min/max range
showValue boolean true Whether to display the value text
label string - Visible label (also used as aria-label)
valueText string - Human-readable value for aria-valuetext
format string - Format pattern for display and aria-valuetext (e.g., "{value}%", "{value} of {max}")

One of label, aria-label, or aria-labelledby is required for accessibility.

Web Component Methods

Method Description
updateValue(value: number) Dynamically update the meter value

Custom Events

Event Detail
valuechange { value: number } - Fired when value is updated via updateValue()

Testing

Tests verify APG compliance for ARIA attributes, value handling, and accessibility requirements. Since Meter is a display-only element, keyboard interaction tests are not applicable.

Test Categories

High Priority: ARIA Attributes

Test Description
role="meter" Element has the meter role
aria-valuenow Current value is correctly set
aria-valuemin Minimum value is set (default: 0)
aria-valuemax Maximum value is set (default: 100)
aria-valuetext Human-readable text is set when provided

High Priority: Accessible Name

Test Description
aria-label Accessible name via aria-label attribute
aria-labelledby Accessible name via external element reference
visible label Visible label provides accessible name

High Priority: Value Clamping

Test Description
clamp above max Values above max are clamped to max
clamp below min Values below min are clamped to min
no clamp Clamping can be disabled with clamp=false

Medium Priority: Accessibility

Test Description
axe violations No accessibility violations detected by axe-core
focus behavior Not focusable by default

Medium Priority: Edge Cases

Test Description
decimal values Handles decimal values correctly
negative range Handles negative min/max ranges
large values Handles large numeric values

Low Priority: HTML Attribute Inheritance

Test Description
className Custom class is applied to container
id ID attribute is set correctly
data-* Data attributes are passed through

Testing Tools

Meter.test.astro.ts
/**
 * Meter Web Component Tests
 *
 * Unit tests for the Web Component class.
 */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';

describe('Meter (Web Component)', () => {
  let container: HTMLElement;

  // Web Component class extracted for testing
  class TestApgMeter extends HTMLElement {
    private meter: HTMLElement | null = null;
    private fill: HTMLElement | null = null;
    private valueDisplay: HTMLElement | null = null;

    connectedCallback() {
      this.meter = this.querySelector('[role="meter"]');
      this.fill = this.querySelector('.apg-meter-fill');
      this.valueDisplay = this.querySelector('.apg-meter-value');
    }

    updateValue(newValue: number) {
      if (!this.meter) return;

      const min = Number(this.dataset.min) || 0;
      const max = Number(this.dataset.max) || 100;
      const clamp = this.dataset.clamp !== 'false';

      let normalizedValue = newValue;
      if (clamp && Number.isFinite(newValue)) {
        normalizedValue = Math.min(max, Math.max(min, newValue));
      }

      this.meter.setAttribute('aria-valuenow', String(normalizedValue));

      if (this.fill) {
        const percentage = max === min ? 0 : ((normalizedValue - min) / (max - min)) * 100;
        this.fill.style.width = `${Math.max(0, Math.min(100, percentage))}%`;
      }

      if (this.valueDisplay) {
        this.valueDisplay.textContent = String(normalizedValue);
      }

      this.dispatchEvent(
        new CustomEvent('valuechange', {
          detail: { value: normalizedValue },
          bubbles: true,
        })
      );
    }

    // Expose for testing
    get _meter() {
      return this.meter;
    }
    get _fill() {
      return this.fill;
    }
    get _valueDisplay() {
      return this.valueDisplay;
    }
  }

  function createMeterHTML(
    options: {
      value?: number;
      min?: number;
      max?: number;
      clamp?: boolean;
      showValue?: boolean;
      label?: string;
      valueText?: string;
      id?: string;
      ariaLabel?: string;
      ariaLabelledby?: string;
    } = {}
  ) {
    const {
      value = 50,
      min = 0,
      max = 100,
      clamp = true,
      showValue = true,
      label,
      valueText,
      id,
      ariaLabel = 'Progress',
      ariaLabelledby,
    } = options;

    // Calculate normalized value
    let normalizedValue = value;
    if (clamp && Number.isFinite(value)) {
      normalizedValue = Math.min(max, Math.max(min, value));
    }

    const percentage = max === min ? 0 : ((normalizedValue - min) / (max - min)) * 100;

    return `
      <apg-meter
        class="apg-meter"
        data-value="${value}"
        data-min="${min}"
        data-max="${max}"
        data-clamp="${clamp}"
      >
        <div
          role="meter"
          aria-valuenow="${normalizedValue}"
          aria-valuemin="${min}"
          aria-valuemax="${max}"
          ${valueText ? `aria-valuetext="${valueText}"` : ''}
          ${ariaLabelledby ? `aria-labelledby="${ariaLabelledby}"` : `aria-label="${label || ariaLabel}"`}
          ${id ? `id="${id}"` : ''}
          class="apg-meter-container"
        >
          ${label ? `<span class="apg-meter-label" aria-hidden="true">${label}</span>` : ''}
          <div class="apg-meter-track" aria-hidden="true">
            <div class="apg-meter-fill" style="width: ${percentage}%"></div>
          </div>
          ${showValue ? `<span class="apg-meter-value" aria-hidden="true">${normalizedValue}</span>` : ''}
        </div>
      </apg-meter>
    `;
  }

  beforeEach(() => {
    // Register custom element if not already registered
    if (!customElements.get('apg-meter')) {
      customElements.define('apg-meter', TestApgMeter);
    }

    container = document.createElement('div');
    document.body.appendChild(container);
  });

  afterEach(() => {
    container.remove();
  });

  // 🔴 High Priority: ARIA Attributes
  describe('ARIA Attributes', () => {
    it('has role="meter"', () => {
      container.innerHTML = createMeterHTML();
      const meter = container.querySelector('[role="meter"]');
      expect(meter).not.toBeNull();
    });

    it('has aria-valuenow set to current value', () => {
      container.innerHTML = createMeterHTML({ value: 75 });
      const meter = container.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-valuenow')).toBe('75');
    });

    it('has aria-valuemin set', () => {
      container.innerHTML = createMeterHTML({ min: 10 });
      const meter = container.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-valuemin')).toBe('10');
    });

    it('has aria-valuemax set', () => {
      container.innerHTML = createMeterHTML({ max: 200 });
      const meter = container.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-valuemax')).toBe('200');
    });

    it('has aria-valuetext when provided', () => {
      container.innerHTML = createMeterHTML({ valueText: '75 percent complete' });
      const meter = container.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-valuetext')).toBe('75 percent complete');
    });

    it('does not have aria-valuetext when not provided', () => {
      container.innerHTML = createMeterHTML();
      const meter = container.querySelector('[role="meter"]');
      expect(meter?.hasAttribute('aria-valuetext')).toBe(false);
    });
  });

  // 🔴 High Priority: Accessible Name
  describe('Accessible Name', () => {
    it('has accessible name via aria-label', () => {
      container.innerHTML = createMeterHTML({ ariaLabel: 'CPU Usage' });
      const meter = container.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-label')).toBe('CPU Usage');
    });

    it('has accessible name via aria-labelledby', () => {
      container.innerHTML = `
        <span id="meter-label">Battery Level</span>
        ${createMeterHTML({ ariaLabelledby: 'meter-label', ariaLabel: undefined })}
      `;
      const meter = container.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-labelledby')).toBe('meter-label');
    });

    it('has accessible name via visible label', () => {
      container.innerHTML = createMeterHTML({ label: 'Storage Used' });
      const meter = container.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-label')).toBe('Storage Used');
    });
  });

  // 🔴 High Priority: Value Clamping
  describe('Value Clamping', () => {
    it('clamps value above max to max', () => {
      container.innerHTML = createMeterHTML({ value: 150, min: 0, max: 100 });
      const meter = container.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-valuenow')).toBe('100');
    });

    it('clamps value below min to min', () => {
      container.innerHTML = createMeterHTML({ value: -50, min: 0, max: 100 });
      const meter = container.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-valuenow')).toBe('0');
    });

    it('does not clamp when clamp=false', () => {
      container.innerHTML = createMeterHTML({ value: 150, min: 0, max: 100, clamp: false });
      const meter = container.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-valuenow')).toBe('150');
    });
  });

  // 🔴 High Priority: Focus Behavior
  describe('Focus Behavior', () => {
    it('is not focusable by default', () => {
      container.innerHTML = createMeterHTML();
      const meter = container.querySelector('[role="meter"]');
      expect(meter?.hasAttribute('tabindex')).toBe(false);
    });
  });

  // 🟡 Medium Priority: Edge Cases
  describe('Edge Cases', () => {
    it('handles decimal values correctly', () => {
      container.innerHTML = createMeterHTML({ value: 33.33 });
      const meter = container.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-valuenow')).toBe('33.33');
    });

    it('handles negative min/max range', () => {
      container.innerHTML = createMeterHTML({ value: 0, min: -50, max: 50 });
      const meter = container.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-valuenow')).toBe('0');
      expect(meter?.getAttribute('aria-valuemin')).toBe('-50');
      expect(meter?.getAttribute('aria-valuemax')).toBe('50');
    });
  });

  // 🟡 Medium Priority: Visual Display
  describe('Visual Display', () => {
    it('shows value when showValue is true (default)', () => {
      container.innerHTML = createMeterHTML({ value: 75 });
      const valueDisplay = container.querySelector('.apg-meter-value');
      expect(valueDisplay?.textContent).toBe('75');
    });

    it('hides value when showValue is false', () => {
      container.innerHTML = createMeterHTML({ value: 75, showValue: false });
      const valueDisplay = container.querySelector('.apg-meter-value');
      expect(valueDisplay).toBeNull();
    });

    it('displays visible label when label provided', () => {
      container.innerHTML = createMeterHTML({ label: 'CPU Usage' });
      const labelDisplay = container.querySelector('.apg-meter-label');
      expect(labelDisplay?.textContent).toBe('CPU Usage');
    });

    it('has correct fill width based on value', () => {
      container.innerHTML = createMeterHTML({ value: 75, min: 0, max: 100 });
      const fill = container.querySelector('.apg-meter-fill') as HTMLElement;
      expect(fill?.style.width).toBe('75%');
    });
  });

  // 🟡 Medium Priority: Dynamic Updates
  describe('Dynamic Updates', () => {
    it('updates aria-valuenow when updateValue is called', async () => {
      container.innerHTML = createMeterHTML({ value: 50 });
      const component = container.querySelector('apg-meter') as TestApgMeter;

      // Wait for connectedCallback
      await new Promise((resolve) => requestAnimationFrame(resolve));

      component.updateValue(75);

      const meter = container.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-valuenow')).toBe('75');
    });

    it('clamps value on updateValue when clamp=true', async () => {
      container.innerHTML = createMeterHTML({ value: 50, clamp: true });
      const component = container.querySelector('apg-meter') as TestApgMeter;

      await new Promise((resolve) => requestAnimationFrame(resolve));

      component.updateValue(150);

      const meter = container.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-valuenow')).toBe('100');
    });

    it('dispatches valuechange event on update', async () => {
      container.innerHTML = createMeterHTML({ value: 50 });
      const component = container.querySelector('apg-meter') as TestApgMeter;

      await new Promise((resolve) => requestAnimationFrame(resolve));

      const handler = vi.fn();
      component.addEventListener('valuechange', handler);

      component.updateValue(75);

      expect(handler).toHaveBeenCalledTimes(1);
      expect(handler).toHaveBeenCalledWith(
        expect.objectContaining({
          detail: { value: 75 },
        })
      );
    });

    it('updates visual fill on updateValue', async () => {
      container.innerHTML = createMeterHTML({ value: 50 });
      const component = container.querySelector('apg-meter') as TestApgMeter;

      await new Promise((resolve) => requestAnimationFrame(resolve));

      component.updateValue(75);

      const fill = container.querySelector('.apg-meter-fill') as HTMLElement;
      expect(fill?.style.width).toBe('75%');
    });

    it('updates value display on updateValue', async () => {
      container.innerHTML = createMeterHTML({ value: 50 });
      const component = container.querySelector('apg-meter') as TestApgMeter;

      await new Promise((resolve) => requestAnimationFrame(resolve));

      component.updateValue(75);

      const valueDisplay = container.querySelector('.apg-meter-value');
      expect(valueDisplay?.textContent).toBe('75');
    });
  });

  // 🟢 Low Priority: HTML Attribute Inheritance
  describe('HTML Attribute Inheritance', () => {
    it('sets id attribute', () => {
      container.innerHTML = createMeterHTML({ id: 'my-meter' });
      const meter = container.querySelector('[role="meter"]');
      expect(meter?.getAttribute('id')).toBe('my-meter');
    });
  });
});

Resources