diff --git a/.changeset/meter-component.md b/.changeset/meter-component.md
new file mode 100644
index 000000000..cd24de274
--- /dev/null
+++ b/.changeset/meter-component.md
@@ -0,0 +1,5 @@
+---
+'@vapor-ui/core': minor
+---
+
+add new `Meter` component
diff --git a/.claude/rules/styling.md b/.claude/rules/styling.md
index 2373faf87..b66b4b9a3 100644
--- a/.claude/rules/styling.md
+++ b/.claude/rules/styling.md
@@ -67,12 +67,19 @@ Use when a component part has no variants — a single fixed className is genera
import { componentStyle } from '~/styles/mixins/layer-style.css';
export const title = componentStyle({
- fontSize: vars.typography.fontSize['200'],
- fontWeight: vars.typography.fontWeight['700'],
+ paddingInline: vars.size.space['150'],
color: vars.color.foreground.normal[200],
});
```
+`componentStyle` also accepts an array — pass mixin classNames alongside the style rule to compose them:
+
+```ts
+import { typography } from '~/styles/mixins/typography.css';
+
+export const title = componentStyle([typography({ style: 'subtitle1' }), { gridColumn: '1' }]);
+```
+
### `interaction()` mixin — interactive states
Include in `base` for any component that responds to hover / active / focus. Implements a `::before` pseudo-element overlay so interaction states work without touching background-color directly.
@@ -86,6 +93,47 @@ base: [
]
```
+### `typography()` mixin — text styles
+
+Never set `fontSize` / `fontWeight` / `lineHeight` / `letterSpacing` by hand, and never spread `typographyVariants`. Call the `typography()` recipe with one of the named styles (`display1`–`display4`, `heading1`–`heading6`, `subtitle1`–`subtitle2`, `body1`–`body4`, `code1`–`code2`) so every component picks the same four tokens together.
+
+```ts
+import { typography } from '~/styles/mixins/typography.css';
+
+// ✅ static part
+export const label = componentStyle([typography({ style: 'subtitle1' }), { gridColumn: '1' }]);
+
+// ✅ inside a recipe — base or a variant value
+export const button = componentRecipe({
+ base: [typography({ style: 'subtitle1' }), { display: 'inline-flex' }],
+ variants: {
+ size: {
+ sm: [typography({ style: 'subtitle2' }), { height: vars.size.space['300'] }],
+ md: [typography({ style: 'subtitle1' }), { height: vars.size.space['400'] }],
+ },
+ },
+});
+```
+
+```ts
+// ❌ raw typography tokens — drifts from the scale
+export const label = componentStyle({
+ fontSize: vars.typography.fontSize['075'],
+ fontWeight: vars.typography.fontWeight[500],
+});
+
+// ❌ spreading the variant map — it exists to feed `variants`, not to style with
+export const label = componentStyle({ ...typographyVariants.subtitle1 });
+```
+
+`typographyVariants` is the raw style map that backs the recipe. Its only job is to be handed to a `variants` group so a component can expose a `typography` prop (see `Text`, `Field`):
+
+```ts
+export const root = componentRecipe({
+ variants: { typography: typographyVariants },
+});
+```
+
## Passing Variants to `recipe` — Two Patterns
### Direct — standalone components or parts that own their variants
diff --git a/apps/storybook/__tests__/screenshots/meter--test-bed-1-chrome-darwin-.png b/apps/storybook/__tests__/screenshots/meter--test-bed-1-chrome-darwin-.png
new file mode 100644
index 000000000..b3db171c2
Binary files /dev/null and b/apps/storybook/__tests__/screenshots/meter--test-bed-1-chrome-darwin-.png differ
diff --git a/apps/storybook/__tests__/screenshots/meter--test-bed-1-edge-darwin-.png b/apps/storybook/__tests__/screenshots/meter--test-bed-1-edge-darwin-.png
new file mode 100644
index 000000000..b3db171c2
Binary files /dev/null and b/apps/storybook/__tests__/screenshots/meter--test-bed-1-edge-darwin-.png differ
diff --git a/apps/storybook/__tests__/screenshots/meter--test-bed-1-firefox-darwin-.png b/apps/storybook/__tests__/screenshots/meter--test-bed-1-firefox-darwin-.png
new file mode 100644
index 000000000..dee0d8381
Binary files /dev/null and b/apps/storybook/__tests__/screenshots/meter--test-bed-1-firefox-darwin-.png differ
diff --git a/apps/storybook/__tests__/screenshots/meter--test-bed-1-safari-darwin-.png b/apps/storybook/__tests__/screenshots/meter--test-bed-1-safari-darwin-.png
new file mode 100644
index 000000000..087f52cea
Binary files /dev/null and b/apps/storybook/__tests__/screenshots/meter--test-bed-1-safari-darwin-.png differ
diff --git a/apps/website/content/docs/components/(components)/meter.mdx b/apps/website/content/docs/components/(components)/meter.mdx
new file mode 100644
index 000000000..900dcdbca
--- /dev/null
+++ b/apps/website/content/docs/components/(components)/meter.mdx
@@ -0,0 +1,156 @@
+---
+title: 'Meter'
+site_name: 'Meter - Vapor Core'
+description: 'Meter는 디스크 사용량이나 점수처럼 알려진 범위 안의 측정값을 표시합니다.'
+---
+
+
+```json doc-gen:file
+{
+ "file": "./src/components/demo/examples/meter/default-meter.tsx",
+ "codeblock": true
+}
+```
+
+
+`Meter.Label`은 보조기술에 전달되는 이름이기도 합니다. `저장 공간`, `CPU 사용률`처럼 **무엇을 재는 값인지**를 문구에 담으세요. 화면에 이름을 두지 않을 때는 `Meter.Label` 대신 `Meter.Root`에 `aria-label`을 주세요 — 둘을 함께 주면 `aria-label`이 무시됩니다.
+
+## Property
+
+---
+
+### Size
+
+`size`로 트랙의 높이를 조절합니다.
+
+
+```json doc-gen:file
+{
+ "file": "./src/components/demo/examples/meter/meter-size.tsx",
+ "codeblock": true
+}
+```
+
+
+### Type
+
+`type`으로 채워진 영역의 색상을 지정합니다. 색만으로는 위험 구간을 알 수 없으므로 레이블에도 함께 적어줍니다.
+
+
+```json doc-gen:file
+{
+ "file": "./src/components/demo/examples/meter/meter-type.tsx",
+ "codeblock": true
+}
+```
+
+
+### Range
+
+`min`, `max`로 범위를, `format`으로 표시 형식을 지정합니다. 값 텍스트가 단위만 담고 있다면 `getAriaValueText`로 무엇을 재는 값인지 함께 읽히도록 합니다.
+
+
+```json doc-gen:file
+{
+ "file": "./src/components/demo/examples/meter/meter-range.tsx",
+ "codeblock": true
+}
+```
+
+
+## Examples
+
+---
+
+### Auto Update
+
+값이 스스로 바뀌는 미터에는 갱신을 멈출 수단을 함께 둡니다. 갱신 간격은 초당 3회를 넘기지 않습니다.
+
+
+```json doc-gen:file
+{
+ "file": "./src/components/demo/examples/meter/meter-auto-update.tsx",
+ "codeblock": true
+}
+```
+
+
+## Accessibility
+
+---
+
+`role="meter"`와 `aria-valuenow` / `aria-valuemin` / `aria-valuemax`는 `Meter`가 알아서 붙입니다. 나머지는 컴포넌트가 대신 지킬 수 없으니 **쓰는 쪽에서 챙겨야 합니다.**
+
+### 진행률에는 쓰지 마세요
+
+`Meter`가 다루는 값은 **범위가 정해진 지금의 측정값**입니다. 디스크 사용량, 점수, 잔량이 여기 해당합니다. 작업이 얼마나 끝났는지 보여주는 진행률이나 상한이 없는 값은 대상이 아닙니다.
+
+진행률에 `role="meter"`가 붙으면 보조기술은 "완료를 향해 가는 값"이 아니라 "지금 재고 있는 값"으로 읽습니다. `render`로 역할만 갈아끼우지 말고 진행률에 맞는 컴포넌트를 쓰세요.
+
+### 이름은 하나만 주세요
+
+`Meter.Label`을 렌더하면 `Meter.Root`에 준 `aria-label`은 이름 계산에서 밀려납니다. 오류도 경고도 없이 조용히 사라지니 둘 중 하나만 쓰세요.
+
+- 이름을 화면에 보여준다 → `Meter.Label`
+- 화면에 이름을 두지 않는다 → `Meter.Root`의 `aria-label`
+
+### 같은 값은 같은 문구·같은 형식으로
+
+한 측정값이 여러 화면에 나온다면 레이블 문구와 `format`을 맞추세요. 여기서는 `저장 공간 4.2GB`, 저기서는 `디스크 52%`라면 읽는 사람은 둘이 같은 값인지 알 길이 없습니다.
+
+### 자동으로 갱신된다면 멈출 수단을 두세요
+
+값이 5초를 넘겨 저절로 바뀐다면 사용자가 일시정지·정지·숨김 중 하나를 고를 수 있어야 합니다. 갱신 간격은 **초당 3회 이하**로 잡으세요. 그보다 빠르면 화면이 깜빡이는 것처럼 보입니다. 위 [Auto Update](#auto-update)가 그 예입니다.
+
+### 색을 바꿀 때는 대비를 확인하세요
+
+`className`이나 `$css`로 색을 갈아끼우면 기본 색이 맞춰 둔 대비가 무너집니다.
+
+- `Meter.Label` / `Meter.Value` 텍스트 대 배경 — **4.5:1** 이상
+- `Meter.IndicatorPrimitive` 대 `Meter.Track`, `Meter.Track` 대 페이지 배경 — **3:1** 이상
+
+`type="warning"`처럼 색으로 상태를 알린다면 그 상태를 레이블 문구에도 적으세요. 색을 구분하지 못하는 사용자에게는 문구가 유일한 단서입니다.
+
+### 미터를 가리킬 때 모양·색·위치로만 말하지 마세요
+
+"오른쪽 초록 막대를 확인하세요" 같은 안내는 그 색과 위치를 알아채는 사람에게만 통합니다. 미터를 가리킬 때는 레이블 문구를 부르세요.
+
+### 페이지와 언어가 다르면 표시하세요
+
+레이블이 페이지 언어와 다른 언어로 적혀 있다면 그 요소에 `lang`을 주세요. 값 형식도 그 언어를 따라야 한다면 `locale`까지 맞춥니다.
+
+### 폭을 고정하지 마세요
+
+`Meter.Root`는 기본이 `width: 100%`입니다. `width`를 고정하는 대신 `max-width`를 쓰면 320px 폭에서도, 200% 확대에서도 내용이 잘리지 않습니다.
+
+### 값은 텍스트로 보여주세요
+
+`42%` 같은 숫자를 그림으로 만들어 넣지 마세요. `Meter.Value`가 내는 텍스트여야 사용자가 글자 크기나 자간을 바꿨을 때 값도 함께 바뀝니다.
+
+## Props Table
+
+---
+
+### Meter.Root
+
+
+
+### Meter.Label
+
+
+
+### Meter.Value
+
+
+
+### Meter.Track
+
+
+
+### Meter.TrackPrimitive
+
+
+
+### Meter.IndicatorPrimitive
+
+
diff --git a/apps/website/content/docs/components/meta.json b/apps/website/content/docs/components/meta.json
index 022b5f9d7..6319cbecc 100644
--- a/apps/website/content/docs/components/meta.json
+++ b/apps/website/content/docs/components/meta.json
@@ -30,6 +30,7 @@
"(components)/icon-button.mdx",
"(components)/input-group.mdx",
"(components)/menu.mdx",
+ "(components)/meter.mdx",
"(components)/multi-select.mdx",
"(components)/navigation-menu.mdx",
"(components)/pagination.mdx",
diff --git a/apps/website/docs-extractor.config.mjs b/apps/website/docs-extractor.config.mjs
index f98638287..57c783b62 100644
--- a/apps/website/docs-extractor.config.mjs
+++ b/apps/website/docs-extractor.config.mjs
@@ -13,5 +13,8 @@ export default defineConfig({
'button/button.tsx': {
include: ['nativeButton'],
},
+ 'meter/meter.tsx': {
+ include: ['aria-valuetext'],
+ },
},
});
diff --git a/apps/website/public/components/generated/meter-indicator-primitive.json b/apps/website/public/components/generated/meter-indicator-primitive.json
new file mode 100644
index 000000000..ce25e3d17
--- /dev/null
+++ b/apps/website/public/components/generated/meter-indicator-primitive.json
@@ -0,0 +1,31 @@
+{
+ "name": "IndicatorPrimitive",
+ "displayName": "Meter.IndicatorPrimitive",
+ "description": "현재 값에 해당하는 만큼 트랙을 채웁니다. `
` 요소로 렌더링됩니다.",
+ "props": [
+ {
+ "name": "className",
+ "type": [
+ "string | ((state: Meter.IndicatorPrimitive.State) => (string | undefined))"
+ ],
+ "required": false,
+ "description": "요소에 적용된 CSS 클래스 또는 컴포넌트의 상태에 따라 클래스를 반환하는 함수."
+ },
+ {
+ "name": "style",
+ "type": [
+ "React.CSSProperties | ((state: Meter.IndicatorPrimitive.State) => (React.CSSProperties | undefined))"
+ ],
+ "required": false,
+ "description": "요소에 적용된 인라인 스타일 또는 컴포넌트의 상태에 따라 스타일 객체를 반환하는 함수."
+ },
+ {
+ "name": "render",
+ "type": [
+ "ReactElement | ((props: HTMLProps, state: Meter.IndicatorPrimitive.State) => ReactElement)"
+ ],
+ "required": false,
+ "description": "컴포넌트의 HTML 요소를 다른 태그로 대체하거나 다른 컴포넌트와 조합할 수 있습니다. 렌더링할 요소를 반환하는 `ReactElement` 또는 함수를 인수로 받습니다."
+ }
+ ]
+}
diff --git a/apps/website/public/components/generated/meter-label.json b/apps/website/public/components/generated/meter-label.json
new file mode 100644
index 000000000..95f1e1a05
--- /dev/null
+++ b/apps/website/public/components/generated/meter-label.json
@@ -0,0 +1,29 @@
+{
+ "name": "Label",
+ "displayName": "Meter.Label",
+ "description": "미터의 이름을 화면에 보여주고 보조기술에 전달합니다. `` 요소로 렌더링됩니다.",
+ "props": [
+ {
+ "name": "className",
+ "type": ["string | ((state: Meter.Label.State) => (string | undefined))"],
+ "required": false,
+ "description": "요소에 적용된 CSS 클래스 또는 컴포넌트의 상태에 따라 클래스를 반환하는 함수."
+ },
+ {
+ "name": "style",
+ "type": [
+ "React.CSSProperties | ((state: Meter.Label.State) => (React.CSSProperties | undefined))"
+ ],
+ "required": false,
+ "description": "요소에 적용된 인라인 스타일 또는 컴포넌트의 상태에 따라 스타일 객체를 반환하는 함수."
+ },
+ {
+ "name": "render",
+ "type": [
+ "ReactElement | ((props: HTMLProps, state: Meter.Label.State) => ReactElement)"
+ ],
+ "required": false,
+ "description": "컴포넌트의 HTML 요소를 다른 태그로 대체하거나 다른 컴포넌트와 조합할 수 있습니다. 렌더링할 요소를 반환하는 `ReactElement` 또는 함수를 인수로 받습니다."
+ }
+ ]
+}
diff --git a/apps/website/public/components/generated/meter-root.json b/apps/website/public/components/generated/meter-root.json
new file mode 100644
index 000000000..da31199c0
--- /dev/null
+++ b/apps/website/public/components/generated/meter-root.json
@@ -0,0 +1,110 @@
+{
+ "name": "Root",
+ "displayName": "Meter.Root",
+ "description": "알려진 범위 안에서 측정값을 표시하는 컴포넌트입니다. 디스크 사용량이나 점수처럼 값 자체의 크기를 보여줄 때 사용합니다. `
` 요소로 렌더링됩니다.",
+ "props": [
+ {
+ "name": "value",
+ "type": [
+ "number"
+ ],
+ "required": true,
+ "description": "미터의 현재 값"
+ },
+ {
+ "name": "size",
+ "type": [
+ "sm",
+ "md",
+ "lg"
+ ],
+ "required": false,
+ "description": "미터의 크기. 트랙의 높이를 결정합니다.",
+ "defaultValue": "md"
+ },
+ {
+ "name": "type",
+ "type": [
+ "default",
+ "warning"
+ ],
+ "required": false,
+ "description": "채워진 영역의 색상. 색만으로 상태를 알리지 않도록 레이블 문구에도 함께 적어 주세요.",
+ "defaultValue": "default"
+ },
+ {
+ "name": "className",
+ "type": [
+ "string | ((state: Meter.Root.State) => (string | undefined))"
+ ],
+ "required": false,
+ "description": "요소에 적용된 CSS 클래스 또는 컴포넌트의 상태에 따라 클래스를 반환하는 함수."
+ },
+ {
+ "name": "aria-valuetext",
+ "type": [
+ "string"
+ ],
+ "required": false,
+ "description": "현재 값을 대신해 보조기술에 전달할 텍스트. 값에 따라 달라지는 텍스트가 필요하면 `getAriaValueText`를 사용하세요."
+ },
+ {
+ "name": "format",
+ "type": [
+ "Intl.NumberFormatOptions"
+ ],
+ "required": false,
+ "description": "값의 표시 형식을 지정하는 옵션"
+ },
+ {
+ "name": "getAriaValueText",
+ "type": [
+ "(formattedValue: string, value: number) => string"
+ ],
+ "required": false,
+ "description": "`aria-valuenow`(미터의 현재 값)를 대신할, 사람이 읽을 수 있는 텍스트를 반환하는 함수"
+ },
+ {
+ "name": "locale",
+ "type": [
+ "Intl.LocalesArgument"
+ ],
+ "required": false,
+ "description": "값을 형식화할 때 `Intl.NumberFormat`이 사용할 로케일\n\n기본값은 사용자 런타임의 로케일입니다."
+ },
+ {
+ "name": "max",
+ "type": [
+ "number"
+ ],
+ "required": false,
+ "description": "미터의 최대값",
+ "defaultValue": "100"
+ },
+ {
+ "name": "min",
+ "type": [
+ "number"
+ ],
+ "required": false,
+ "description": "미터의 최소값",
+ "defaultValue": "0"
+ },
+ {
+ "name": "style",
+ "type": [
+ "React.CSSProperties | ((state: Meter.Root.State) => (React.CSSProperties | undefined))"
+ ],
+ "required": false,
+ "description": "요소에 적용된 인라인 스타일 또는 컴포넌트의 상태에 따라 스타일 객체를 반환하는 함수."
+ },
+ {
+ "name": "render",
+ "type": [
+ "ReactElement | ((props: HTMLProps, state: Meter.Root.State) => ReactElement)"
+ ],
+ "required": false,
+ "description": "컴포넌트의 HTML 요소를 다른 태그로 대체하거나 다른 컴포넌트와 조합할 수 있습니다. 렌더링할 요소를 반환하는 `ReactElement` 또는 함수를 인수로 받습니다."
+ }
+ ]
+}
diff --git a/apps/website/public/components/generated/meter-track-primitive.json b/apps/website/public/components/generated/meter-track-primitive.json
new file mode 100644
index 000000000..c8777e92f
--- /dev/null
+++ b/apps/website/public/components/generated/meter-track-primitive.json
@@ -0,0 +1,31 @@
+{
+ "name": "TrackPrimitive",
+ "displayName": "Meter.TrackPrimitive",
+ "description": "미터의 전체 범위를 나타내며, 내부에 인디케이터를 포함하지 않습니다. `
` 요소로 렌더링됩니다.",
+ "props": [
+ {
+ "name": "className",
+ "type": [
+ "string | ((state: Meter.TrackPrimitive.State) => (string | undefined))"
+ ],
+ "required": false,
+ "description": "요소에 적용된 CSS 클래스 또는 컴포넌트의 상태에 따라 클래스를 반환하는 함수."
+ },
+ {
+ "name": "style",
+ "type": [
+ "React.CSSProperties | ((state: Meter.TrackPrimitive.State) => (React.CSSProperties | undefined))"
+ ],
+ "required": false,
+ "description": "요소에 적용된 인라인 스타일 또는 컴포넌트의 상태에 따라 스타일 객체를 반환하는 함수."
+ },
+ {
+ "name": "render",
+ "type": [
+ "ReactElement | ((props: HTMLProps, state: Meter.TrackPrimitive.State) => ReactElement)"
+ ],
+ "required": false,
+ "description": "컴포넌트의 HTML 요소를 다른 태그로 대체하거나 다른 컴포넌트와 조합할 수 있습니다. 렌더링할 요소를 반환하는 `ReactElement` 또는 함수를 인수로 받습니다."
+ }
+ ]
+}
diff --git a/apps/website/public/components/generated/meter-track.json b/apps/website/public/components/generated/meter-track.json
new file mode 100644
index 000000000..ee0efb244
--- /dev/null
+++ b/apps/website/public/components/generated/meter-track.json
@@ -0,0 +1,39 @@
+{
+ "name": "Track",
+ "displayName": "Meter.Track",
+ "description": "미터의 전체 범위를 나타내며, 내부적으로 `Meter.IndicatorPrimitive`를 포함합니다. `
` 요소로 렌더링됩니다.",
+ "props": [
+ {
+ "name": "className",
+ "type": [
+ "string | ((state: Meter.Track.State) => (string | undefined))"
+ ],
+ "required": false,
+ "description": "요소에 적용된 CSS 클래스 또는 컴포넌트의 상태에 따라 클래스를 반환하는 함수."
+ },
+ {
+ "name": "style",
+ "type": [
+ "React.CSSProperties | ((state: Meter.Track.State) => (React.CSSProperties | undefined))"
+ ],
+ "required": false,
+ "description": "요소에 적용된 인라인 스타일 또는 컴포넌트의 상태에 따라 스타일 객체를 반환하는 함수."
+ },
+ {
+ "name": "render",
+ "type": [
+ "ReactElement | ((props: HTMLProps, state: Meter.Track.State) => ReactElement)"
+ ],
+ "required": false,
+ "description": "컴포넌트의 HTML 요소를 다른 태그로 대체하거나 다른 컴포넌트와 조합할 수 있습니다. 렌더링할 요소를 반환하는 `ReactElement` 또는 함수를 인수로 받습니다."
+ },
+ {
+ "name": "indicatorElement",
+ "type": [
+ "ReactElement"
+ ],
+ "required": false,
+ "description": "커스텀 인디케이터 요소입니다. 기본값은 ``입니다."
+ }
+ ]
+}
diff --git a/apps/website/public/components/generated/meter-value.json b/apps/website/public/components/generated/meter-value.json
new file mode 100644
index 000000000..e07825c7c
--- /dev/null
+++ b/apps/website/public/components/generated/meter-value.json
@@ -0,0 +1,35 @@
+{
+ "name": "Value",
+ "displayName": "Meter.Value",
+ "description": "형식화된 값을 텍스트로 표시합니다. 중복 안내를 막기 위해 보조기술에서는 숨겨집니다. `` 요소로 렌더링됩니다.",
+ "props": [
+ {
+ "name": "children",
+ "type": ["(formattedValue: string, value: number) => ReactNode"],
+ "required": false,
+ "description": "형식화된 값과 원본 값을 받아 표시할 내용을 반환하는 함수"
+ },
+ {
+ "name": "className",
+ "type": ["string | ((state: Meter.Value.State) => (string | undefined))"],
+ "required": false,
+ "description": "요소에 적용된 CSS 클래스 또는 컴포넌트의 상태에 따라 클래스를 반환하는 함수."
+ },
+ {
+ "name": "style",
+ "type": [
+ "React.CSSProperties | ((state: Meter.Value.State) => (React.CSSProperties | undefined))"
+ ],
+ "required": false,
+ "description": "요소에 적용된 인라인 스타일 또는 컴포넌트의 상태에 따라 스타일 객체를 반환하는 함수."
+ },
+ {
+ "name": "render",
+ "type": [
+ "ReactElement | ((props: HTMLProps, state: Meter.Value.State) => ReactElement)"
+ ],
+ "required": false,
+ "description": "컴포넌트의 HTML 요소를 다른 태그로 대체하거나 다른 컴포넌트와 조합할 수 있습니다. 렌더링할 요소를 반환하는 `ReactElement` 또는 함수를 인수로 받습니다."
+ }
+ ]
+}
diff --git a/apps/website/src/components/demo/examples/meter/default-meter.tsx b/apps/website/src/components/demo/examples/meter/default-meter.tsx
new file mode 100644
index 000000000..d45e3deba
--- /dev/null
+++ b/apps/website/src/components/demo/examples/meter/default-meter.tsx
@@ -0,0 +1,11 @@
+import { Meter } from '@vapor-ui/core';
+
+export default function DefaultMeter() {
+ return (
+
+ 저장 공간
+
+
+
+ );
+}
diff --git a/apps/website/src/components/demo/examples/meter/meter-auto-update.tsx b/apps/website/src/components/demo/examples/meter/meter-auto-update.tsx
new file mode 100644
index 000000000..5117a2079
--- /dev/null
+++ b/apps/website/src/components/demo/examples/meter/meter-auto-update.tsx
@@ -0,0 +1,30 @@
+import { useEffect, useState } from 'react';
+
+import { Button, Meter, VStack } from '@vapor-ui/core';
+
+export default function MeterAutoUpdate() {
+ const [value, setValue] = useState(42);
+ const [running, setRunning] = useState(true);
+
+ useEffect(() => {
+ if (!running) return;
+
+ const id = setInterval(() => setValue((prev) => (prev + 7) % 101), 1000);
+
+ return () => clearInterval(id);
+ }, [running]);
+
+ return (
+
+
+ CPU 사용률
+
+
+
+
+
+
+ );
+}
diff --git a/apps/website/src/components/demo/examples/meter/meter-range.tsx b/apps/website/src/components/demo/examples/meter/meter-range.tsx
new file mode 100644
index 000000000..033feb95d
--- /dev/null
+++ b/apps/website/src/components/demo/examples/meter/meter-range.tsx
@@ -0,0 +1,17 @@
+import { Meter } from '@vapor-ui/core';
+
+export default function MeterRange() {
+ return (
+ `8GB 중 ${formattedValue} 사용`}
+ $css={{ maxWidth: '20rem' }}
+ >
+ 저장 공간
+
+
+
+ );
+}
diff --git a/apps/website/src/components/demo/examples/meter/meter-size.tsx b/apps/website/src/components/demo/examples/meter/meter-size.tsx
new file mode 100644
index 000000000..199363144
--- /dev/null
+++ b/apps/website/src/components/demo/examples/meter/meter-size.tsx
@@ -0,0 +1,15 @@
+import { Meter, VStack } from '@vapor-ui/core';
+
+export default function MeterSize() {
+ return (
+
+ {(['sm', 'md', 'lg'] as const).map((size) => (
+
+ 저장 공간 ({size})
+
+
+
+ ))}
+
+ );
+}
diff --git a/apps/website/src/components/demo/examples/meter/meter-type.tsx b/apps/website/src/components/demo/examples/meter/meter-type.tsx
new file mode 100644
index 000000000..1665a92c2
--- /dev/null
+++ b/apps/website/src/components/demo/examples/meter/meter-type.tsx
@@ -0,0 +1,19 @@
+import { Meter, VStack } from '@vapor-ui/core';
+
+export default function MeterType() {
+ return (
+
+
+ 저장 공간
+
+
+
+
+
+ 저장 공간 부족
+
+
+
+
+ );
+}
diff --git a/packages/core/src/components/meter/index.parts.ts b/packages/core/src/components/meter/index.parts.ts
new file mode 100644
index 000000000..149c3dc50
--- /dev/null
+++ b/packages/core/src/components/meter/index.parts.ts
@@ -0,0 +1,8 @@
+export {
+ MeterRoot as Root,
+ MeterLabel as Label,
+ MeterTrackPrimitive as TrackPrimitive,
+ MeterTrack as Track,
+ MeterIndicatorPrimitive as IndicatorPrimitive,
+ MeterValue as Value,
+} from './meter';
diff --git a/packages/core/src/components/meter/index.ts b/packages/core/src/components/meter/index.ts
new file mode 100644
index 000000000..170d463dd
--- /dev/null
+++ b/packages/core/src/components/meter/index.ts
@@ -0,0 +1 @@
+export * as Meter from './index.parts';
diff --git a/packages/core/src/components/meter/meter.css.ts b/packages/core/src/components/meter/meter.css.ts
new file mode 100644
index 000000000..01e71aacc
--- /dev/null
+++ b/packages/core/src/components/meter/meter.css.ts
@@ -0,0 +1,69 @@
+import type { RecipeVariants } from '@vanilla-extract/recipes';
+
+import { componentRecipe, componentStyle } from '~/styles/mixins/layer-style.css';
+import { typography } from '~/styles/mixins/typography.css';
+import { vars } from '~/styles/themes.css';
+
+export const root = componentStyle({
+ display: 'grid',
+ gridTemplateColumns: 'minmax(0, 1fr) auto',
+ alignItems: 'center',
+ rowGap: vars.size.space['100'],
+ width: '100%',
+});
+
+export const track = componentRecipe({
+ base: {
+ gridColumn: '1 / -1',
+ borderRadius: vars.size.borderRadius['900'],
+ backgroundColor: vars.color.background['secondary'],
+ overflow: 'hidden',
+ },
+
+ defaultVariants: { size: 'md' },
+ variants: {
+ /**
+ * Size of the meter. Controls the height of the track.
+ */
+ size: {
+ sm: { height: vars.size.dimension['050'] },
+ md: { height: vars.size.dimension['075'] },
+ lg: { height: vars.size.dimension['150'] },
+ },
+ },
+});
+
+export const indicator = componentRecipe({
+ base: {
+ display: 'block',
+ borderRadius: 'inherit',
+ },
+
+ defaultVariants: { type: 'default' },
+ variants: {
+ /**
+ * Color of the filled portion of the meter.
+ */
+ type: {
+ default: { backgroundColor: vars.color.background['primary'] },
+ warning: { backgroundColor: vars.color.background['warning'] },
+ },
+ },
+});
+
+export const label = componentStyle([
+ typography({ style: 'subtitle1' }),
+ {
+ color: vars.color.foreground['normal'],
+ },
+]);
+
+export const value = componentStyle([
+ typography({ style: 'subtitle1' }),
+ {
+ color: vars.color.foreground['secondary'],
+ },
+]);
+
+export type TrackVariants = NonNullable>;
+export type IndicatorVariants = NonNullable>;
diff --git a/packages/core/src/components/meter/meter.stories.tsx b/packages/core/src/components/meter/meter.stories.tsx
new file mode 100644
index 000000000..627873031
--- /dev/null
+++ b/packages/core/src/components/meter/meter.stories.tsx
@@ -0,0 +1,55 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { Meter } from '.';
+import { VStack } from '../v-stack';
+
+export default {
+ title: 'Meter',
+ component: Meter.Root,
+ argTypes: {
+ size: { control: 'inline-radio', options: ['sm', 'md', 'lg'] },
+ type: { control: 'inline-radio', options: ['default', 'warning'] },
+ value: { control: { type: 'range', min: 0, max: 100 } },
+ },
+} as Meta;
+
+type Story = StoryObj;
+
+const MeterExample = ({
+ label = 'Storage used',
+ ...args
+}: Meter.Root.Props & { label?: string }) => (
+
+ {label}
+
+
+
+);
+
+export const Default: Story = {
+ args: { value: 42 },
+ render: (args) => (
+
+
+
+ ),
+};
+export const TestBed: Story = {
+ render: () => (
+
+ {(['sm', 'md', 'lg'] as const).map((size) => (
+
+ {(['default', 'warning'] as const).map((type) => (
+
+ ))}
+
+ ))}
+
+ ),
+};
diff --git a/packages/core/src/components/meter/meter.test.tsx b/packages/core/src/components/meter/meter.test.tsx
new file mode 100644
index 000000000..8b005485e
--- /dev/null
+++ b/packages/core/src/components/meter/meter.test.tsx
@@ -0,0 +1,258 @@
+import { cleanup, render, screen } from '@testing-library/react';
+import { axe } from 'vitest-axe';
+
+import { Meter } from '.';
+import * as styles from './meter.css';
+
+const LABEL_TEXT = 'Storage used';
+
+const MeterTest = ({ children, ...props }: Partial) => (
+
+ {children ?? (
+ <>
+ {LABEL_TEXT}
+
+
+ >
+ )}
+
+);
+
+describe('Meter', () => {
+ afterEach(cleanup);
+
+ it('should keep the value-text path open on the root', () => {
+ expectTypeOf().toEqualTypeOf<
+ Intl.LocalesArgument | undefined
+ >();
+ expectTypeOf().toEqualTypeOf<
+ Intl.NumberFormatOptions | undefined
+ >();
+ expectTypeOf().toHaveProperty('getAriaValueText');
+ expectTypeOf().toHaveProperty('aria-valuetext');
+ });
+
+ it('should have no a11y violations', async () => {
+ const rendered = render();
+ const result = await axe(rendered.container);
+
+ expect(result).toHaveNoViolations();
+ });
+
+ it('should expose role="meter" with the value range', () => {
+ render();
+ const meter = screen.getByRole('meter');
+
+ expect(meter).toHaveAttribute('aria-valuemin', '0');
+ expect(meter).toHaveAttribute('aria-valuemax', '100');
+ expect(meter).toHaveAttribute('aria-valuenow', '42');
+ });
+
+ it('should clamp values outside the range', () => {
+ const { unmount } = render();
+ expect(screen.getByRole('meter')).toHaveAttribute('aria-valuenow', '100');
+ unmount();
+
+ render();
+ expect(screen.getByRole('meter')).toHaveAttribute('aria-valuenow', '0');
+ });
+
+ it('should use the label as the accessible name', () => {
+ render();
+
+ expect(screen.getByRole('meter')).toHaveAccessibleName(LABEL_TEXT);
+ });
+
+ it('should format `aria-valuetext` with the given `format` instead of a percentage', () => {
+ const { unmount } = render();
+ expect(screen.getByRole('meter')).toHaveAttribute('aria-valuetext', '42%');
+ unmount();
+
+ render();
+ expect(screen.getByRole('meter')).toHaveAttribute('aria-valuetext', '4.2 GB');
+ });
+
+ it('should let `getAriaValueText` replace the generated value text', () => {
+ render(
+ `${formatted} of 20 GB (${value})`}
+ />,
+ );
+
+ expect(screen.getByRole('meter')).toHaveAttribute(
+ 'aria-valuetext',
+ '4.2 GB of 20 GB (4.2)',
+ );
+ });
+
+ it('should let `aria-valuetext` override the generated value text', () => {
+ render();
+
+ expect(screen.getByRole('meter')).toHaveAttribute(
+ 'aria-valuetext',
+ '42GB used out of 100GB',
+ );
+ });
+
+ it('should format the value with the given `locale`', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole('meter')).toHaveAttribute('aria-valuetext', '4.2GB');
+ });
+
+ it('should hide the value text from assistive technology', () => {
+ render();
+
+ expect(screen.getByText('42%')).toHaveAttribute('aria-hidden', 'true');
+ });
+
+ it('should not set `aria-labelledby` when no label is rendered', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByRole('meter')).not.toHaveAttribute('aria-labelledby');
+ });
+
+ it('should point `aria-labelledby` at the rendered label', () => {
+ const { container } = render();
+ const labelledBy = screen.getByRole('meter').getAttribute('aria-labelledby');
+
+ expect(labelledBy).toBeTruthy();
+ expect(container.querySelector(`#${labelledBy}`)).toHaveTextContent(LABEL_TEXT);
+ });
+
+ it('should give each instance its own label id', () => {
+ const { container } = render(
+ <>
+
+
+
+ >,
+ );
+
+ const ids = screen
+ .getAllByRole('meter')
+ .map((meter) => meter.getAttribute('aria-labelledby'));
+
+ expect(new Set(ids).size).toBe(ids.length);
+ ids.forEach((id) => {
+ expect(container.querySelector(`#${id}`)).toBeInTheDocument();
+ });
+ });
+
+ it('should use `aria-label` as the accessible name', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByRole('meter')).toHaveAccessibleName('Disk usage');
+ });
+
+ it('should use `aria-labelledby` as the accessible name', () => {
+ render(
+ <>
+
Disk usage
+
+
+
+ >,
+ );
+
+ expect(screen.getByRole('meter')).toHaveAccessibleName('Disk usage');
+ });
+
+ it('should apply `size` to the track and `type` to the indicator', () => {
+ render(
+
+ }
+ />
+ ,
+ );
+
+ expect(screen.getByTestId('track')).toHaveClass(styles.track({ size: 'lg' }));
+ expect(screen.getByTestId('indicator')).toHaveClass(styles.indicator({ type: 'warning' }));
+ expect(screen.getByTestId('indicator')).not.toHaveClass(
+ styles.indicator({ type: 'default' }),
+ );
+ });
+
+ it('should render an indicator inside `Meter.Track` by default', () => {
+ render(
+
+
+ ,
+ );
+
+ const indicator = screen.getByTestId('track').firstElementChild;
+
+ expect(indicator).toHaveClass(styles.indicator({ type: 'warning' }));
+ });
+
+ it('should render no indicator when `Meter.TrackPrimitive` is used directly', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('track')).toBeEmptyDOMElement();
+ });
+
+ describe('development warnings', () => {
+ let warn: ReturnType;
+
+ beforeEach(() => {
+ warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ warn.mockRestore();
+ });
+
+ it('should warn when `min` is not less than `max`', () => {
+ render();
+
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('`min` must be less than'));
+ });
+
+ it('should warn when the meter has no accessible name', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(warn).toHaveBeenCalledWith(
+ expect.stringContaining('Meter has no accessible name'),
+ );
+ });
+
+ it('should stay silent when `Meter.Label` names the meter', () => {
+ render();
+
+ expect(warn).not.toHaveBeenCalled();
+ });
+
+ it('should stay silent when only `aria-label` is given', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(warn).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/packages/core/src/components/meter/meter.tsx b/packages/core/src/components/meter/meter.tsx
new file mode 100644
index 000000000..efa2284d7
--- /dev/null
+++ b/packages/core/src/components/meter/meter.tsx
@@ -0,0 +1,222 @@
+'use client';
+
+import type { ReactElement, RefObject } from 'react';
+import { forwardRef, useEffect, useRef } from 'react';
+
+import { Meter as BaseMeter } from '@base-ui/react/meter';
+
+import { createContext } from '~/libs/create-context';
+import { cn } from '~/utils/cn';
+import { createSplitProps } from '~/utils/create-split-props';
+import { resolveStyles } from '~/utils/resolve-styles';
+import type { VaporUIComponentProps } from '~/utils/types';
+import { warn } from '~/utils/warn';
+
+import type { IndicatorVariants, TrackVariants } from './meter.css';
+import * as styles from './meter.css';
+
+type MeterVariants = TrackVariants & IndicatorVariants;
+type MeterSharedProps = MeterVariants;
+
+type MeterContext = MeterSharedProps & {
+ /** Set by `Meter.Label` so the root can warn when the meter has no accessible name. */
+ hasLabelRef: RefObject;
+};
+
+const [MeterProvider, useMeterContext] = createContext({
+ name: 'Meter',
+ hookName: 'useMeterContext',
+ providerName: 'MeterProvider',
+});
+
+/* -------------------------------------------------------------------------------------------------
+ * Meter.Root
+ * -----------------------------------------------------------------------------------------------*/
+
+/**
+ * Displays a measurement inside a known range, such as disk usage or a score. Renders a `
` element.
+ */
+export const MeterRoot = forwardRef((props, ref) => {
+ const { className, min = 0, max = 100, ...componentProps } = resolveStyles(props);
+ const [variantProps, otherProps] = createSplitProps()(componentProps, [
+ 'type',
+ 'size',
+ ]);
+
+ const hasLabelRef = useRef(false);
+ const ariaLabel = otherProps['aria-label'];
+ const ariaLabelledBy = otherProps['aria-labelledby'];
+
+ useEffect(() => {
+ if (process.env.NODE_ENV === 'production') return;
+
+ if (min >= max) {
+ warn(
+ `Meter received min={${min}} and max={${max}}. \`min\` must be less than \`max\` for the value to be meaningful.`,
+ );
+ }
+
+ if (!hasLabelRef.current && !ariaLabel && !ariaLabelledBy) {
+ warn(
+ 'Meter has no accessible name. Render a `Meter.Label`, or pass `aria-label` / `aria-labelledby` to `Meter.Root`.',
+ );
+ }
+ }, [min, max, ariaLabel, ariaLabelledBy]);
+
+ return (
+
+
+
+ );
+});
+MeterRoot.displayName = 'Meter.Root';
+
+/* -------------------------------------------------------------------------------------------------
+ * Meter.Label
+ * -----------------------------------------------------------------------------------------------*/
+
+/**
+ * Names the meter for assistive technology and shows that name on screen. Renders a `` element.
+ */
+export const MeterLabel = forwardRef((props, ref) => {
+ const { className, ...componentProps } = resolveStyles(props);
+ const { hasLabelRef } = useMeterContext();
+
+ useEffect(() => {
+ hasLabelRef.current = true;
+
+ return () => {
+ hasLabelRef.current = false;
+ };
+ }, [hasLabelRef]);
+
+ return (
+
+ );
+});
+MeterLabel.displayName = 'Meter.Label';
+
+/* -------------------------------------------------------------------------------------------------
+ * Meter.TrackPrimitive
+ * -----------------------------------------------------------------------------------------------*/
+
+/**
+ * Represents the full range of the meter without any indicator inside. Renders a `
` element.
+ */
+export const MeterTrackPrimitive = forwardRef(
+ (props, ref) => {
+ const { className, ...componentProps } = resolveStyles(props);
+ const { size } = useMeterContext();
+
+ return (
+
+ );
+ },
+);
+MeterTrackPrimitive.displayName = 'Meter.TrackPrimitive';
+
+/* -------------------------------------------------------------------------------------------------
+ * Meter.IndicatorPrimitive
+ * -----------------------------------------------------------------------------------------------*/
+
+/**
+ * Fills the portion of the track that corresponds to the current value. Renders a `
` element.
+ */
+export const MeterIndicatorPrimitive = forwardRef(
+ (props, ref) => {
+ const { className, ...componentProps } = resolveStyles(props);
+ const { type } = useMeterContext();
+
+ return (
+
+ );
+ },
+);
+MeterIndicatorPrimitive.displayName = 'Meter.IndicatorPrimitive';
+
+/* -------------------------------------------------------------------------------------------------
+ * Meter.Track
+ * -----------------------------------------------------------------------------------------------*/
+
+/**
+ * Represents the full range of the meter and renders `Meter.IndicatorPrimitive` inside. Renders a `
` element.
+ */
+export const MeterTrack = forwardRef((props, ref) => {
+ const { indicatorElement, ...componentProps } = props;
+
+ return (
+
+ {indicatorElement ?? }
+
+ );
+});
+MeterTrack.displayName = 'Meter.Track';
+
+/* -------------------------------------------------------------------------------------------------
+ * Meter.Value
+ * -----------------------------------------------------------------------------------------------*/
+
+/**
+ * Shows the formatted value as text, hidden from assistive technology to avoid a duplicate announcement. Renders a `` element.
+ */
+export const MeterValue = forwardRef((props, ref) => {
+ const { className, ...componentProps } = resolveStyles(props);
+
+ return (
+
+ );
+});
+MeterValue.displayName = 'Meter.Value';
+
+/* -----------------------------------------------------------------------------------------------*/
+
+export namespace MeterRoot {
+ export type State = BaseMeter.Root.State;
+ export type Props = VaporUIComponentProps & MeterSharedProps;
+}
+
+export namespace MeterLabel {
+ export type State = BaseMeter.Label.State;
+ export type Props = VaporUIComponentProps;
+}
+
+export namespace MeterTrackPrimitive {
+ export type State = BaseMeter.Track.State;
+ export type Props = VaporUIComponentProps;
+}
+
+export namespace MeterIndicatorPrimitive {
+ export type State = BaseMeter.Indicator.State;
+ export type Props = VaporUIComponentProps;
+}
+
+export interface MeterTrackProps extends Omit {
+ /**
+ * A Custom element for Meter.IndicatorPrimitive. If not provided, the default Meter.IndicatorPrimitive will be rendered.
+ */
+ indicatorElement?: ReactElement;
+}
+
+export namespace MeterTrack {
+ export type State = MeterTrackPrimitive.State;
+ export type Props = MeterTrackProps;
+}
+
+export namespace MeterValue {
+ export type State = BaseMeter.Value.State;
+ export type Props = VaporUIComponentProps;
+}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index a2bec0c73..c1f5d1757 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -18,6 +18,7 @@ export * from './components/h-stack';
export * from './components/icon-button';
export * from './components/input-group';
export * from './components/menu';
+export * from './components/meter';
export * from './components/multi-select';
export * from './components/navigation-menu';
export * from './components/pagination';
diff --git a/packages/core/src/utils/warn.test.ts b/packages/core/src/utils/warn.test.ts
new file mode 100644
index 000000000..4e09ef9ba
--- /dev/null
+++ b/packages/core/src/utils/warn.test.ts
@@ -0,0 +1,28 @@
+import { warn } from './warn';
+
+describe('warn', () => {
+ let warnSpy: ReturnType;
+
+ beforeEach(() => {
+ warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ warnSpy.mockRestore();
+ });
+
+ it('warns once per message', () => {
+ warn('same message');
+ warn('same message');
+
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ expect(warnSpy).toHaveBeenCalledWith('Vapor UI: same message');
+ });
+
+ it('warns for each distinct message', () => {
+ warn('first message');
+ warn('second message');
+
+ expect(warnSpy).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/packages/core/src/utils/warn.ts b/packages/core/src/utils/warn.ts
new file mode 100644
index 000000000..8686d2e8e
--- /dev/null
+++ b/packages/core/src/utils/warn.ts
@@ -0,0 +1,14 @@
+const warned = new Set();
+
+/**
+ * Logs a development-only warning, at most once per distinct message.
+ *
+ * Guard the call site with `process.env.NODE_ENV !== 'production'` so bundlers
+ * can drop the message construction from production builds.
+ */
+export const warn = (message: string) => {
+ if (process.env.NODE_ENV === 'production' || warned.has(message)) return;
+
+ warned.add(message);
+ console.warn(`Vapor UI: ${message}`);
+};