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. |
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
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}") |
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