",
+ "cssComponent": false
+}
diff --git a/docs/data/components/meter/MeterIntroduction.js b/docs/data/components/meter/MeterIntroduction.js
new file mode 100644
index 0000000000..6ae6f14cb6
--- /dev/null
+++ b/docs/data/components/meter/MeterIntroduction.js
@@ -0,0 +1,29 @@
+'use client';
+import * as React from 'react';
+import { Meter } from '@base-ui-components/react/meter';
+import classes from './styles.module.css';
+
+export default function MeterIntroduction() {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/docs/data/components/meter/MeterIntroduction.tsx b/docs/data/components/meter/MeterIntroduction.tsx
new file mode 100644
index 0000000000..6ae6f14cb6
--- /dev/null
+++ b/docs/data/components/meter/MeterIntroduction.tsx
@@ -0,0 +1,29 @@
+'use client';
+import * as React from 'react';
+import { Meter } from '@base-ui-components/react/meter';
+import classes from './styles.module.css';
+
+export default function MeterIntroduction() {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/docs/data/components/meter/meter.mdx b/docs/data/components/meter/meter.mdx
new file mode 100644
index 0000000000..862cb8bf79
--- /dev/null
+++ b/docs/data/components/meter/meter.mdx
@@ -0,0 +1,107 @@
+---
+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-components/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
+
+
+
+
+
+```
+
+## Value
+
+The `value` prop represents the (percentage) value of the Meter component.
+
+```tsx
+
+
+
+
+
+```
+
+## Min/max and high/low
+
+The `min` and `max` props can be used to establish the lower and upper bound of the range. The default minimum and maximum values are `0` and `100`.
+
+```tsx
+
+
+
+
+
+```
+
+The `high` and `low` props can be used together with `min` and `max` to divide the range into 3 segments: `'low'`, `'medium'`, and `'high'`.
+A `[data-segment='low' | 'medium' | 'high']` attribute is set depending on which segment the value lands on.
+
+```tsx
+
+
+
+
+
+```
+
+## Optimum value
+
+The optimum prop defines whether the low, medium, or high segment of the range is "preferable". For example, for "battery health" higher is better, but for "CPU temperature" lower may be better. A `[data-optimum]` attribute is set when the value is in the "preferable" segment.
+
+```tsx
+
+
+
+
+
+```
+
+## RTL
+
+Place the component inside any HTML element or component with the HTML dir attribute to change the direction that the `Indicator` fills towards for right-to-left languages:
+
+```jsx
+
+
+ {/* Subcomponents */}
+
+
+```
+
+## Overriding default components
+
+Use the `render` prop to override the rendered element for all subcomponents:
+
+```jsx
+} />
+// or
+} />
+```
diff --git a/docs/data/components/meter/styles.module.css b/docs/data/components/meter/styles.module.css
new file mode 100644
index 0000000000..cc81605d35
--- /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: 48px;
+ height: 26px;
+ 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 b0efde5018..51b2adc22e 100644
--- a/docs/data/pages.ts
+++ b/docs/data/pages.ts
@@ -32,6 +32,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 0000000000..4bc12cf1e0
--- /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 0000000000..1d207a7b5e
--- /dev/null
+++ b/docs/data/translations/api-docs/meter-root/meter-root.json
@@ -0,0 +1,37 @@
+{
+ "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."
+ },
+ "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 0000000000..4bc12cf1e0
--- /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/reference/generated/meter-indicator.json b/docs/reference/generated/meter-indicator.json
new file mode 100644
index 0000000000..db3629be94
--- /dev/null
+++ b/docs/reference/generated/meter-indicator.json
@@ -0,0 +1,14 @@
+{
+ "name": "MeterIndicator",
+ "description": "",
+ "props": {
+ "className": {
+ "type": "string | (state) => string",
+ "description": "Class names applied to the element or a function that returns them based on the component's state."
+ },
+ "render": {
+ "type": "React.ReactElement | (props, state) => React.ReactElement",
+ "description": "A function to customize rendering of the component."
+ }
+ }
+}
diff --git a/docs/reference/generated/meter-root.json b/docs/reference/generated/meter-root.json
new file mode 100644
index 0000000000..ff189d3ed1
--- /dev/null
+++ b/docs/reference/generated/meter-root.json
@@ -0,0 +1,64 @@
+{
+ "name": "MeterRoot",
+ "description": "",
+ "props": {
+ "value": {
+ "type": "number",
+ "required": true,
+ "description": "The current value."
+ },
+ "aria-label": {
+ "type": "string",
+ "description": "The label for the Indicator component."
+ },
+ "aria-labelledby": {
+ "type": "string",
+ "description": "An id or space-separated list of ids of elements that label the Indicator component."
+ },
+ "aria-valuetext": {
+ "type": "string",
+ "description": "A string value that provides a human-readable text alternative for the current value of the meter indicator."
+ },
+ "className": {
+ "type": "string | (state) => string",
+ "description": "Class names applied to the element or a function that returns them based on the component's state."
+ },
+ "getAriaLabel": {
+ "type": "function(value: number) => string",
+ "description": "Accepts a function which returns a string value that provides an accessible name for the Indicator component"
+ },
+ "getAriaValueText": {
+ "type": "function(value: number) => string",
+ "description": "Accepts a function which returns a string value that provides a human-readable text alternative for the current value of the meter indicator."
+ },
+ "high": {
+ "type": "number",
+ "default": "100",
+ "description": "Sets the lower boundary of the high end of the numeric range represented by the component.\nIf unspecified, or greater than `max`, it will fall back to `max`."
+ },
+ "low": {
+ "type": "number",
+ "default": "0",
+ "description": "Sets the upper boundary of the low end of the numeric range represented by the component.\nIf unspecified, or less than `min`, it will fall back to `min`."
+ },
+ "max": {
+ "type": "number",
+ "default": "100",
+ "description": "The maximum value"
+ },
+ "min": {
+ "type": "number",
+ "default": "0",
+ "description": "The minimum value"
+ },
+ "optimum": {
+ "type": "number",
+ "default": "50",
+ "description": "Indicates the optimal point in the numeric range represented by the component.\nIf unspecified, it will fall back to the midpoint between `min` and `max`."
+ },
+ "render": {
+ "type": "React.ReactElement | (props, state) => React.ReactElement",
+ "description": "A function to customize rendering of the component."
+ }
+ }
+}
diff --git a/docs/reference/generated/meter-track.json b/docs/reference/generated/meter-track.json
new file mode 100644
index 0000000000..15ee067a25
--- /dev/null
+++ b/docs/reference/generated/meter-track.json
@@ -0,0 +1,14 @@
+{
+ "name": "MeterTrack",
+ "description": "",
+ "props": {
+ "className": {
+ "type": "string | (state) => string",
+ "description": "Class names applied to the element or a function that returns them based on the component's state."
+ },
+ "render": {
+ "type": "React.ReactElement | (props, state) => React.ReactElement",
+ "description": "A function to customize rendering of the component."
+ }
+ }
+}
diff --git a/docs/src/app/experiments/meter.module.css b/docs/src/app/experiments/meter.module.css
new file mode 100644
index 0000000000..6f589d450c
--- /dev/null
+++ b/docs/src/app/experiments/meter.module.css
@@ -0,0 +1,156 @@
+.wrapper {
+ font-family: system-ui, sans-serif;
+ background-color: var(--gray-container-1);
+ display: flex;
+ flex-flow: column nowrap;
+ gap: 3rem;
+ padding: 3rem;
+}
+
+.grid {
+ display: grid;
+ grid-template-columns: 40rem 40rem;
+ width: 100%;
+}
+
+.demo {
+ display: flex;
+ flex-flow: row nowrap;
+ align-items: center;
+ margin-left: 4rem;
+}
+
+.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 {
+ --red: rgb(255, 59, 48);
+ --yellow: rgb(255, 204, 0);
+ --green: rgb(40, 205, 65);
+
+ position: relative;
+ background-color: var(--gray-text-1);
+ border-radius: 3px;
+ transition: background-color 200ms;
+}
+
+.indicator[data-segment='low'] {
+ background-color: var(--red);
+}
+
+.indicator[data-segment='low'][data-optimum] {
+ background-color: var(--green);
+}
+
+.indicator[data-segment='medium'] {
+ background-color: var(--yellow);
+}
+
+.indicator[data-segment='high'][data-optimum] {
+ background-color: var(--green);
+}
+
+.indicator[data-segment='high'] {
+ background-color: var(--red);
+}
+
+.controls {
+ display: flex;
+ flex-flow: column nowrap;
+ gap: 1rem;
+ align-items: flex-start;
+ padding-top: 2rem;
+ padding-bottom: 2rem;
+}
+
+.label {
+ font-family: monospace;
+ font-weight: 400;
+ font-size: 1rem;
+ cursor: unset;
+ color: var(--gray-text-2);
+}
+
+.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-1);
+ margin: 0;
+ font-family: math;
+ background-color: var(--gray-container-2);
+}
+
+.button:hover {
+ background-color: var(--gray-surface-1);
+ color: var(--gray-text-2);
+}
+
+.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 0000000000..7b9038cf26
--- /dev/null
+++ b/docs/src/app/experiments/meter.tsx
@@ -0,0 +1,124 @@
+'use client';
+import * as React from 'react';
+import clsx from 'clsx';
+import { NumberField } from '@base-ui-components/react/number-field';
+import { Meter } from '@base-ui-components/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 (
+