# Usage Meter (Lumen Halo): prompt.md (v1.0.0)

- id: `lumen-usage-meter` · version 1.0.0 · component · free
- category: Feedback
- build: Base UI (one set of files; its dependencies follow the build)
- install (this build): `npx shadcn@latest add https://beautiful-ui.dev/r/lumen-usage-meter.json`
- npm dependencies: none
- registry dependencies: utils, https://beautiful-ui.dev/r/lumen-foundation.json
- docs: https://beautiful-ui.dev/components/lumen-usage-meter
- The install command carries everything this item needs (files, CSS, tokens, npm and registry dependencies). Prefer it to copying source by hand.

An allowance and what fills it: one hairline track split by category, the total against the limit in words, an amber rim near the limit, and past it a limit tick with the overage hatched after it.

## Build it
- Stack: React 19 (`ref` is a plain prop), TypeScript, Tailwind CSS v4 utilities, a shadcn-initialised project with the `@/*` alias.
- Packages: none beyond React.
- Files: `components/ui/lumen/controls/usage-meter.tsx`.
- Registry dependencies, installed with it automatically: shadcn `utils` (cn), `lumen-foundation`.
- Builds: one set of files for both, but its dependencies come in Base UI and Radix builds. Install the one that matches the project (see Install): a free item's bare URL installs the Base UI build of it and its dependencies.
- Exports to keep: `UsageMeter`, and every exported type.
- CSS: the install merges this item's rules (the registry `css` field) into your global stylesheet, in `@layer components`, and adds the lumen foundation (tokens, keyframes, motion levels) once. Nothing to import by hand.
- Re-running `add` (or `--overwrite`) re-applies those rules: put overrides in your own CSS, never in the installed rules.
- Tokens: retheme with the `--lumen-*` custom properties (`--lumen-accent`, `--lumen-accent-foreground`, `--lumen-accent-text`, `--lumen-bad`, `--lumen-bad-text`, `--lumen-chart-accent`, `--lumen-chart-accent-foreground`, `--lumen-chart-accent-text`, `--lumen-focus`, `--lumen-good`, `--lumen-good-text`, `--lumen-hairline`, `--lumen-ink`, `--lumen-muted-ink`, `--lumen-series-1`, `--lumen-series-2`, `--lumen-series-3`, `--lumen-series-4`, `--lumen-series-5`, `--lumen-series-6`, `--lumen-warn`, `--lumen-warn-text`); this item's CSS also reads `--lumen-font-mono`, `--lumen-font-sans`, `--lumen-radius-k`. Never add Tailwind colour classes inside the component.

```tsx
import { UsageMeter } from "@/components/ui/lumen/controls/usage-meter";
```

## Parts

| Part | data-slot | What it is for |
|---|---|---|
| `UsageMeter` | — | The header, the track (usage-meter-track, -segment, -limit, -overage), the status line and the legend. |

## Sound
- Keep every `data-slot` and `data-sound` attribute: the sound layer reads them.
- Installing this item adds no audio. Nothing plays until the app mounts `GlassSoundProvider` once (install: `npx shadcn@latest add https://beautiful-ui.dev/r/glass-sound.json`, import from `@/components/beautiful-ui/glass-sound`); `GlassSoundToggle` is its mute control. Without a provider the audio engine never loads.

## Match the original
- Read `components/ui/lumen/controls/usage-meter.tsx` as the reference implementation before changing or recreating anything, and match it: sizes, colours per theme, motion timings, copy and behaviour.
- If you deviate (a prop you can't honour, a style you changed, a dependency you swapped), say so in your reply, part by part.
- Keep the accessibility contract, the keyboard map and the motion levels listed below.

## Use it when
- usage meter, quota, allowance, storage used, plan limit, seats used, credits, overage, billing usage
- Plan limits on billing and settings pages: storage, seats, build minutes, API calls
- Showing what fills a quota, by category, so people know what to clean up

### Not when
- A task's progress toward done: use Progress
- A reading in a range with no allowance (CPU, latency): use Gauge

## Mistakes
- Pass the plan's limit, not the larger of limit and usage: the meter rescales past the limit itself
- Keep segments to five or fewer; group the rest as Other

## Usage

```tsx
"use client";

import { UsageMeter } from "@/components/ui/lumen/controls/usage-meter";

export function StorageUsage() {
  return (
    <UsageMeter
      label="Storage"
      limit={10}
      format={(v) => `${v} GB`}
      segments={[
        { id: "media", label: "Media", value: 4.6 },
        { id: "docs", label: "Documents", value: 2.2 },
        { id: "backups", label: "Backups", value: 1.4 },
      ]}
    />
  );
}
```

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `label` | `ReactNode` |  | What the allowance is, e.g. "Storage". Names the meter. |
| `segments` | `{ id, label, value, color? }[]` |  | What fills it, by category, in stacking order. Without colours they step down in ink. |
| `limit` | `number` |  | The allowance. |
| `warnAt` | `number` | `0.8` | Share of the limit where the meter turns amber. |
| `format` | `(value: number) => string` |  | Formats amounts, e.g. (v) => `${v} GB`. Default: the locale's number. |
| `locale` | `string` |  | For the default number and percent formats. |
| `labels` | `Partial<UsageMeterLabels>` |  | Every sentence (total, left, near, over, full), for other languages. |
| `showLegend` | `boolean` | `true` | The categories with their amounts under the bar. |

Full docs: https://beautiful-ui.dev/components/lumen-usage-meter

## Customising
- Colours: set the `--lumen-*` tokens on `:root`, or on a container with the `lumen-scope` class to retheme one area. Add the `lumen-inherit` class to follow your shadcn palette instead (`--chart-N`, `--destructive`).
- Dark mode follows the `.dark` class on an ancestor (the shadcn and next-themes convention).
- Update later by re-running the install with `--overwrite` (review the diff if you edited it). Changelog: https://beautiful-ui.dev/r/changelog.json

## Keyboard

| Keys | Action |
|---|---|
| Tab | Not focusable: it reports, it doesn't act. Put your Upgrade button next to it |

## Motion inventory

| Interaction | What moves |
|---|---|
| First paint | The fill wipes in from the start of the track |
| A value changes | Segments ease to their new widths; reduced motion skips both |
| Hover a category | The others fade back, in the bar and the legend |

## Accessibility contract (preserve when editing)
- role=meter with the whole sentence as its value text: the total, the share and what's left or over
- Near and over are said in words and marked with a dot, not only coloured

## Install

Base UI project (a base-* style in components.json):

```bash
npx shadcn@latest add https://beautiful-ui.dev/r/lumen-usage-meter.json
```

Radix project (a radix-*, new-york or default style):

```bash
npx shadcn@latest add https://beautiful-ui.dev/r/radix-nova/lumen-usage-meter.json
```

Or add the `@beautiful-ui` registry to components.json and run `npx shadcn@latest add @beautiful-ui/lumen-usage-meter`: the CLI picks the build from your style.

## Credits
- Built on shadcn/ui (https://ui.shadcn.com)

## Source (Base UI build)

### components/ui/lumen/controls/usage-meter.tsx

```tsx
"use client";
// Generated from src/registry/core/controls/usage-meter.tsx by scripts/gen-systems.ts. Edit the core file, not this one.

import * as React from "react";
import { cn } from "@/lib/utils";

/*
 * Usage Meter: an allowance and what fills it. One hairline track split by category, the total against
 * the limit in words, and a quiet change of state as it fills: amber near the limit, and past it the
 * track rescales so the limit sits inside it as a tick, with the overage hatched in red after it.
 * Hovering a category (in the bar or the legend) brings it forward. Screen readers get a meter with the
 * whole sentence ("8.2 GB of 10 GB, 82% used, 1.8 GB left").
 *
 *   <UsageMeter label="Storage" limit={10} format={(v) => `${v} GB`}
 *     segments={[{ id: "media", label: "Media", value: 5.1 }, { id: "docs", label: "Documents", value: 3.1 }]} />
 */

export interface UsageSegment {
  id: string;
  label: string;
  value: number;
  /** Any CSS colour. Default: ink at a lighter step for each category, so the meter stays neutral. */
  color?: string | undefined;
}

export type UsageState = "ok" | "near" | "over";

export interface UsageMeterLabels {
  /** The total against the allowance: "8.2 GB of 10 GB". */
  total: (used: string, limit: string) => string;
  /** Under the warning line. */
  left: (left: string) => string;
  /** Near the limit. */
  near: (percent: string, left: string) => string;
  /** Past the limit. */
  over: (over: string) => string;
  /** At exactly the limit. */
  full: string;
}

const DEFAULT_LABELS: UsageMeterLabels = {
  total: (used, limit) => `${used} of ${limit}`,
  left: (left) => `${left} left`,
  near: (percent, left) => `${percent} used, ${left} left`,
  over: (over) => `Over by ${over}`,
  full: "Limit reached",
};

/** The meter's arithmetic, on its own: the total, its share of the limit, and the state. */
export function usageOf(segments: readonly Pick<UsageSegment, "value">[], limitIn: number, warnAt = 0.8) {
  // NaN, Infinity and negatives become a limit of 0 (nothing allowed), so ARIA and the marker stay valid.
  const limit = Number.isFinite(limitIn) ? Math.max(0, limitIn) : 0;
  const used = segments.reduce((s, x) => s + (Number.isFinite(x.value) ? Math.max(0, x.value) : 0), 0);
  const ratio = limit > 0 ? used / limit : used > 0 ? Infinity : 0;
  const state: UsageState = ratio > 1 ? "over" : ratio >= warnAt ? "near" : "ok";
  // Past the limit the track rescales so the limit marker sits inside it.
  const scale = Math.max(limit, used) || 1;
  return { used, limit, ratio, state, scale, limitAt: Math.min(1, limit / scale) };
}

// Ink at falling strengths: categories read apart without bringing a palette into the UI.
const INK_STEPS = [100, 64, 40, 24, 14];
const inkFor = (i: number) => `color-mix(in srgb, var(--gc-ink) ${INK_STEPS[i % INK_STEPS.length]}%, transparent)`;

function UsageMeter({
  label,
  segments,
  limit: limitProp,
  warnAt = 0.8,
  format,
  locale,
  labels: labelsProp,
  showLegend = true,
  className,
  ...props
}: Omit<React.ComponentProps<"div">, "children"> & {
  /** What the allowance is, e.g. "Storage". Names the meter. */
  label: React.ReactNode;
  /** What fills it, by category, in the order they stack. */
  segments: readonly UsageSegment[];
  /** The allowance. */
  limit: number;
  /** Share of the limit (0–1) where the meter starts to warn. Default 0.8. */
  warnAt?: number;
  /** Formats amounts, e.g. (v) => `${v} GB`. Default: the locale's number format. */
  format?: ((value: number) => string) | undefined;
  /** For the default number and percent formats. */
  locale?: string | undefined;
  /** Every sentence, for other languages. */
  labels?: Partial<UsageMeterLabels> | undefined;
  /** The categories under the bar, with their amounts. Default true. */
  showLegend?: boolean;
}) {
  const labels = { ...DEFAULT_LABELS, ...labelsProp };
  const [focus, setFocus] = React.useState<string | null>(null);
  const fmt = format ?? ((v: number) => new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(v));
  const pct = new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 0 });
  const { used, ratio, state, scale, limitAt, limit: cap } = usageOf(segments, limitProp, warnAt);
  const limit = cap;
  const left = Math.max(0, limit - used);
  const status = state === "over" ? labels.over(fmt(used - limit)) : used === limit && limit > 0 ? labels.full : state === "near" ? labels.near(pct.format(ratio), fmt(left)) : labels.left(fmt(left));
  const total = labels.total(fmt(used), fmt(limit));
  const name = typeof label === "string" ? label : undefined;
  const labelId = React.useId();

  return (
    <div data-slot="usage-meter" data-state={state} data-focus={focus ?? undefined} className={cn(`lumen-usage-meter`, className)} {...props}>
      <div data-slot="usage-meter-header" className={`lumen-usage-meter-header`}>
        <span id={labelId} className={`lumen-usage-meter-label`}>
          {label}
        </span>
        <span data-slot="usage-meter-total" className={`lumen-usage-meter-total`}>
          {total}
        </span>
      </div>
      <div
        role="meter"
        aria-labelledby={name ? undefined : labelId}
        aria-label={name}
        aria-valuemin={0}
        aria-valuemax={limit}
        aria-valuenow={Math.min(used, limit)}
        aria-valuetext={`${total}. ${status}`}
        data-slot="usage-meter-track"
        className={`lumen-usage-meter-track`}
      >
        <span className={`lumen-usage-meter-fill`}>
          {segments.map((s, i) => {
            const w = (Math.max(0, Number.isFinite(s.value) ? s.value : 0) / scale) * 100;
            return w > 0 ? (
              <span
                key={s.id}
                data-slot="usage-meter-segment"
                data-id={s.id}
                data-dim={(focus && focus !== s.id) || undefined}
                className={`lumen-usage-meter-segment`}
                style={{ width: `${w}%`, background: s.color ?? inkFor(i) }}
                onPointerEnter={() => setFocus(s.id)}
                onPointerLeave={() => setFocus(null)}
              />
            ) : null;
          })}
        </span>
        {state === "over" && <span aria-hidden data-slot="usage-meter-overage" className={`lumen-usage-meter-overage`} style={{ insetInlineStart: `${limitAt * 100}%` }} />}
        {state === "over" && <span aria-hidden data-slot="usage-meter-limit" className={`lumen-usage-meter-limit`} style={{ insetInlineStart: `${limitAt * 100}%` }} />}
      </div>
      <p data-slot="usage-meter-status" className={`lumen-usage-meter-status`}>
        {state !== "ok" && <span aria-hidden className={`lumen-usage-meter-dot`} />}
        {status}
      </p>
      {showLegend && segments.length > 0 && (
        <ul data-slot="usage-meter-legend" className={`lumen-usage-meter-legend`}>
          {segments.map((s, i) => (
            <li
              key={s.id}
              data-dim={(focus && focus !== s.id) || undefined}
              className={`lumen-usage-meter-key`}
              onPointerEnter={() => setFocus(s.id)}
              onPointerLeave={() => setFocus(null)}
            >
              <span aria-hidden className={`lumen-usage-meter-swatch`} style={{ background: s.color ?? inkFor(i) }} />
              <span className={`lumen-usage-meter-key-label`}>{s.label}</span>
              <span className={`lumen-usage-meter-key-value`}>{fmt(Number.isFinite(s.value) ? Math.max(0, s.value) : 0)}</span>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

export { UsageMeter };
```

### CSS (the registry `css` / `cssVars`, merged into the global stylesheet by the shadcn CLI)

```css
@layer components {
  .lumen-usage-meter {
    display: flex;
    flex-direction: column;
    gap: 8px;
    width: 100%;
    min-width: 0;
    color: var(--gc-ink);
    font: 400 13px/1.4 var(--lumen-font-sans, var(--font-sans, var(--font-geist, var(--font-geist-sans, 'Geist')))), system-ui, sans-serif;
  }
  .lumen-usage-meter-header {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 12px;
    min-width: 0;
  }
  .lumen-usage-meter-label {
    min-width: 0;
    overflow: hidden;
    font-weight: 500;
    font-size: 13.5px;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .lumen-usage-meter-total {
    flex: none;
    font: 500 12px/1.4 var(--lumen-font-mono, var(--font-mono, var(--font-geist-mono, 'Geist Mono'))), ui-monospace, monospace;
    font-variant-numeric: tabular-nums;
    color: var(--gc-sec);
  }
  .lumen-usage-meter-track {
    position: relative;
    height: 8px;
    border-radius: calc(4px * var(--lumen-radius-k, 1));
    background: color-mix(in srgb,var(--gc-ink) 6%,transparent);
    box-shadow: inset 0 0 0 1px var(--gc-hair);
    transition: box-shadow min(0.3s, var(--gcp-cm)) ease;
  }
  .lumen-usage-meter-fill {
    position: absolute;
    inset: 0;
    display: flex;
    gap: 2px;
    overflow: hidden;
    border-radius: inherit;
    animation: lumen-usage-in calc(.7s * var(--gcp-k,1)) cubic-bezier(.22,1,.36,1) both;
  }
  @keyframes lumen-usage-in {
    from {
      clip-path: inset(0 100% 0 0);
    }
    to {
      clip-path: inset(0 0 0 0);
    }
  }
  [dir="rtl"] .lumen-usage-meter-fill {
    animation-name: lumen-usage-in-rtl;
  }
  @keyframes lumen-usage-in-rtl {
    from {
      clip-path: inset(0 0 0 100%);
    }
    to {
      clip-path: inset(0 0 0 0);
    }
  }
  .lumen-usage-meter-segment {
    flex: 0 1 auto;
    min-width: 2px;
    height: 100%;
    transition: width calc(0.5s * var(--gcp-k)) cubic-bezier(.22,1,.36,1),opacity min(0.2s, var(--gcp-cm)) ease;
  }
  .lumen-usage-meter :is(.lumen-usage-meter-segment,.lumen-usage-meter-key)[data-dim] {
    opacity: .28;
  }
  .lumen-usage-meter[data-state="near"] .lumen-usage-meter-track {
    box-shadow: inset 0 0 0 1px color-mix(in srgb,var(--gc-warn) 55%,var(--gc-hair));
  }
  .lumen-usage-meter[data-state="over"] .lumen-usage-meter-track {
    box-shadow: inset 0 0 0 1px color-mix(in srgb,var(--gc-bad) 50%,var(--gc-hair));
  }
  .lumen-usage-meter-overage {
    position: absolute;
    inset-block: 0;
    inset-inline-end: 0;
    border-start-end-radius: calc(4px * var(--lumen-radius-k, 1));
    border-end-end-radius: calc(4px * var(--lumen-radius-k, 1));
    background: repeating-linear-gradient(135deg,color-mix(in srgb,var(--gc-bad) 75%,transparent) 0 2px,transparent 2px 5px);
  }
  .lumen-usage-meter-limit {
    position: absolute;
    inset-block: -4px;
    width: 2px;
    margin-inline-start: -1px;
    border-radius: calc(1px * var(--lumen-radius-k, 1));
    background: var(--gc-ink);
  }
  .lumen-usage-meter-status {
    display: flex;
    align-items: center;
    gap: 6px;
    margin: 0;
    font-size: 12px;
    color: var(--gc-sec);
  }
  .lumen-usage-meter[data-state="near"] .lumen-usage-meter-status {
    color: var(--gc-warn-ui);
  }
  .lumen-usage-meter[data-state="over"] .lumen-usage-meter-status {
    color: var(--gc-bad-ui);
  }
  .lumen-usage-meter-dot {
    flex: none;
    width: 6px;
    height: 6px;
    border-radius: 50%;
    background: currentColor;
    box-shadow: 0 0 6px currentColor;
  }
  .lumen-usage-meter-legend {
    display: grid;
    grid-template-columns: repeat(auto-fill,minmax(9rem,1fr));
    gap: 4px 16px;
    margin: 4px 0 0;
    padding: 0;
    list-style: none;
  }
  .lumen-usage-meter-key {
    display: flex;
    align-items: center;
    gap: 8px;
    min-width: 0;
    font-size: 12.5px;
    transition: opacity min(0.2s, var(--gcp-cm)) ease;
  }
  .lumen-usage-meter-swatch {
    flex: none;
    width: 8px;
    height: 8px;
    border-radius: calc(2px * var(--lumen-radius-k, 1));
    box-shadow: inset 0 0 0 1px var(--gc-hair);
  }
  .lumen-usage-meter-key-label {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    color: var(--gc-sec);
  }
  .lumen-usage-meter-key-value {
    margin-inline-start: auto;
    font: 500 12px/1.4 var(--lumen-font-mono, var(--font-mono, var(--font-geist-mono, 'Geist Mono'))), ui-monospace, monospace;
    font-variant-numeric: tabular-nums;
  }
  @media (prefers-reduced-motion:reduce) {
    .lumen-usage-meter-fill {
      animation: none;
    }
    .lumen-usage-meter-segment {
      transition: none;
    }
  }
}
```

The lumen foundation (the tokens listed above, keyframes and motion levels) installs once with the first component; its CSS is public at https://beautiful-ui.dev/r/lumen-foundation.json.
