Meter
A graphical display of a numeric value within a defined range.
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 Roles
| Role | Target 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
Must be between aria-valuemin and aria-valuemax
| Type | Number (current value) |
| Required | Yes |
| Range |
Must be between aria-valuemin and aria-valuemax |
aria-valuemin
Specifies the minimum allowed value for the meter
| Type | Number (default: 0) |
| Required | Yes |
| Default | 0 |
aria-valuemax
Specifies the maximum allowed value for the meter
| Type | Number (default: 100) |
| 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 (e.g., "75% complete") |
| Required | No |
| Example | "75% complete", "3 out of 4 GB used" |
aria-label
Provides an invisible label for the meter
| Type | String |
| Required | Conditional (required if no visible label) |
aria-labelledby
References an external element as the label
| Type | ID reference |
| Required | Conditional (required if visible label exists) |
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
labelprop 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
import { clsx } from 'clsx';
// Label: one of these required (exclusive)
type LabelProps =
| { label: string; 'aria-label'?: never; 'aria-labelledby'?: never }
| { label?: never; 'aria-label': string; 'aria-labelledby'?: never }
| { label?: never; 'aria-label'?: never; 'aria-labelledby': string };
// ValueText: exclusive with format
type ValueTextProps =
| { valueText: string; format?: never }
| { valueText?: never; format?: string }
| { valueText?: never; format?: never };
type MeterBaseProps = {
value: number;
min?: number;
max?: number;
clamp?: boolean;
showValue?: boolean;
id?: string;
className?: string;
tabIndex?: number;
'aria-describedby'?: string;
'data-testid'?: string;
};
export type MeterProps = MeterBaseProps & LabelProps & ValueTextProps;
const clampNumber = (value: number, min: number, max: number, shouldClamp: boolean): number => {
if (!Number.isFinite(value) || !Number.isFinite(min) || !Number.isFinite(max)) {
return value;
}
return shouldClamp ? Math.min(max, Math.max(min, value)) : value;
};
const calculatePercentage = (value: number, min: number, max: number): number => {
if (max === min) return 0;
return ((value - min) / (max - min)) * 100;
};
// Format value helper
const formatValueText = (
value: number,
formatStr: string | undefined,
min: number,
max: number
): string => {
if (!formatStr) return String(value);
return formatStr
.replace('{value}', String(value))
.replace('{min}', String(min))
.replace('{max}', String(max));
};
export const Meter: React.FC<MeterProps> = ({
value,
min = 0,
max = 100,
clamp = true,
showValue = true,
label,
valueText,
format,
className,
...rest
}) => {
const normalizedValue = clampNumber(value, min, max, clamp);
const percentage = calculatePercentage(normalizedValue, min, max);
// Determine aria-valuetext
const ariaValueText =
valueText ?? (format ? formatValueText(normalizedValue, format, min, max) : undefined);
// Determine display text (valueText takes priority, then format, then raw value)
const displayText = valueText ? valueText : formatValueText(normalizedValue, format, min, max);
return (
<div
role="meter"
aria-valuenow={normalizedValue}
aria-valuemin={min}
aria-valuemax={max}
aria-valuetext={ariaValueText}
aria-label={label ?? rest['aria-label']}
aria-labelledby={rest['aria-labelledby']}
className={clsx('apg-meter', className)}
id={rest.id}
tabIndex={rest.tabIndex}
aria-describedby={rest['aria-describedby']}
data-testid={rest['data-testid']}
>
{label && (
<span className="apg-meter-label" aria-hidden="true">
{label}
</span>
)}
<div className="apg-meter-track" aria-hidden="true">
<div
className="apg-meter-fill"
style={{ width: `${Math.max(0, Math.min(100, percentage))}%` }}
/>
</div>
{showValue && (
<span className="apg-meter-value" aria-hidden="true">
{displayText}
</span>
)}
</div>
);
}; Usage
import { Meter } from './Meter';
function App() {
return (
<div>
{/* Basic usage with aria-label */}
<Meter value={75} aria-label="CPU Usage" />
{/* With visible label */}
<Meter value={75} label="CPU Usage" />
{/* With valueText for human-readable value */}
<Meter
value={75}
label="Progress"
valueText="75%"
/>
{/* Custom range with valueText */}
<Meter
value={3.5}
min={0}
max={5}
label="Rating"
valueText="3.5 out of 5"
/>
{/* With format pattern */}
<Meter
value={75}
label="Download Progress"
format="{value}%"
/>
</div>
);
} 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.
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)
| 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 (Unit + E2E)
| 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 (Unit)
| 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 (Unit + E2E)
| Test | Description |
|---|---|
axe violations | No accessibility violations detected by axe-core |
focus behavior | Not focusable by default |
Medium Priority: Edge Cases (Unit + E2E)
| 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 (Unit)
| Test | Description |
|---|---|
className | Custom class is applied to container |
id | ID attribute is set correctly |
data-* | Data attributes are passed through |
Low Priority: Cross-framework Consistency (E2E)
| Test | Description |
|---|---|
Same meter count | React, Vue, Svelte, Astro all render same number of meters |
Consistent ARIA attributes | All frameworks have consistent ARIA structure |
Testing Tools
- Vitest (opens in new tab) - Test runner for unit tests
- Testing Library (opens in new tab) - Framework-specific testing utilities (React, Vue, Svelte)
- Playwright (opens in new tab) - Browser automation for E2E tests
- axe-core/playwright (opens in new tab) - Automated accessibility testing in E2E
See testing-strategy.md (opens in new tab) for full documentation.
import { render, screen } from '@testing-library/react';
import { axe } from 'jest-axe';
import { describe, expect, it } from 'vitest';
import { Meter } from './Meter';
describe('Meter', () => {
// 🔴 High Priority: ARIA Attributes
describe('ARIA Attributes', () => {
it('has role="meter"', () => {
render(<Meter value={50} aria-label="Progress" />);
expect(screen.getByRole('meter')).toBeInTheDocument();
});
it('has aria-valuenow set to current value', () => {
render(<Meter value={75} aria-label="Progress" />);
const meter = screen.getByRole('meter');
expect(meter).toHaveAttribute('aria-valuenow', '75');
});
it('has aria-valuemin set (default: 0)', () => {
render(<Meter value={50} aria-label="Progress" />);
const meter = screen.getByRole('meter');
expect(meter).toHaveAttribute('aria-valuemin', '0');
});
it('has aria-valuemax set (default: 100)', () => {
render(<Meter value={50} aria-label="Progress" />);
const meter = screen.getByRole('meter');
expect(meter).toHaveAttribute('aria-valuemax', '100');
});
it('has custom aria-valuemin when provided', () => {
render(<Meter value={50} min={10} aria-label="Progress" />);
const meter = screen.getByRole('meter');
expect(meter).toHaveAttribute('aria-valuemin', '10');
});
it('has custom aria-valuemax when provided', () => {
render(<Meter value={50} max={200} aria-label="Progress" />);
const meter = screen.getByRole('meter');
expect(meter).toHaveAttribute('aria-valuemax', '200');
});
it('has aria-valuetext when valueText provided', () => {
render(<Meter value={75} valueText="75 percent complete" aria-label="Progress" />);
const meter = screen.getByRole('meter');
expect(meter).toHaveAttribute('aria-valuetext', '75 percent complete');
});
it('does not have aria-valuetext when not provided', () => {
render(<Meter value={75} aria-label="Progress" />);
const meter = screen.getByRole('meter');
expect(meter).not.toHaveAttribute('aria-valuetext');
});
it('uses format for aria-valuetext', () => {
render(<Meter value={75} min={0} max={100} format="{value}%" aria-label="Progress" />);
const meter = screen.getByRole('meter');
expect(meter).toHaveAttribute('aria-valuetext', '75%');
});
});
// 🔴 High Priority: Accessible Name
describe('Accessible Name', () => {
it('has accessible name via aria-label', () => {
render(<Meter value={50} aria-label="CPU Usage" />);
expect(screen.getByRole('meter', { name: 'CPU Usage' })).toBeInTheDocument();
});
it('has accessible name via aria-labelledby', () => {
render(
<>
<span id="meter-label">Battery Level</span>
<Meter value={80} aria-labelledby="meter-label" />
</>
);
expect(screen.getByRole('meter', { name: 'Battery Level' })).toBeInTheDocument();
});
it('has accessible name via visible label', () => {
render(<Meter value={50} label="Storage Used" />);
expect(screen.getByRole('meter', { name: 'Storage Used' })).toBeInTheDocument();
});
});
// 🔴 High Priority: Value Clamping
describe('Value Clamping', () => {
it('clamps value above max to max', () => {
render(<Meter value={150} min={0} max={100} aria-label="Progress" />);
const meter = screen.getByRole('meter');
expect(meter).toHaveAttribute('aria-valuenow', '100');
});
it('clamps value below min to min', () => {
render(<Meter value={-50} min={0} max={100} aria-label="Progress" />);
const meter = screen.getByRole('meter');
expect(meter).toHaveAttribute('aria-valuenow', '0');
});
it('does not clamp when clamp=false', () => {
render(<Meter value={150} min={0} max={100} clamp={false} aria-label="Progress" />);
const meter = screen.getByRole('meter');
expect(meter).toHaveAttribute('aria-valuenow', '150');
});
});
// 🔴 High Priority: Focus Behavior
describe('Focus Behavior', () => {
it('is not focusable by default', () => {
render(<Meter value={50} aria-label="Progress" />);
const meter = screen.getByRole('meter');
expect(meter).not.toHaveAttribute('tabindex');
});
it('is focusable when tabIndex is provided', () => {
render(<Meter value={50} aria-label="Progress" tabIndex={0} />);
const meter = screen.getByRole('meter');
expect(meter).toHaveAttribute('tabindex', '0');
});
});
// 🟡 Medium Priority: Accessibility
describe('Accessibility', () => {
it('has no axe violations', async () => {
const { container } = render(<Meter value={50} aria-label="Progress" />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with visible label', async () => {
const { container } = render(<Meter value={50} label="CPU Usage" />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with aria-labelledby', async () => {
const { container } = render(
<>
<span id="label">Battery</span>
<Meter value={80} aria-labelledby="label" />
</>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations with valueText', async () => {
const { container } = render(
<Meter value={75} valueText="75% complete" aria-label="Progress" />
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations at boundary values', async () => {
const { container } = render(<Meter value={0} aria-label="Empty" />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
// 🟡 Medium Priority: Edge Cases
describe('Edge Cases', () => {
it('handles decimal values correctly', () => {
render(<Meter value={33.33} aria-label="Progress" />);
const meter = screen.getByRole('meter');
expect(meter).toHaveAttribute('aria-valuenow', '33.33');
});
it('handles negative min/max range', () => {
render(<Meter value={0} min={-50} max={50} aria-label="Temperature" />);
const meter = screen.getByRole('meter');
expect(meter).toHaveAttribute('aria-valuenow', '0');
expect(meter).toHaveAttribute('aria-valuemin', '-50');
expect(meter).toHaveAttribute('aria-valuemax', '50');
});
it('handles large values', () => {
render(<Meter value={500000} min={0} max={1000000} aria-label="Revenue" />);
const meter = screen.getByRole('meter');
expect(meter).toHaveAttribute('aria-valuenow', '500000');
expect(meter).toHaveAttribute('aria-valuemax', '1000000');
});
it('handles zero range edge case (min equals max)', () => {
render(<Meter value={50} min={50} max={50} aria-label="Static" />);
const meter = screen.getByRole('meter');
expect(meter).toHaveAttribute('aria-valuenow', '50');
});
});
// 🟡 Medium Priority: Visual Display
describe('Visual Display', () => {
it('shows value when showValue is true (default)', () => {
render(<Meter value={75} aria-label="Progress" />);
expect(screen.getByText('75')).toBeInTheDocument();
});
it('hides value when showValue is false', () => {
render(<Meter value={75} aria-label="Progress" showValue={false} />);
expect(screen.queryByText('75')).not.toBeInTheDocument();
});
it('displays formatted value when format provided', () => {
render(<Meter value={75} format="{value}%" aria-label="Progress" />);
expect(screen.getByText('75%')).toBeInTheDocument();
});
it('displays visible label when label provided', () => {
render(<Meter value={50} label="CPU Usage" />);
expect(screen.getByText('CPU Usage')).toBeInTheDocument();
});
});
// 🟢 Low Priority: HTML Attribute Inheritance
describe('HTML Attribute Inheritance', () => {
it('applies className to container', () => {
render(<Meter value={50} aria-label="Progress" className="custom-meter" />);
const meter = screen.getByRole('meter');
expect(meter).toHaveClass('custom-meter');
});
it('sets id attribute', () => {
render(<Meter value={50} aria-label="Progress" id="my-meter" />);
const meter = screen.getByRole('meter');
expect(meter).toHaveAttribute('id', 'my-meter');
});
it('passes through data-* attributes', () => {
render(<Meter value={50} aria-label="Progress" data-testid="custom-meter" />);
expect(screen.getByTestId('custom-meter')).toBeInTheDocument();
});
it('supports aria-describedby', () => {
render(
<>
<Meter value={50} aria-label="Progress" aria-describedby="desc" />
<p id="desc">This shows your progress</p>
</>
);
const meter = screen.getByRole('meter');
expect(meter).toHaveAttribute('aria-describedby', 'desc');
});
});
}); Resources
- WAI-ARIA APG: Meter Pattern (opens in new tab)
- MDN: <meter> element (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist