APG Patterns
日本語
日本語

Meter

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

Demo

Open demo only →

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 CaseNative HTMLCustom Implementation
Basic value displayRecommendedNot needed
JavaScript disabled supportWorks nativelyRequires fallback
low/high/optimum thresholdsBuilt-in supportManual implementation
Custom stylingLimited (browser-dependent)Full control
Consistent cross-browser appearanceVaries by browserConsistent
Dynamic value updatesWorks nativelyFull 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 Roles

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

WAI-ARIA Properties

aria-valuenow

Must be between aria-valuemin and aria-valuemax

Values
Number (current value)
Required
Yes

aria-valuemin

Specifies the minimum allowed value for the meter

Values
Number (default: 0)
Required
Yes

aria-valuemax

Specifies the maximum allowed value for the meter

Values
Number (default: 100)
Required
Yes

aria-valuetext

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

Values
String (e.g., 75% complete)
Required
No

aria-label

Provides an invisible label for the meter

Values
String
Required
Conditional (required if no visible label)

aria-labelledby

References an external element as the label

Values
ID reference
Required

Conditional (required if visible label exists)

  • Meters must have an accessible name. This can be provided through a visible label using the label prop, aria-label for an invisible label, or aria-labelledby to reference an external element.
  • 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.

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

PropTypeDefaultDescription
valuenumberrequiredCurrent value of the meter
minnumber0Minimum value
maxnumber100Maximum value
clampbooleantrueWhether to clamp value to min/max range
showValuebooleantrueWhether to display the value text
labelstring-Visible label (also used as aria-label)
valueTextstring-Human-readable value for aria-valuetext
formatstring-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.

Custom Events

EventDetailDescription
valuechange{ value: number }Fired when value is updated via <code>updateValue()</code>

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. The Meter component uses a two-layer testing strategy.

Testing Strategy

Unit Tests (Testing Library)

Verify the component's rendered output using framework-specific testing libraries. These tests ensure correct HTML structure and ARIA attributes.

  • ARIA attributes (role="meter", aria-valuenow, aria-valuemin, aria-valuemax)
  • Value clamping behavior
  • Accessible name handling
  • Accessibility via jest-axe

E2E Tests (Playwright)

Verify component behavior in a real browser environment across all frameworks. These tests cover value display and cross-framework consistency.

  • ARIA structure in live browser
  • Non-interactive behavior verification
  • Value display correctness
  • axe-core accessibility scanning
  • Cross-framework consistency checks

Test Categories

High Priority: ARIA Attributes (Unit + E2E)

TestDescription
role="meter"Element has the meter role
aria-valuenowCurrent value is correctly set
aria-valueminMinimum value is set (default: 0)
aria-valuemaxMaximum value is set (default: 100)
aria-valuetextHuman-readable text is set when provided

High Priority: Accessible Name (Unit + E2E)

TestDescription
aria-labelAccessible name via aria-label attribute
aria-labelledbyAccessible name via external element reference
visible labelVisible label provides accessible name

High Priority: Value Clamping (Unit)

TestDescription
clamp above maxValues above max are clamped to max
clamp below minValues below min are clamped to min
no clampClamping can be disabled with clamp=false

Medium Priority: Accessibility (Unit + E2E)

TestDescription
axe violationsNo accessibility violations detected by axe-core
focus behaviorNot focusable by default

Medium Priority: Edge Cases (Unit + E2E)

TestDescription
decimal valuesHandles decimal values correctly
negative rangeHandles negative min/max ranges
large valuesHandles large numeric values

Low Priority: HTML Attribute Inheritance (Unit)

TestDescription
classNameCustom class is applied to container
idID attribute is set correctly
data-*Data attributes are passed through

Low Priority: Cross-framework Consistency (E2E)

TestDescription
Same meter countReact, Vue, Svelte, Astro all render same number of meters
Consistent ARIA attributesAll frameworks have consistent ARIA structure

Testing Tools

See the Testing Strategy guide for details.

Meter.test.astro.ts
/**
 * Meter Astro Component Tests using Container API
 *
 * These tests verify the initial server-rendered HTML of Meter.astro.
 * Web Component behavior (the updateValue method and the valuechange event)
 * is covered by E2E tests in e2e/meter.spec.ts.
 *
 * @see https://docs.astro.build/en/reference/container-reference/
 */
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { describe, it, expect, beforeEach } from 'vitest';
import { JSDOM } from 'jsdom';
import Meter from './Meter.astro';

describe('Meter (Astro Container API)', () => {
  let container: AstroContainer;

  beforeEach(async () => {
    container = await AstroContainer.create();
  });

  async function renderMeter(props: Record<string, unknown>): Promise<Document> {
    const html = await container.renderToString(Meter, { props });
    return new JSDOM(html).window.document;
  }

  // 🔴 High Priority: HTML Structure
  describe('HTML Structure', () => {
    it('renders apg-meter custom element wrapper', async () => {
      const doc = await renderMeter({ value: 50, 'aria-label': 'Progress' });
      expect(doc.querySelector('apg-meter')).not.toBeNull();
    });

    it('stores min, max and clamp as data attributes for the Web Component', async () => {
      const doc = await renderMeter({
        value: 50,
        min: 10,
        max: 200,
        clamp: false,
        'aria-label': 'Progress',
      });
      const wrapper = doc.querySelector('apg-meter');
      expect(wrapper?.getAttribute('data-min')).toBe('10');
      expect(wrapper?.getAttribute('data-max')).toBe('200');
      expect(wrapper?.getAttribute('data-clamp')).toBe('false');
    });

    it('stores format as a data attribute for the Web Component', async () => {
      const doc = await renderMeter({ value: 50, format: '{value}%', 'aria-label': 'Progress' });
      expect(doc.querySelector('apg-meter')?.getAttribute('data-format')).toBe('{value}%');
    });
  });

  // 🔴 High Priority: ARIA Attributes
  describe('ARIA Attributes', () => {
    it('has role="meter"', async () => {
      const doc = await renderMeter({ value: 50, 'aria-label': 'Progress' });
      expect(doc.querySelector('[role="meter"]')).not.toBeNull();
    });

    it('has aria-valuenow set to current value', async () => {
      const doc = await renderMeter({ value: 75, 'aria-label': 'Progress' });
      const meter = doc.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-valuenow')).toBe('75');
    });

    it('has aria-valuemin="0" when min is not provided', async () => {
      const doc = await renderMeter({ value: 50, 'aria-label': 'Progress' });
      const meter = doc.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-valuemin')).toBe('0');
    });

    it('has aria-valuemin set to min', async () => {
      const doc = await renderMeter({ value: 50, min: 10, 'aria-label': 'Progress' });
      const meter = doc.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-valuemin')).toBe('10');
    });

    it('has aria-valuemax="100" when max is not provided', async () => {
      const doc = await renderMeter({ value: 50, 'aria-label': 'Progress' });
      const meter = doc.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-valuemax')).toBe('100');
    });

    it('has aria-valuemax set to max', async () => {
      const doc = await renderMeter({ value: 50, max: 200, 'aria-label': 'Progress' });
      const meter = doc.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-valuemax')).toBe('200');
    });

    it('has aria-valuetext when valueText is provided', async () => {
      const doc = await renderMeter({
        value: 75,
        valueText: '75 percent complete',
        'aria-label': 'Progress',
      });
      const meter = doc.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-valuetext')).toBe('75 percent complete');
    });

    it('has aria-valuetext built from format with value, min and max', async () => {
      const doc = await renderMeter({
        value: 3.5,
        min: 0,
        max: 5,
        format: '{value} / {max}',
        'aria-label': 'Rating',
      });
      const meter = doc.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-valuetext')).toBe('3.5 / 5');
    });

    it('prefers valueText over format for aria-valuetext', async () => {
      const doc = await renderMeter({
        value: 75,
        valueText: 'Three quarters',
        format: '{value}%',
        'aria-label': 'Progress',
      });
      const meter = doc.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-valuetext')).toBe('Three quarters');
    });

    it('does not have aria-valuetext when neither valueText nor format is provided', async () => {
      const doc = await renderMeter({ value: 50, 'aria-label': 'Progress' });
      const meter = doc.querySelector('[role="meter"]');
      expect(meter?.hasAttribute('aria-valuetext')).toBe(false);
    });
  });

  // 🔴 High Priority: Accessible Name
  describe('Accessible Name', () => {
    it('has accessible name via aria-label', async () => {
      const doc = await renderMeter({ value: 50, 'aria-label': 'CPU Usage' });
      const meter = doc.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-label')).toBe('CPU Usage');
    });

    it('has accessible name via aria-labelledby', async () => {
      const doc = await renderMeter({ value: 50, 'aria-labelledby': 'meter-label' });
      const meter = doc.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-labelledby')).toBe('meter-label');
    });

    it('uses the visible label as aria-label', async () => {
      const doc = await renderMeter({ value: 50, label: 'Storage Used' });
      const meter = doc.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-label')).toBe('Storage Used');
    });

    it('has aria-describedby when provided', async () => {
      const doc = await renderMeter({
        value: 50,
        'aria-label': 'Progress',
        'aria-describedby': 'meter-help',
      });
      const meter = doc.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-describedby')).toBe('meter-help');
    });
  });

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

    it('clamps value below min to min', async () => {
      const doc = await renderMeter({ value: -50, min: 0, max: 100, 'aria-label': 'Progress' });
      const meter = doc.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-valuenow')).toBe('0');
    });

    it('does not clamp when clamp is false', async () => {
      const doc = await renderMeter({
        value: 150,
        min: 0,
        max: 100,
        clamp: false,
        'aria-label': 'Progress',
      });
      const meter = doc.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-valuenow')).toBe('150');
    });
  });

  // 🔴 High Priority: Focus Behavior
  describe('Focus Behavior', () => {
    it('is not focusable (no tabindex)', async () => {
      const doc = await renderMeter({ value: 50, 'aria-label': 'Progress' });
      const meter = doc.querySelector('[role="meter"]');
      expect(meter?.hasAttribute('tabindex')).toBe(false);
    });
  });

  // 🟡 Medium Priority: Edge Cases
  describe('Edge Cases', () => {
    it('renders decimal values as-is', async () => {
      const doc = await renderMeter({ value: 33.33, 'aria-label': 'Progress' });
      const meter = doc.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-valuenow')).toBe('33.33');
    });

    it('renders a negative min/max range', async () => {
      const doc = await renderMeter({ value: 0, min: -50, max: 50, 'aria-label': 'Temperature' });
      const meter = doc.querySelector('[role="meter"]');
      expect(meter?.getAttribute('aria-valuenow')).toBe('0');
      expect(meter?.getAttribute('aria-valuemin')).toBe('-50');
      expect(meter?.getAttribute('aria-valuemax')).toBe('50');
    });

    it('renders 0% fill width when min equals max', async () => {
      const doc = await renderMeter({ value: 10, min: 10, max: 10, 'aria-label': 'Progress' });
      const fill = doc.querySelector<HTMLElement>('.apg-meter-fill');
      expect(fill?.style.width).toBe('0%');
    });
  });

  // 🟡 Medium Priority: Visual Display
  describe('Visual Display', () => {
    it('shows the value when showValue is not specified', async () => {
      const doc = await renderMeter({ value: 75, 'aria-label': 'Progress' });
      expect(doc.querySelector('.apg-meter-value')?.textContent?.trim()).toBe('75');
    });

    it('shows the formatted value when format is provided', async () => {
      const doc = await renderMeter({ value: 75, format: '{value}%', 'aria-label': 'Progress' });
      expect(doc.querySelector('.apg-meter-value')?.textContent?.trim()).toBe('75%');
    });

    it('shows valueText instead of the raw value when valueText is provided', async () => {
      const doc = await renderMeter({
        value: -10,
        valueText: '-10°C',
        'aria-label': 'Temperature',
      });
      expect(doc.querySelector('.apg-meter-value')?.textContent?.trim()).toBe('-10°C');
    });

    it('does not render the value display when showValue is false', async () => {
      const doc = await renderMeter({ value: 75, showValue: false, 'aria-label': 'Progress' });
      expect(doc.querySelector('.apg-meter-value')).toBeNull();
    });

    it('displays the visible label when label is provided', async () => {
      const doc = await renderMeter({ value: 50, label: 'CPU Usage' });
      expect(doc.querySelector('.apg-meter-label')?.textContent?.trim()).toBe('CPU Usage');
    });

    it('hides the visual label, track and value from assistive technology', async () => {
      const doc = await renderMeter({ value: 50, label: 'CPU Usage' });
      expect(doc.querySelector('.apg-meter-label')?.getAttribute('aria-hidden')).toBe('true');
      expect(doc.querySelector('.apg-meter-track')?.getAttribute('aria-hidden')).toBe('true');
      expect(doc.querySelector('.apg-meter-value')?.getAttribute('aria-hidden')).toBe('true');
    });

    it('has fill width based on value', async () => {
      const doc = await renderMeter({ value: 75, min: 0, max: 100, 'aria-label': 'Progress' });
      const fill = doc.querySelector<HTMLElement>('.apg-meter-fill');
      expect(fill?.style.width).toBe('75%');
    });

    it('has fill width relative to a non-zero min', async () => {
      const doc = await renderMeter({ value: -10, min: -50, max: 50, 'aria-label': 'Temperature' });
      const fill = doc.querySelector<HTMLElement>('.apg-meter-fill');
      expect(fill?.style.width).toBe('40%');
    });
  });

  // 🟢 Low Priority: HTML Attribute Inheritance
  describe('HTML Attributes', () => {
    it('sets the id attribute on the meter', async () => {
      const doc = await renderMeter({ value: 50, id: 'my-meter', 'aria-label': 'Progress' });
      expect(doc.querySelector('[role="meter"]')?.id).toBe('my-meter');
    });

    it('applies a custom class to the meter', async () => {
      const doc = await renderMeter({ value: 50, class: 'custom-class', 'aria-label': 'Progress' });
      const meter = doc.querySelector('[role="meter"]');
      expect(meter?.classList.contains('apg-meter')).toBe(true);
      expect(meter?.classList.contains('custom-class')).toBe(true);
    });
  });
});

Resources