diff --git a/docs/data/api/meter-indicator.json b/docs/data/api/meter-indicator.json
new file mode 100644
index 000000000..c5145f32d
--- /dev/null
+++ b/docs/data/api/meter-indicator.json
@@ -0,0 +1,19 @@
+{
+ "props": {
+ "className": { "type": { "name": "union", "description": "func
| string" } },
+ "render": { "type": { "name": "union", "description": "element
| func" } }
+ },
+ "name": "MeterIndicator",
+ "imports": [
+ "import { Meter } from '@base_ui/react/Meter';\nconst MeterIndicator = Meter.Indicator;"
+ ],
+ "classes": [],
+ "spread": true,
+ "themeDefaultProps": true,
+ "muiName": "MeterIndicator",
+ "forwardsRefTo": "HTMLSpanElement",
+ "filename": "/packages/mui-base/src/Meter/Indicator/MeterIndicator.tsx",
+ "inheritance": null,
+ "demos": "
",
+ "cssComponent": false
+}
diff --git a/docs/data/api/meter-root.json b/docs/data/api/meter-root.json
new file mode 100644
index 000000000..83b18ea87
--- /dev/null
+++ b/docs/data/api/meter-root.json
@@ -0,0 +1,35 @@
+{
+ "props": {
+ "value": { "type": { "name": "number" }, "required": true },
+ "aria-label": { "type": { "name": "string" } },
+ "aria-labelledby": { "type": { "name": "string" } },
+ "aria-valuetext": { "type": { "name": "string" } },
+ "className": { "type": { "name": "union", "description": "func
| string" } },
+ "direction": {
+ "type": { "name": "enum", "description": "'ltr'
| 'rtl'" },
+ "default": "'ltr'"
+ },
+ "getAriaLabel": {
+ "type": { "name": "func" },
+ "signature": { "type": "function(value: number) => string", "describedArgs": ["value"] }
+ },
+ "getAriaValueText": {
+ "type": { "name": "func" },
+ "signature": { "type": "function(value: number) => string", "describedArgs": ["value"] }
+ },
+ "max": { "type": { "name": "number" }, "default": "100" },
+ "min": { "type": { "name": "number" }, "default": "0" },
+ "render": { "type": { "name": "union", "description": "element
| func" } }
+ },
+ "name": "MeterRoot",
+ "imports": ["import { Meter } from '@base_ui/react/Meter';\nconst MeterRoot = Meter.Root;"],
+ "classes": [],
+ "spread": true,
+ "themeDefaultProps": true,
+ "muiName": "MeterRoot",
+ "forwardsRefTo": "HTMLDivElement",
+ "filename": "/packages/mui-base/src/Meter/Root/MeterRoot.tsx",
+ "inheritance": null,
+ "demos": "",
+ "cssComponent": false
+}
diff --git a/docs/data/api/meter-track.json b/docs/data/api/meter-track.json
new file mode 100644
index 000000000..beee62e88
--- /dev/null
+++ b/docs/data/api/meter-track.json
@@ -0,0 +1,17 @@
+{
+ "props": {
+ "className": { "type": { "name": "union", "description": "func
| string" } },
+ "render": { "type": { "name": "union", "description": "element
| func" } }
+ },
+ "name": "MeterTrack",
+ "imports": ["import { Meter } from '@base_ui/react/Meter';\nconst MeterTrack = Meter.Track;"],
+ "classes": [],
+ "spread": true,
+ "themeDefaultProps": true,
+ "muiName": "MeterTrack",
+ "forwardsRefTo": "HTMLSpanElement",
+ "filename": "/packages/mui-base/src/Meter/Track/MeterTrack.tsx",
+ "inheritance": null,
+ "demos": "",
+ "cssComponent": false
+}
diff --git a/docs/data/components/meter/MeterIntroduction.js b/docs/data/components/meter/MeterIntroduction.js
new file mode 100644
index 000000000..f68e2cb55
--- /dev/null
+++ b/docs/data/components/meter/MeterIntroduction.js
@@ -0,0 +1,19 @@
+'use client';
+import * as React from 'react';
+import { Meter } from '@base_ui/react/Meter';
+import classes from './styles.module.css';
+
+export default function MeterIntroduction() {
+ return (
+
+
+
+ Battery Health
+
+
+
+
+
+
+ );
+}
diff --git a/docs/data/components/meter/MeterIntroduction.tsx b/docs/data/components/meter/MeterIntroduction.tsx
new file mode 100644
index 000000000..f68e2cb55
--- /dev/null
+++ b/docs/data/components/meter/MeterIntroduction.tsx
@@ -0,0 +1,19 @@
+'use client';
+import * as React from 'react';
+import { Meter } from '@base_ui/react/Meter';
+import classes from './styles.module.css';
+
+export default function MeterIntroduction() {
+ return (
+
+
+
+ Battery Health
+
+
+
+
+
+
+ );
+}
diff --git a/docs/data/components/meter/MeterIntroduction.tsx.preview b/docs/data/components/meter/MeterIntroduction.tsx.preview
new file mode 100644
index 000000000..c7e391dd5
--- /dev/null
+++ b/docs/data/components/meter/MeterIntroduction.tsx.preview
@@ -0,0 +1,8 @@
+
+
+ Battery Health
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/data/components/meter/meter.mdx b/docs/data/components/meter/meter.mdx
new file mode 100644
index 000000000..6d095bc3e
--- /dev/null
+++ b/docs/data/components/meter/meter.mdx
@@ -0,0 +1,38 @@
+---
+productId: base-ui
+title: React Meter components
+description: The Meter component provides a graphical display of a numeric value within a defined range
+components: MeterRoot, MeterTrack, MeterIndicator
+hooks: useMeterRoot, useMeterIndicator
+githubLabel: 'component: meter'
+waiAria: https://www.w3.org/WAI/ARIA/apg/patterns/meter/
+packageName: '@base_ui/react'
+---
+
+# Meter
+
+
+
+
+
+
+
+## Installation
+
+
+
+### Anatomy
+
+Meter
+
+- `` is a top-level component that wraps the other components.
+- `` renders the rail that represents the full range of possible values.
+- `` renders the filled portion of the track.
+
+```tsx
+
+
+
+
+
+```
diff --git a/docs/data/components/meter/styles.module.css b/docs/data/components/meter/styles.module.css
new file mode 100644
index 000000000..5849ed08b
--- /dev/null
+++ b/docs/data/components/meter/styles.module.css
@@ -0,0 +1,33 @@
+.demo {
+ font-family: system-ui, sans-serif;
+ width: 20rem;
+ padding: 1rem;
+}
+
+.meter {
+ display: flex;
+ flex-flow: column nowrap;
+ gap: 1rem;
+}
+
+.track {
+ position: relative;
+ width: 100%;
+ height: 18px;
+ border-radius: 5px;
+ border: 2px solid #222;
+ padding: 2px;
+ background-color: var(--gray-container-1);
+ display: flex;
+ overflow: hidden;
+}
+
+.indicator {
+ background-color: rgb(40, 205, 65);
+ border-radius: 3px;
+}
+
+.label {
+ cursor: unset;
+ font-weight: bold;
+}
diff --git a/docs/data/pages.ts b/docs/data/pages.ts
index e26c851b4..08f84edb8 100644
--- a/docs/data/pages.ts
+++ b/docs/data/pages.ts
@@ -31,6 +31,7 @@ const pages: readonly RouteMetadata[] = [
{ pathname: '/components/react-fieldset', title: 'Fieldset' },
{ pathname: '/components/react-form', title: 'Form' },
{ pathname: '/components/react-menu', title: 'Menu' },
+ { pathname: '/components/react-meter', title: 'Meter' },
{ pathname: '/components/react-number-field', title: 'Number Field' },
{ pathname: '/components/react-popover', title: 'Popover' },
{ pathname: '/components/react-preview-card', title: 'Preview Card' },
diff --git a/docs/data/translations/api-docs/meter-indicator/meter-indicator.json b/docs/data/translations/api-docs/meter-indicator/meter-indicator.json
new file mode 100644
index 000000000..4bc12cf1e
--- /dev/null
+++ b/docs/data/translations/api-docs/meter-indicator/meter-indicator.json
@@ -0,0 +1,10 @@
+{
+ "componentDescription": "",
+ "propDescriptions": {
+ "className": {
+ "description": "Class names applied to the element or a function that returns them based on the component's state."
+ },
+ "render": { "description": "A function to customize rendering of the component." }
+ },
+ "classDescriptions": {}
+}
diff --git a/docs/data/translations/api-docs/meter-root/meter-root.json b/docs/data/translations/api-docs/meter-root/meter-root.json
new file mode 100644
index 000000000..e2615b5c2
--- /dev/null
+++ b/docs/data/translations/api-docs/meter-root/meter-root.json
@@ -0,0 +1,29 @@
+{
+ "componentDescription": "",
+ "propDescriptions": {
+ "aria-label": { "description": "The label for the Indicator component." },
+ "aria-labelledby": {
+ "description": "An id or space-separated list of ids of elements that label the Indicator component."
+ },
+ "aria-valuetext": {
+ "description": "A string value that provides a human-readable text alternative for the current value of the meter indicator."
+ },
+ "className": {
+ "description": "Class names applied to the element or a function that returns them based on the component's state."
+ },
+ "direction": { "description": "The direction that the meter fills towards" },
+ "getAriaLabel": {
+ "description": "Accepts a function which returns a string value that provides an accessible name for the Indicator component",
+ "typeDescriptions": { "value": "The component's value" }
+ },
+ "getAriaValueText": {
+ "description": "Accepts a function which returns a string value that provides a human-readable text alternative for the current value of the meter indicator.",
+ "typeDescriptions": { "value": "The component's value to format" }
+ },
+ "max": { "description": "The maximum value" },
+ "min": { "description": "The minimum value" },
+ "render": { "description": "A function to customize rendering of the component." },
+ "value": { "description": "The current value." }
+ },
+ "classDescriptions": {}
+}
diff --git a/docs/data/translations/api-docs/meter-track/meter-track.json b/docs/data/translations/api-docs/meter-track/meter-track.json
new file mode 100644
index 000000000..4bc12cf1e
--- /dev/null
+++ b/docs/data/translations/api-docs/meter-track/meter-track.json
@@ -0,0 +1,10 @@
+{
+ "componentDescription": "",
+ "propDescriptions": {
+ "className": {
+ "description": "Class names applied to the element or a function that returns them based on the component's state."
+ },
+ "render": { "description": "A function to customize rendering of the component." }
+ },
+ "classDescriptions": {}
+}
diff --git a/packages/mui-base/src/Meter/Indicator/MeterIndicator.test.tsx b/packages/mui-base/src/Meter/Indicator/MeterIndicator.test.tsx
new file mode 100644
index 000000000..391934cae
--- /dev/null
+++ b/packages/mui-base/src/Meter/Indicator/MeterIndicator.test.tsx
@@ -0,0 +1,53 @@
+import * as React from 'react';
+import { expect } from 'chai';
+import { Meter } from '@base_ui/react/Meter';
+import { createRenderer, describeConformance } from '#test-utils';
+import { MeterRootContext } from '../Root/MeterRootContext';
+
+const contextValue: MeterRootContext = {
+ direction: 'ltr',
+ max: 100,
+ min: 0,
+ value: 30,
+ ownerState: {
+ direction: 'ltr',
+ max: 100,
+ min: 0,
+ },
+};
+
+describe('', () => {
+ const { render } = createRenderer();
+
+ describeConformance(, () => ({
+ render: (node) => {
+ return render(
+ {node},
+ );
+ },
+ refInstanceof: window.HTMLSpanElement,
+ }));
+
+ describe('internal styles', () => {
+ it('determinate', async function test() {
+ if (/jsdom/.test(window.navigator.userAgent)) {
+ this.skip();
+ }
+
+ const { getByTestId } = await render(
+
+
+
+
+ ,
+ );
+
+ const indicator = getByTestId('indicator');
+
+ expect(indicator).toHaveComputedStyle({
+ left: '0px',
+ width: '33%',
+ });
+ });
+ });
+});
diff --git a/packages/mui-base/src/Meter/Indicator/MeterIndicator.tsx b/packages/mui-base/src/Meter/Indicator/MeterIndicator.tsx
new file mode 100644
index 000000000..9cc09c53e
--- /dev/null
+++ b/packages/mui-base/src/Meter/Indicator/MeterIndicator.tsx
@@ -0,0 +1,76 @@
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import { useComponentRenderer } from '../../utils/useComponentRenderer';
+import { useMeterIndicator } from './useMeterIndicator';
+import { MeterRoot } from '../Root/MeterRoot';
+import { useMeterRootContext } from '../Root/MeterRootContext';
+import { BaseUIComponentProps } from '../../utils/types';
+/**
+ *
+ * Demos:
+ *
+ * - [Meter](https://base-ui.netlify.app/components/react-meter/)
+ *
+ * API:
+ *
+ * - [MeterIndicator API](https://base-ui.netlify.app/components/react-meter/#api-reference-MeterIndicator)
+ */
+const MeterIndicator = React.forwardRef(function MeterIndicator(
+ props: MeterIndicator.Props,
+ forwardedRef: React.ForwardedRef,
+) {
+ const { render, className, ...otherProps } = props;
+
+ const { direction, max, min, value, ownerState } = useMeterRootContext();
+
+ const { getRootProps } = useMeterIndicator({
+ direction,
+ max,
+ min,
+ value,
+ });
+
+ const { renderElement } = useComponentRenderer({
+ propGetter: getRootProps,
+ render: render ?? 'span',
+ ownerState,
+ className,
+ ref: forwardedRef,
+ extraProps: otherProps,
+ customStyleHookMapping: {
+ direction: () => null,
+ max: () => null,
+ min: () => null,
+ },
+ });
+
+ return renderElement();
+});
+
+MeterIndicator.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * @ignore
+ */
+ children: PropTypes.node,
+ /**
+ * Class names applied to the element or a function that returns them based on the component's state.
+ */
+ className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
+ /**
+ * A function to customize rendering of the component.
+ */
+ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
+} as any;
+
+namespace MeterIndicator {
+ export interface OwnerState extends MeterRoot.OwnerState {}
+
+ export interface Props extends BaseUIComponentProps<'span', OwnerState> {}
+}
+
+export { MeterIndicator };
diff --git a/packages/mui-base/src/Meter/Indicator/useMeterIndicator.ts b/packages/mui-base/src/Meter/Indicator/useMeterIndicator.ts
new file mode 100644
index 000000000..489de110c
--- /dev/null
+++ b/packages/mui-base/src/Meter/Indicator/useMeterIndicator.ts
@@ -0,0 +1,72 @@
+'use client';
+import * as React from 'react';
+import { mergeReactProps } from '../../utils/mergeReactProps';
+import { valueToPercent } from '../../utils/valueToPercent';
+import { MeterDirection } from '../Root/useMeterRoot';
+
+function useMeterIndicator(
+ parameters: useMeterIndicator.Parameters,
+): useMeterIndicator.ReturnValue {
+ const { direction, max = 100, min = 0, value } = parameters;
+
+ const isRtl = direction === 'rtl';
+
+ const percentageValue =
+ Number.isFinite(value) && value !== null ? valueToPercent(value, min, max) : null;
+
+ const getStyles = React.useCallback(() => {
+ if (!percentageValue) {
+ return {};
+ }
+
+ return {
+ [isRtl ? 'right' : 'left']: 0,
+ height: 'inherit',
+ width: `${percentageValue}%`,
+ };
+ }, [isRtl, percentageValue]);
+
+ const getRootProps: useMeterIndicator.ReturnValue['getRootProps'] = React.useCallback(
+ (externalProps = {}) =>
+ mergeReactProps<'span'>(externalProps, {
+ style: getStyles(),
+ }),
+ [getStyles],
+ );
+
+ return {
+ getRootProps,
+ };
+}
+
+namespace useMeterIndicator {
+ export interface Parameters {
+ /**
+ * The direction that the meter fills towards
+ * @default 'ltr'
+ */
+ direction?: MeterDirection;
+ /**
+ * The maximum value
+ * @default 100
+ */
+ max?: number;
+ /**
+ * The minimum value
+ * @default 0
+ */
+ min?: number;
+ /**
+ * The current value.
+ */
+ value: number | null;
+ }
+
+ export interface ReturnValue {
+ getRootProps: (
+ externalProps?: React.ComponentPropsWithRef<'span'>,
+ ) => React.ComponentPropsWithRef<'span'>;
+ }
+}
+
+export { useMeterIndicator };
diff --git a/packages/mui-base/src/Meter/Root/MeterRoot.test.tsx b/packages/mui-base/src/Meter/Root/MeterRoot.test.tsx
new file mode 100644
index 000000000..6871edb51
--- /dev/null
+++ b/packages/mui-base/src/Meter/Root/MeterRoot.test.tsx
@@ -0,0 +1,62 @@
+import { expect } from 'chai';
+import * as React from 'react';
+import { Meter } from '@base_ui/react/Meter';
+import { createRenderer, describeConformance } from '#test-utils';
+import type { MeterRoot } from './MeterRoot';
+
+function TestMeter(props: MeterRoot.Props) {
+ return (
+
+
+
+
+
+ );
+}
+
+describe('', () => {
+ const { render } = createRenderer();
+
+ describeConformance(, () => ({
+ render,
+ refInstanceof: window.HTMLDivElement,
+ }));
+
+ it('renders a meter', async () => {
+ const { getByRole } = await render(
+
+
+
+
+ ,
+ );
+
+ expect(getByRole('meter')).to.have.attribute('aria-valuenow', '30');
+ });
+
+ describe('ARIA attributes', () => {
+ it('sets the correct aria attributes', async () => {
+ const { getByRole } = await render(
+
+
+
+
+ ,
+ );
+
+ const meter = getByRole('meter');
+
+ expect(meter).to.have.attribute('aria-valuenow', '30');
+ expect(meter).to.have.attribute('aria-valuemin', '0');
+ expect(meter).to.have.attribute('aria-valuemax', '100');
+ expect(meter).to.have.attribute('aria-valuetext', '30%');
+ });
+
+ it('should update aria-valuenow when value changes', async () => {
+ const { getByRole, setProps } = await render();
+ const meter = getByRole('meter');
+ setProps({ value: 77 });
+ expect(meter).to.have.attribute('aria-valuenow', '77');
+ });
+ });
+});
diff --git a/packages/mui-base/src/Meter/Root/MeterRoot.tsx b/packages/mui-base/src/Meter/Root/MeterRoot.tsx
new file mode 100644
index 000000000..e885e3c13
--- /dev/null
+++ b/packages/mui-base/src/Meter/Root/MeterRoot.tsx
@@ -0,0 +1,157 @@
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import { useComponentRenderer } from '../../utils/useComponentRenderer';
+import { type MeterDirection, useMeterRoot } from './useMeterRoot';
+import { MeterRootContext } from './MeterRootContext';
+import { BaseUIComponentProps } from '../../utils/types';
+/**
+ *
+ * Demos:
+ *
+ * - [Meter](https://base-ui.netlify.app/components/react-meter/)
+ *
+ * API:
+ *
+ * - [MeterRoot API](https://base-ui.netlify.app/components/react-meter/#api-reference-MeterRoot)
+ */
+const MeterRoot = React.forwardRef(function MeterRoot(
+ props: MeterRoot.Props,
+ forwardedRef: React.ForwardedRef,
+) {
+ const {
+ 'aria-label': ariaLabel,
+ 'aria-labelledby': ariaLabelledby,
+ 'aria-valuetext': ariaValuetext,
+ direction = 'ltr',
+ getAriaLabel,
+ getAriaValueText,
+ max = 100,
+ min = 0,
+ value,
+ render,
+ className,
+ ...otherProps
+ } = props;
+
+ const { getRootProps, ...progress } = useMeterRoot({
+ 'aria-label': ariaLabel,
+ 'aria-labelledby': ariaLabelledby,
+ 'aria-valuetext': ariaValuetext,
+ direction,
+ getAriaLabel,
+ getAriaValueText,
+ max,
+ min,
+ value,
+ });
+
+ const ownerState: MeterRoot.OwnerState = React.useMemo(
+ () => ({
+ direction,
+ max,
+ min,
+ }),
+ [direction, max, min],
+ );
+
+ const contextValue: MeterRootContext = React.useMemo(
+ () => ({
+ ...progress,
+ ownerState,
+ }),
+ [progress, ownerState],
+ );
+
+ const { renderElement } = useComponentRenderer({
+ propGetter: getRootProps,
+ render: render ?? 'div',
+ ownerState,
+ className,
+ ref: forwardedRef,
+ extraProps: otherProps,
+ customStyleHookMapping: {
+ direction: () => null,
+ max: () => null,
+ min: () => null,
+ },
+ });
+
+ return (
+ {renderElement()}
+ );
+});
+
+MeterRoot.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * The label for the Indicator component.
+ */
+ 'aria-label': PropTypes.string,
+ /**
+ * An id or space-separated list of ids of elements that label the Indicator component.
+ */
+ 'aria-labelledby': PropTypes.string,
+ /**
+ * A string value that provides a human-readable text alternative for the current value of the meter indicator.
+ */
+ 'aria-valuetext': PropTypes.string,
+ /**
+ * @ignore
+ */
+ children: PropTypes.node,
+ /**
+ * Class names applied to the element or a function that returns them based on the component's state.
+ */
+ className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
+ /**
+ * The direction that the meter fills towards
+ * @default 'ltr'
+ */
+ direction: PropTypes.oneOf(['ltr', 'rtl']),
+ /**
+ * Accepts a function which returns a string value that provides an accessible name for the Indicator component
+ * @param {number} value The component's value
+ * @returns {string}
+ */
+ getAriaLabel: PropTypes.func,
+ /**
+ * Accepts a function which returns a string value that provides a human-readable text alternative for the current value of the meter indicator.
+ * @param {number} value The component's value to format
+ * @returns {string}
+ */
+ getAriaValueText: PropTypes.func,
+ /**
+ * The maximum value
+ * @default 100
+ */
+ max: PropTypes.number,
+ /**
+ * The minimum value
+ * @default 0
+ */
+ min: PropTypes.number,
+ /**
+ * A function to customize rendering of the component.
+ */
+ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
+ /**
+ * The current value.
+ */
+ value: PropTypes.number.isRequired,
+} as any;
+
+namespace MeterRoot {
+ export type OwnerState = {
+ direction: MeterDirection;
+ max: number;
+ min: number;
+ };
+
+ export interface Props extends useMeterRoot.Parameters, BaseUIComponentProps<'div', OwnerState> {}
+}
+
+export { MeterRoot };
diff --git a/packages/mui-base/src/Meter/Root/MeterRootContext.tsx b/packages/mui-base/src/Meter/Root/MeterRootContext.tsx
new file mode 100644
index 000000000..eb37f7574
--- /dev/null
+++ b/packages/mui-base/src/Meter/Root/MeterRootContext.tsx
@@ -0,0 +1,28 @@
+'use client';
+import * as React from 'react';
+import type { MeterRoot } from './MeterRoot';
+import type { useMeterRoot } from './useMeterRoot';
+
+export type MeterRootContext = Omit & {
+ ownerState: MeterRoot.OwnerState;
+};
+
+/**
+ * @ignore - internal component.
+ */
+export const MeterRootContext = React.createContext(undefined);
+
+if (process.env.NODE_ENV !== 'production') {
+ MeterRootContext.displayName = 'MeterRootContext';
+}
+
+export function useMeterRootContext() {
+ const context = React.useContext(MeterRootContext);
+ if (context === undefined) {
+ throw new Error(
+ 'Base UI: MeterRootContext is missing. Meter parts must be placed within .',
+ );
+ }
+
+ return context;
+}
diff --git a/packages/mui-base/src/Meter/Root/useMeterRoot.ts b/packages/mui-base/src/Meter/Root/useMeterRoot.ts
new file mode 100644
index 000000000..8bc0dbdd7
--- /dev/null
+++ b/packages/mui-base/src/Meter/Root/useMeterRoot.ts
@@ -0,0 +1,135 @@
+'use client';
+import * as React from 'react';
+import { mergeReactProps } from '../../utils/mergeReactProps';
+import { valueToPercent } from '../../utils/valueToPercent';
+
+export type MeterDirection = 'ltr' | 'rtl';
+
+function useMeterRoot(parameters: useMeterRoot.Parameters): useMeterRoot.ReturnValue {
+ const {
+ 'aria-label': ariaLabel,
+ 'aria-labelledby': ariaLabelledby,
+ 'aria-valuetext': ariaValuetext,
+ direction = 'ltr',
+ getAriaLabel,
+ getAriaValueText,
+ max = 100,
+ min = 0,
+ value,
+ } = parameters;
+
+ const percentageValue = valueToPercent(value, min, max);
+
+ const getRootProps: useMeterRoot.ReturnValue['getRootProps'] = React.useCallback(
+ (externalProps = {}) =>
+ mergeReactProps<'div'>(externalProps, {
+ 'aria-label': getAriaLabel ? getAriaLabel(value) : ariaLabel,
+ 'aria-labelledby': ariaLabelledby,
+ 'aria-valuemax': max,
+ 'aria-valuemin': min,
+ 'aria-valuenow': percentageValue,
+ 'aria-valuetext': getAriaValueText
+ ? getAriaValueText(value)
+ : ariaValuetext ?? `${percentageValue}%`,
+ dir: direction,
+ role: 'meter',
+ }),
+ [
+ ariaLabel,
+ ariaLabelledby,
+ ariaValuetext,
+ direction,
+ getAriaLabel,
+ getAriaValueText,
+ max,
+ min,
+ value,
+ percentageValue,
+ ],
+ );
+
+ return {
+ getRootProps,
+ direction,
+ max,
+ min,
+ value,
+ percentageValue,
+ };
+}
+
+namespace useMeterRoot {
+ export interface Parameters {
+ /**
+ * The label for the Indicator component.
+ */
+ 'aria-label'?: string;
+ /**
+ * An id or space-separated list of ids of elements that label the Indicator component.
+ */
+ 'aria-labelledby'?: string;
+ /**
+ * A string value that provides a human-readable text alternative for the current value of the meter indicator.
+ */
+ 'aria-valuetext'?: string;
+ /**
+ * The direction that the meter fills towards
+ * @default 'ltr'
+ */
+ direction?: MeterDirection;
+ /**
+ * Accepts a function which returns a string value that provides an accessible name for the Indicator component
+ * @param {number} value The component's value
+ * @returns {string}
+ */
+ getAriaLabel?: (value: number) => string;
+ /**
+ * Accepts a function which returns a string value that provides a human-readable text alternative for the current value of the meter indicator.
+ * @param {number} value The component's value to format
+ * @returns {string}
+ */
+ getAriaValueText?: (value: number) => string;
+ /**
+ * The maximum value
+ * @default 100
+ */
+ max?: number;
+ /**
+ * The minimum value
+ * @default 0
+ */
+ min?: number;
+ /**
+ * The current value.
+ */
+ value: number;
+ }
+
+ export interface ReturnValue {
+ getRootProps: (
+ externalProps?: React.ComponentPropsWithRef<'div'>,
+ ) => React.ComponentPropsWithRef<'div'>;
+ /**
+ * The direction that progress bars fill in
+ */
+ direction: MeterDirection;
+ /**
+ * The maximum value
+ */
+ max: number;
+ /**
+ * The minimum value
+ */
+ min: number;
+ /**
+ * Value of the component
+ */
+ value: number;
+ /**
+ * Value represented as a percentage of the range between `min` and `max`
+ */
+ percentageValue: number;
+ }
+}
+
+export { useMeterRoot };
diff --git a/packages/mui-base/src/Meter/Track/MeterTrack.test.tsx b/packages/mui-base/src/Meter/Track/MeterTrack.test.tsx
new file mode 100644
index 000000000..7b4ae3190
--- /dev/null
+++ b/packages/mui-base/src/Meter/Track/MeterTrack.test.tsx
@@ -0,0 +1,29 @@
+import * as React from 'react';
+import { Meter } from '@base_ui/react/Meter';
+import { createRenderer, describeConformance } from '#test-utils';
+import { MeterRootContext } from '../Root/MeterRootContext';
+
+const contextValue: MeterRootContext = {
+ direction: 'ltr',
+ max: 100,
+ min: 0,
+ value: 30,
+ ownerState: {
+ direction: 'ltr',
+ max: 100,
+ min: 0,
+ },
+};
+
+describe('', () => {
+ const { render } = createRenderer();
+
+ describeConformance(, () => ({
+ render: (node) => {
+ return render(
+ {node},
+ );
+ },
+ refInstanceof: window.HTMLSpanElement,
+ }));
+});
diff --git a/packages/mui-base/src/Meter/Track/MeterTrack.tsx b/packages/mui-base/src/Meter/Track/MeterTrack.tsx
new file mode 100644
index 000000000..9332bc2a4
--- /dev/null
+++ b/packages/mui-base/src/Meter/Track/MeterTrack.tsx
@@ -0,0 +1,67 @@
+'use client';
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import { useComponentRenderer } from '../../utils/useComponentRenderer';
+import { useMeterRootContext } from '../Root/MeterRootContext';
+import { MeterRoot } from '../Root/MeterRoot';
+import { BaseUIComponentProps } from '../../utils/types';
+/**
+ *
+ * Demos:
+ *
+ * - [Meter](https://base-ui.netlify.app/components/react-meter/)
+ *
+ * API:
+ *
+ * - [MeterTrack API](https://base-ui.netlify.app/components/react-meter/#api-reference-MeterTrack)
+ */
+const MeterTrack = React.forwardRef(function MeterTrack(
+ props: MeterTrack.Props,
+ forwardedRef: React.ForwardedRef,
+) {
+ const { render, className, ...otherProps } = props;
+
+ const { ownerState } = useMeterRootContext();
+
+ const { renderElement } = useComponentRenderer({
+ render: render ?? 'span',
+ ownerState,
+ className,
+ ref: forwardedRef,
+ extraProps: otherProps,
+ customStyleHookMapping: {
+ direction: () => null,
+ max: () => null,
+ min: () => null,
+ },
+ });
+
+ return renderElement();
+});
+
+MeterTrack.propTypes /* remove-proptypes */ = {
+ // ┌────────────────────────────── Warning ──────────────────────────────┐
+ // │ These PropTypes are generated from the TypeScript type definitions. │
+ // │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
+ // └─────────────────────────────────────────────────────────────────────┘
+ /**
+ * @ignore
+ */
+ children: PropTypes.node,
+ /**
+ * Class names applied to the element or a function that returns them based on the component's state.
+ */
+ className: PropTypes.oneOfType([PropTypes.func, PropTypes.string]),
+ /**
+ * A function to customize rendering of the component.
+ */
+ render: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
+} as any;
+
+namespace MeterTrack {
+ export interface OwnerState extends MeterRoot.OwnerState {}
+
+ export interface Props extends BaseUIComponentProps<'span', OwnerState> {}
+}
+
+export { MeterTrack };
diff --git a/packages/mui-base/src/Meter/index.parts.ts b/packages/mui-base/src/Meter/index.parts.ts
new file mode 100644
index 000000000..f3115c388
--- /dev/null
+++ b/packages/mui-base/src/Meter/index.parts.ts
@@ -0,0 +1,5 @@
+export { MeterRoot as Root } from './Root/MeterRoot';
+export { MeterTrack as Track } from './Track/MeterTrack';
+export { MeterIndicator as Indicator } from './Indicator/MeterIndicator';
+
+export type { MeterDirection as Direction } from './Root/useMeterRoot';
diff --git a/packages/mui-base/src/Meter/index.ts b/packages/mui-base/src/Meter/index.ts
new file mode 100644
index 000000000..170d463dd
--- /dev/null
+++ b/packages/mui-base/src/Meter/index.ts
@@ -0,0 +1 @@
+export * as Meter from './index.parts';
diff --git a/packages/mui-base/src/Progress/Indicator/useProgressIndicator.ts b/packages/mui-base/src/Progress/Indicator/useProgressIndicator.ts
index 4e7e76e12..db0475786 100644
--- a/packages/mui-base/src/Progress/Indicator/useProgressIndicator.ts
+++ b/packages/mui-base/src/Progress/Indicator/useProgressIndicator.ts
@@ -1,12 +1,9 @@
'use client';
import * as React from 'react';
import { mergeReactProps } from '../../utils/mergeReactProps';
+import { valueToPercent } from '../../utils/valueToPercent';
import { ProgressDirection } from '../Root/useProgressRoot';
-function valueToPercent(value: number, min: number, max: number) {
- return ((value - min) * 100) / (max - min);
-}
-
function useProgressIndicator(
parameters: useProgressIndicator.Parameters,
): useProgressIndicator.ReturnValue {
diff --git a/packages/mui-base/src/Slider/Root/useSliderRoot.ts b/packages/mui-base/src/Slider/Root/useSliderRoot.ts
index 1984aa5ac..0ef2e22e6 100644
--- a/packages/mui-base/src/Slider/Root/useSliderRoot.ts
+++ b/packages/mui-base/src/Slider/Root/useSliderRoot.ts
@@ -8,7 +8,8 @@ import { ownerDocument } from '../../utils/owner';
import { useControlled } from '../../utils/useControlled';
import { useForkRef } from '../../utils/useForkRef';
import { useEnhancedEffect } from '../../utils/useEnhancedEffect';
-import { percentToValue, roundValueToStep, valueToPercent } from '../utils';
+import { valueToPercent } from '../../utils/valueToPercent';
+import { percentToValue, roundValueToStep } from '../utils';
import { useFieldRootContext } from '../../Field/Root/FieldRootContext';
import { useId } from '../../utils/useId';
import { useFieldControlValidation } from '../../Field/Control/useFieldControlValidation';
diff --git a/packages/mui-base/src/Slider/utils.ts b/packages/mui-base/src/Slider/utils.ts
index 95927f20b..ed473cffd 100644
--- a/packages/mui-base/src/Slider/utils.ts
+++ b/packages/mui-base/src/Slider/utils.ts
@@ -19,7 +19,3 @@ export function roundValueToStep(value: number, step: number, min: number) {
const nearest = Math.round((value - min) / step) * step + min;
return Number(nearest.toFixed(getDecimalPrecision(step)));
}
-
-export function valueToPercent(value: number, min: number, max: number) {
- return ((value - min) * 100) / (max - min);
-}
diff --git a/packages/mui-base/src/index.ts b/packages/mui-base/src/index.ts
index 5c2fbaeff..3b3f1bc21 100644
--- a/packages/mui-base/src/index.ts
+++ b/packages/mui-base/src/index.ts
@@ -7,6 +7,7 @@ export * from './Field';
export * from './Fieldset';
export * from './Form';
export * from './Menu';
+export * from './Meter';
export * from './NumberField';
export * from './Popover';
export * from './PreviewCard';
diff --git a/packages/mui-base/src/utils/valueToPercent.ts b/packages/mui-base/src/utils/valueToPercent.ts
new file mode 100644
index 000000000..9886ee07f
--- /dev/null
+++ b/packages/mui-base/src/utils/valueToPercent.ts
@@ -0,0 +1,3 @@
+export function valueToPercent(value: number, min: number, max: number) {
+ return ((value - min) * 100) / (max - min);
+}