Game UI

Meter

A HUD bar — health, fuel, progress — that glows and blinks low.

stablev0.2.0 Chromium 103 ui-scale safe

Install

Copies the file into components/ds/, with its registry dependencies and npm packages. See Installation for the one-time setup.
bunx shadcn@3 add @fastrp/meter

Examples

Default

Open alone
Can72
Zırh60
Enerji45
Susuzluk14
Yakıt31 L

API

<Meter />

A HUD bar: health, stamina, fuel, a skill's progress. A thin pill that glows in its colour, optionally cut into cells, blinking when it runs low.

PropTypeDefaultNotes
value*number—
iconReactNode—
labelReactNode—Over the bar, left: "CAN", "DAYANIKLILIK".
maxnumber100
segmentsnumber—Split the bar into this many cells, like a HUD's armour.
size"sm" | "md"md
tone"primary" | "danger" | "success" | "warning" | "pink" | "teal" | "purple" | "cyan"primary
valueLabelReactNode—Over the bar, right; the rounded percentage when left out. `null` hides it.
warnBelownumber—Under this share (0–1), the fill blinks: low health, low fuel.

Dependencies

npm

  • None

Registry

  • @fastrp/utils

Source

components/ds/meter.tsxShow
import type * as React from 'react';
import { cn } from '@/lib/utils';

// @fast-ds [email protected]

/** Fill and glow per tone — written out so Tailwind finds every class. */
const TONE = {
  primary: 'from-primary/70 to-primary shadow-[0_0_0.5rem_hsl(var(--primary)/0.55)]',
  success: 'from-success/70 to-success shadow-[0_0_0.5rem_hsl(var(--success)/0.55)]',
  danger: 'from-danger/70 to-danger shadow-[0_0_0.5rem_hsl(var(--danger)/0.55)]',
  warning: 'from-warning/70 to-warning shadow-[0_0_0.5rem_hsl(var(--warning)/0.55)]',
  pink: 'from-pink/70 to-pink shadow-[0_0_0.5rem_hsl(var(--pink)/0.55)]',
  teal: 'from-teal/70 to-teal shadow-[0_0_0.5rem_hsl(var(--teal)/0.55)]',
  purple: 'from-purple/70 to-purple shadow-[0_0_0.5rem_hsl(var(--purple)/0.55)]',
  cyan: 'from-cyan/70 to-cyan shadow-[0_0_0.5rem_hsl(var(--cyan)/0.55)]',
} as const;

export type MeterProps = Omit<React.ComponentProps<'div'>, 'children'> & {
  value: number;
  max?: number;
  tone?: keyof typeof TONE;
  /** Over the bar, left: "CAN", "DAYANIKLILIK". */
  label?: React.ReactNode;
  /** Over the bar, right; the rounded percentage when left out. `null` hides it. */
  valueLabel?: React.ReactNode | null;
  icon?: React.ReactNode;
  /** Split the bar into this many cells, like a HUD's armour. */
  segments?: number;
  /** Under this share (0–1), the fill blinks: low health, low fuel. */
  warnBelow?: number;
  size?: 'sm' | 'md';
};

/**
 * A HUD bar: health, stamina, fuel, a skill's progress. A thin pill that glows in its colour,
 * optionally cut into cells, blinking when it runs low.
 */
function Meter({
  value,
  max = 100,
  tone = 'primary',
  label,
  valueLabel,
  icon,
  segments,
  warnBelow,
  size = 'md',
  className,
  ...props
}: MeterProps) {
  const share = max > 0 ? Math.min(1, Math.max(0, value / max)) : 0;
  const low = warnBelow !== undefined && share < warnBelow;
  const step = segments ? 100 / segments : 0;

  return (
    // biome-ignore lint/a11y/useSemanticElements: a native <meter> cannot hold the label and the drawn fill
    <div
      data-slot="meter"
      role="meter"
      aria-valuenow={value}
      aria-valuemin={0}
      aria-valuemax={max}
      aria-label={typeof label === 'string' ? label : undefined}
      className={cn('w-full', className)}
      {...props}
    >
      {(label || icon || valueLabel !== null) && (
        <div className="mb-1.5 flex items-center justify-between gap-3 font-display text-label uppercase">
          <span className="flex items-center gap-1.5 text-foreground/80 [&_svg]:size-3.5">
            {icon}
            {label}
          </span>
          {valueLabel !== null && (
            <span className="tabular-nums text-foreground">
              {valueLabel ?? Math.round(share * 100)}
            </span>
          )}
        </div>
      )}
      <div
        className={cn(
          'relative overflow-hidden rounded-full bg-foreground/15',
          size === 'sm' ? 'h-1' : 'h-1.5',
        )}
      >
        <div
          className={cn(
            'h-full rounded-full bg-gradient-to-r transition-[width] duration-slow ease-brand',
            TONE[tone],
            low && 'animate-blink',
          )}
          style={{ width: `${share * 100}%` }}
        />
        {step > 0 && (
          <div
            aria-hidden
            className="absolute inset-0"
            // Gaps as a share of the bar, so nothing here is in pixels.
            style={{
              backgroundImage: `repeating-linear-gradient(to right, transparent 0 ${step - 0.8}%, hsl(var(--ink)) ${step - 0.8}% ${step}%)`,
            }}
          />
        )}
      </div>
    </div>
  );
}

export { Meter };