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..f9d9812ca
--- /dev/null
+++ b/docs/data/api/meter-root.json
@@ -0,0 +1,38 @@
+{
+ "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"] }
+ },
+ "high": { "type": { "name": "number" }, "default": "100" },
+ "low": { "type": { "name": "number" }, "default": "0" },
+ "max": { "type": { "name": "number" }, "default": "100" },
+ "min": { "type": { "name": "number" }, "default": "0" },
+ "optimum": { "type": { "name": "number" }, "default": "50" },
+ "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..90018b06a
--- /dev/null
+++ b/docs/data/components/meter/MeterIntroduction.js
@@ -0,0 +1,35 @@
+'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 (
+
+
+
+
+
+
+
+
+ );
+}
+
+function BoltIcon(props) {
+ return (
+
+ );
+}
diff --git a/docs/data/components/meter/MeterIntroduction.tsx b/docs/data/components/meter/MeterIntroduction.tsx
new file mode 100644
index 000000000..01790be82
--- /dev/null
+++ b/docs/data/components/meter/MeterIntroduction.tsx
@@ -0,0 +1,35 @@
+'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 (
+
+
+
+
+
+
+
+
+ );
+}
+
+function BoltIcon(props: React.SVGProps) {
+ return (
+
+ );
+}
diff --git a/docs/data/components/meter/MeterIntroduction.tsx.preview b/docs/data/components/meter/MeterIntroduction.tsx.preview
new file mode 100644
index 000000000..5d04e0bc0
--- /dev/null
+++ b/docs/data/components/meter/MeterIntroduction.tsx.preview
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ 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..0a16c71b2
--- /dev/null
+++ b/docs/data/components/meter/styles.module.css
@@ -0,0 +1,49 @@
+.demo {
+ --icon-size: 46px;
+ display: flex;
+ flex-flow: row nowrap;
+ justify-content: center;
+ align-items: center;
+ padding: 1rem;
+}
+
+.meter {
+ display: flex;
+ flex-flow: column nowrap;
+ gap: 1rem;
+ color: var(--gray-text-2);
+}
+
+.track {
+ position: relative;
+ width: 38px;
+ height: 16px;
+ border-radius: 5px;
+ border: 3px solid currentColor;
+ padding: 2px;
+ display: flex;
+}
+
+.track:after {
+ content: '';
+ background-color: currentColor;
+ position: absolute;
+ z-index: 1;
+ top: 3px;
+ right: -6px;
+ width: 3px;
+ height: 14px;
+ border-radius: 0 6px 6px 0;
+}
+
+.icon {
+ position: absolute;
+ width: var(--icon-size);
+ height: var(--icon-size);
+ transform: translate(-3px, -14px);
+}
+
+.indicator {
+ background-color: rgb(40, 205, 65);
+ border-radius: 3px;
+}
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..9e4520bd2
--- /dev/null
+++ b/docs/data/translations/api-docs/meter-root/meter-root.json
@@ -0,0 +1,38 @@
+{
+ "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" }
+ },
+ "high": {
+ "description": "Sets the lower boundary of the high end of the numeric range represented by the component. If unspecified, or greater than max
, it will fall back to max
."
+ },
+ "low": {
+ "description": "Sets the upper boundary of the low end of the numeric range represented by the component. If unspecified, or less than min
, it will fall back to min
."
+ },
+ "max": { "description": "The maximum value" },
+ "min": { "description": "The minimum value" },
+ "optimum": {
+ "description": "Indicates the optimal point in the numeric range represented by the component. If unspecified, it will fall back to the midpoint between min
and max
."
+ },
+ "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/docs/src/app/experiments/meter.module.css b/docs/src/app/experiments/meter.module.css
new file mode 100644
index 000000000..ae80c123a
--- /dev/null
+++ b/docs/src/app/experiments/meter.module.css
@@ -0,0 +1,143 @@
+.grid {
+ font-family: system-ui, sans-serif;
+ display: grid;
+ grid-template-columns: 640px auto;
+}
+
+.demo {
+ display: flex;
+ flex-flow: row nowrap;
+ align-items: center;
+}
+
+.meter {
+ display: flex;
+ flex-flow: column nowrap;
+ gap: 1rem;
+ color: var(--gray-text-2);
+ width: 30rem;
+}
+
+.track {
+ position: relative;
+ width: 100%;
+ height: 1.5rem;
+ border-radius: 3px;
+ border: 2px solid currentColor;
+ padding: 2px;
+ display: flex;
+}
+
+.indicator {
+ position: relative;
+ background-color: var(--gray-text-1);
+ border-radius: 3px;
+ transition: background-color 200ms;
+}
+
+.indicator[data-segment='low'] {
+ background-color: red;
+}
+
+.indicator[data-segment='low'][data-optimum] {
+ background-color: green;
+}
+
+.indicator[data-segment='medium'] {
+ background-color: yellow;
+}
+
+.indicator[data-segment='high'][data-optimum] {
+ background-color: green;
+}
+
+.indicator[data-segment='high'] {
+ background-color: red;
+}
+
+.controls {
+ display: flex;
+ flex-flow: column nowrap;
+ gap: 1rem;
+}
+
+.scrubarea {
+ cursor: ns-resize;
+ font-weight: bold;
+ user-select: none;
+}
+
+.label {
+ font-family: monospace;
+ font-weight: 400;
+ font-size: 1rem;
+ cursor: unset;
+ color: var(--gray-text-2);
+}
+
+.scrubareacursor {
+ filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.3));
+}
+
+.group {
+ display: flex;
+ align-items: center;
+ margin-top: 0.25rem;
+ border-radius: 0.25rem;
+ border: 1px solid var(--gray-outline-2);
+ border-color: var(--gray-outline-2);
+ overflow: hidden;
+}
+
+.group:focus-within {
+ outline: 2px solid var(--code-4);
+ border-color: var(--code-6);
+}
+
+.input {
+ position: relative;
+ z-index: 10;
+ align-self: stretch;
+ padding: 0.25rem 0.5rem;
+ font-size: 1rem;
+ line-height: 1.5;
+ border: none;
+ background-color: #fff;
+ color: var(--gray-text-2);
+ box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
+ overflow: hidden;
+ max-width: 150px;
+ font: inherit;
+}
+
+.input:focus {
+ outline: none;
+ z-index: 10;
+}
+
+.button {
+ position: relative;
+ border: none;
+ font-weight: bold;
+ transition-property: background-color, border-color, color;
+ transition-duration: 100ms;
+ padding: 0.5rem 0.75rem;
+ flex: 1;
+ align-self: stretch;
+ font-family: inherit;
+ color: var(--gray-text-2);
+ margin: 0;
+ font-family: math;
+}
+
+.decrement {
+ border-right: 1px solid var(--gray-outline-2);
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+}
+
+.increment {
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+ border-left: 1px solid var(--gray-outline-2);
+}
diff --git a/docs/src/app/experiments/meter.tsx b/docs/src/app/experiments/meter.tsx
new file mode 100644
index 000000000..1d812cf26
--- /dev/null
+++ b/docs/src/app/experiments/meter.tsx
@@ -0,0 +1,119 @@
+'use client';
+import * as React from 'react';
+import clsx from 'clsx';
+import { NumberField } from '@base_ui/react/NumberField';
+import { Meter } from '@base_ui/react/Meter';
+import classes from './meter.module.css';
+
+interface Range {
+ value: number;
+ min: number;
+ max: number;
+ high: number;
+ low: number;
+ optimum: number;
+}
+
+export default function MeterIntroduction() {
+ const [range, setRange] = React.useState({
+ value: 55,
+ min: 0,
+ max: 100,
+ high: 70,
+ low: 20,
+ optimum: 80,
+ });
+
+ function setValue(name: string, value: number | null) {
+ if (value != null) {
+ setRange({
+ ...range,
+ [name]: value,
+ });
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ {['value', 'min', 'max', 'high', 'low', 'optimum'].map((v) => {
+ return (
+
+ );
+ })}
+
+
+ );
+}
+
+function Input(props: {
+ name: string;
+ label: string;
+ value: number;
+ setValue: (key: string, value: number | null) => void;
+}) {
+ const { name, label, value, setValue } = props;
+ const id = React.useId();
+ return (
+ setValue(name, newValue)}
+ allowWheelScrub
+ >
+
+
+
+
+
+
+
+
+ −
+
+
+
+ +
+
+
+
+ );
+}
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..841bc8672
--- /dev/null
+++ b/packages/mui-base/src/Meter/Indicator/MeterIndicator.test.tsx
@@ -0,0 +1,58 @@
+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,
+ percentageValue: 30,
+ segment: 'low',
+ isOptimal: false,
+ ownerState: {
+ direction: 'ltr',
+ max: 100,
+ min: 0,
+ segment: 'low',
+ isOptimal: false,
+ },
+};
+
+describe('', () => {
+ const { render } = createRenderer();
+
+ describeConformance(, () => ({
+ render: (node) => {
+ return render(
+ {node},
+ );
+ },
+ refInstanceof: window.HTMLSpanElement,
+ }));
+
+ describe('internal styles', () => {
+ it('sets positioning styles', 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..f1509da31
--- /dev/null
+++ b/packages/mui-base/src/Meter/Indicator/MeterIndicator.tsx
@@ -0,0 +1,71 @@
+'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 { meterStyleHookMapping } from '../Root/styleHooks';
+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, percentageValue, ownerState } = useMeterRootContext();
+
+ const { getRootProps } = useMeterIndicator({
+ direction,
+ percentageValue,
+ });
+
+ const { renderElement } = useComponentRenderer({
+ propGetter: getRootProps,
+ render: render ?? 'span',
+ ownerState,
+ className,
+ ref: forwardedRef,
+ extraProps: otherProps,
+ customStyleHookMapping: meterStyleHookMapping,
+ });
+
+ return renderElement();
+});
+
+namespace MeterIndicator {
+ export interface OwnerState extends MeterRoot.OwnerState {}
+
+ export interface Props extends BaseUIComponentProps<'span', OwnerState> {}
+}
+
+export { MeterIndicator };
+
+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;
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..6c1c258d0
--- /dev/null
+++ b/packages/mui-base/src/Meter/Indicator/useMeterIndicator.ts
@@ -0,0 +1,53 @@
+'use client';
+import * as React from 'react';
+import { mergeReactProps } from '../../utils/mergeReactProps';
+import { MeterDirection } from '../Root/useMeterRoot';
+
+function useMeterIndicator(
+ parameters: useMeterIndicator.Parameters,
+): useMeterIndicator.ReturnValue {
+ const { direction, percentageValue } = parameters;
+
+ const isRtl = direction === 'rtl';
+
+ const getStyles = React.useCallback(() => {
+ return {
+ [isRtl ? 'right' : 'left']: 0,
+ 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 current value.
+ */
+ percentageValue: number;
+ }
+
+ 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..85dbbcadb
--- /dev/null
+++ b/packages/mui-base/src/Meter/Root/MeterRoot.tsx
@@ -0,0 +1,183 @@
+'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';
+import { meterStyleHookMapping } from './styleHooks';
+
+/**
+ *
+ * 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,
+ low,
+ high,
+ optimum,
+ value,
+ render,
+ className,
+ ...otherProps
+ } = props;
+
+ const { getRootProps, ...meter } = useMeterRoot({
+ 'aria-label': ariaLabel,
+ 'aria-labelledby': ariaLabelledby,
+ 'aria-valuetext': ariaValuetext,
+ direction,
+ getAriaLabel,
+ getAriaValueText,
+ max,
+ min,
+ low,
+ high,
+ optimum,
+ value,
+ });
+
+ const ownerState: MeterRoot.OwnerState = React.useMemo(
+ () => ({
+ direction,
+ max,
+ min,
+ segment: meter.segment,
+ isOptimal: meter.isOptimal,
+ }),
+ [direction, max, min, meter.segment, meter.isOptimal],
+ );
+
+ const contextValue: MeterRootContext = React.useMemo(
+ () => ({
+ ...meter,
+ ownerState,
+ }),
+ [meter, ownerState],
+ );
+
+ const { renderElement } = useComponentRenderer({
+ propGetter: getRootProps,
+ render: render ?? 'div',
+ ownerState,
+ className,
+ ref: forwardedRef,
+ extraProps: otherProps,
+ customStyleHookMapping: meterStyleHookMapping,
+ });
+
+ return (
+ {renderElement()}
+ );
+});
+
+namespace MeterRoot {
+ export type OwnerState = {
+ direction: MeterDirection;
+ max: number;
+ min: number;
+ segment: useMeterRoot.Segment;
+ isOptimal: boolean;
+ };
+
+ export interface Props extends useMeterRoot.Parameters, BaseUIComponentProps<'div', OwnerState> {}
+}
+
+export { MeterRoot };
+
+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,
+ /**
+ * Sets the lower boundary of the high end of the numeric range represented by the component.
+ * If unspecified, or greater than `max`, it will fall back to `max`.
+ * @default 100
+ */
+ high: PropTypes.number,
+ /**
+ * Sets the upper boundary of the low end of the numeric range represented by the component.
+ * If unspecified, or less than `min`, it will fall back to `min`.
+ * @default 0
+ */
+ low: PropTypes.number,
+ /**
+ * The maximum value
+ * @default 100
+ */
+ max: PropTypes.number,
+ /**
+ * The minimum value
+ * @default 0
+ */
+ min: PropTypes.number,
+ /**
+ * Indicates the optimal point in the numeric range represented by the component.
+ * If unspecified, it will fall back to the midpoint between `min` and `max`.
+ * @default 50
+ */
+ optimum: 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;
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/styleHooks.ts b/packages/mui-base/src/Meter/Root/styleHooks.ts
new file mode 100644
index 000000000..935654891
--- /dev/null
+++ b/packages/mui-base/src/Meter/Root/styleHooks.ts
@@ -0,0 +1,17 @@
+import type { CustomStyleHookMapping } from '../../utils/getStyleHookProps';
+import type { MeterRoot } from './MeterRoot';
+
+export const meterStyleHookMapping: CustomStyleHookMapping = {
+ direction: () => null,
+ max: () => null,
+ min: () => null,
+ isOptimal: (value: boolean) => {
+ if (value) {
+ return {
+ 'data-optimum': '',
+ };
+ }
+
+ return null;
+ },
+};
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..018524281
--- /dev/null
+++ b/packages/mui-base/src/Meter/Root/useMeterRoot.ts
@@ -0,0 +1,194 @@
+'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,
+ high: highParam = NaN,
+ low: lowParam = NaN,
+ optimum: optimumParam = NaN,
+ value,
+ } = parameters;
+
+ const percentageValue = valueToPercent(value, min, max);
+
+ const high = highParam ?? max;
+ const low = lowParam ?? min;
+ const optimum = optimumParam ?? (max + min) / 2;
+
+ let segment: useMeterRoot.Segment | undefined;
+
+ if (value <= low) {
+ segment = 'low';
+ } else if (value >= high) {
+ segment = 'high';
+ } else {
+ segment = 'medium';
+ }
+
+ // 'low' is preferred if `min <= optimum <= low`
+ // 'high' is preferred if `high <= optimum <= max`
+ let isOptimal = false;
+
+ if (min <= optimum && optimum <= low) {
+ isOptimal = segment === 'low';
+ } else if (high <= optimum && optimum <= max) {
+ isOptimal = segment === 'high';
+ }
+
+ 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,
+ segment,
+ isOptimal,
+ };
+}
+
+namespace useMeterRoot {
+ export type Segment = 'low' | 'medium' | 'high';
+
+ 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;
+ /**
+ * Sets the lower boundary of the high end of the numeric range represented by the component.
+ * If unspecified, or greater than `max`, it will fall back to `max`.
+ * @default 100
+ */
+ high?: number;
+ /**
+ * Sets the upper boundary of the low end of the numeric range represented by the component.
+ * If unspecified, or less than `min`, it will fall back to `min`.
+ * @default 0
+ */
+ low?: number;
+ /**
+ * The maximum value
+ * @default 100
+ */
+ max?: number;
+ /**
+ * The minimum value
+ * @default 0
+ */
+ min?: number;
+ /**
+ * Indicates the optimal point in the numeric range represented by the component.
+ * If unspecified, it will fall back to the midpoint between `min` and `max`.
+ * @default 50
+ */
+ optimum?: number;
+ /**
+ * The current value.
+ */
+ value: number;
+ }
+
+ export interface ReturnValue {
+ getRootProps: (
+ externalProps?: React.ComponentPropsWithRef<'div'>,
+ ) => React.ComponentPropsWithRef<'div'>;
+ /**
+ * The direction that the meter fills towards
+ */
+ 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;
+ /**
+ * Which segment the value falls in, where the segment boundaries are defined
+ * by the `min`, `max`, `high`, `low`, and `optimum` props.
+ */
+ segment: Segment;
+ /**
+ * Whether the value is in the preferred end - higher or lower values - of
+ * the numeric range represented by the component.
+ */
+ isOptimal: boolean;
+ }
+}
+
+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..2713ca868
--- /dev/null
+++ b/packages/mui-base/src/Meter/Track/MeterTrack.test.tsx
@@ -0,0 +1,34 @@
+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,
+ percentageValue: 30,
+ segment: 'low',
+ isOptimal: false,
+ ownerState: {
+ direction: 'ltr',
+ max: 100,
+ min: 0,
+ segment: 'low',
+ isOptimal: false,
+ },
+};
+
+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..2bc768493
--- /dev/null
+++ b/packages/mui-base/src/Meter/Track/MeterTrack.tsx
@@ -0,0 +1,64 @@
+'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 { meterStyleHookMapping } from '../Root/styleHooks';
+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: meterStyleHookMapping,
+ });
+
+ return renderElement();
+});
+
+namespace MeterTrack {
+ export interface OwnerState extends MeterRoot.OwnerState {}
+
+ export interface Props extends BaseUIComponentProps<'span', OwnerState> {}
+}
+
+export { MeterTrack };
+
+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;
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);
+}