component — feedback

Signal Bars

A discrete level on an absolute scale — connection quality, confidence, load — drawn in four or five steps with the unfilled ones left in place. Shares its geometry with Activity Pulse so the pair reads as the question that chooses between them: a level, or a rhythm?

  • level
  • signal
  • strength
  • confidence
  • indicator
  • feedback

04 examples

A level, in four steps

Filled from the left, with the unfilled steps left in place so the reader can see what the level is out of. The visible text is not decoration — it is the text equivalent, and with it present the graphic steps back out of the accessibility tree rather than being announced twice.

eu-central-1Excellent
us-east-1Good
ap-south-1Degraded
sa-east-1Poor
af-south-1No connection

Without visible text

With no children the graphic keeps a generated `role="img"` label ("3 of 4"), so it still has a text equivalent. Pass `label` to say what the level is *of* — nothing else on the page may be saying it.

Five steps, and sizes

`steps` is the scale, not a style: five is common for a confidence score. The three sizes move height, bar width and gap together.

A level, or a rhythm?

The two components share a geometry so that the question which chooses between them is the only thing you have to answer. Signal Bars is a level: absolute, no time in it, the same picture a second later — as the top row is. Activity Pulse is a rhythm: a self-normalising window over the last five seconds, which is why it carries no number and never claims to.

SignalBars — a level, out of four
ActivityPulse — a rhythm, out of nothing

source

signal-bars.tsx

Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.

import type { ReactNode } from "react"
import { cn } from "@/lib/utils"

/**
 * SignalBars — quebi design system
 *
 * A discrete level on an absolute scale: connection quality, confidence, load.
 * Four steps by default, filled from the left, with the unfilled steps left in
 * place so the reader can see what the level is *out of*.
 *
 * It shares its bar geometry with `ActivityPulse` on purpose, because that pair
 * is the thing worth being able to tell apart at a glance: **is this value a
 * level, or a rhythm?** SignalBars is a level — an absolute scale with no time
 * in it, the same picture a second later if nothing changed. ActivityPulse is a
 * rhythm — a self-normalising window over the last few seconds, which is why it
 * carries no number and never claims to. The bars look alike; the reading is
 * the difference, and the two components are named for it.
 *
 * ## Colour is never the signal
 *
 * The level is in the *height* of the filled bars, so it survives a monochrome
 * render, a colour-vision deficiency and forced-colors mode. On top of that the
 * graphic always carries a text equivalent: pass `children` for a visible one
 * (the graphic then steps back and is `aria-hidden`, so a screen reader hears
 * the label once rather than twice) or let it fall back to the generated
 * `role="img"` label.
 *
 * @example
 * <SignalBars value={3} tone="level">Good</SignalBars>
 * <SignalBars value={1} steps={5} label="Model confidence: 1 of 5" />
 */

export type SignalBarsSize = "sm" | "md" | "lg"

/**
 * `brand` inherits `currentColor` (ink by default) — the caller decides, which is right when the
 * level means "how much" rather than "how bad". `level` maps the fraction onto
 * the quebi status tokens, for the cases where a low value genuinely is a
 * problem (a dying connection, a struggling worker).
 */
export type SignalBarsTone = "brand" | "level"

interface SizeSpec {
  height: number
  bar: number
  gap: number
}

const SIZES: Record<SignalBarsSize, SizeSpec> = {
  sm: { height: 10, bar: 2, gap: 1 },
  md: { height: 14, bar: 3, gap: 2 },
  lg: { height: 20, bar: 4, gap: 2 },
}

/** The shortest bar is 40% of the tallest: a level of 1 must still read as a bar. */
const SHORTEST = 0.4

export interface SignalBarsProps extends Omit<React.ComponentProps<"span">, "children"> {
  /** How many steps are filled. Clamped into `0…steps`. */
  value: number
  /** How many steps there are. Four is the phone-signal default; five is common for confidence. */
  steps?: number
  size?: SignalBarsSize
  tone?: SignalBarsTone
  /** The accessible name. Defaults to `"<value> of <steps>"`. */
  label?: string
  /** A visible text equivalent, rendered after the bars. */
  children?: ReactNode
}

/** `level` in three bands, so the token does not depend on how many steps there are. */
function levelTone(fraction: number): string {
  if (fraction <= 1 / 3) return "text-quebi-danger"
  if (fraction <= 2 / 3) return "text-quebi-warn"
  return "text-quebi-success"
}

export function SignalBars({
  value,
  steps = 4,
  size = "md",
  tone = "brand",
  label,
  children,
  className,
  ...props
}: SignalBarsProps) {
  const spec = SIZES[size]
  const total = Math.max(1, Math.round(steps))
  const filled = Math.min(total, Math.max(0, Math.round(value)))
  const fraction = filled / total

  // With a visible label beside it the graphic is a second copy of the same
  // fact, so it stops being announced rather than being announced twice.
  const hasVisibleText = children !== undefined && children !== null

  return (
    <span
      {...props}
      data-slot="signal-bars"
      className={cn("inline-flex items-center gap-1.5", className)}
    >
      <span
        data-slot="signal-bars-graphic"
        data-value={filled}
        data-steps={total}
        {...(hasVisibleText
          ? { "aria-hidden": true as const }
          : { role: "img", "aria-label": label ?? `${filled} of ${total}` })}
        className={cn("flex shrink-0 items-end", tone === "level" && levelTone(fraction))}
        style={{ height: spec.height, gap: spec.gap }}
      >
        {Array.from({ length: total }, (_, index) => (
          <span
            // A step is a fixed position on a fixed scale — there is nothing
            // else to key it by, and nothing about it moves.
            // biome-ignore lint/suspicious/noArrayIndexKey: the steps are positions on an absolute scale.
            key={index}
            data-slot="signal-bars-step"
            data-filled={index < filled ? "true" : undefined}
            className={cn(
              "shrink-0 bg-current transition-opacity duration-200 motion-reduce:transition-none",
              index < filled ? "opacity-100" : "opacity-20",
            )}
            style={{
              width: spec.bar,
              height: Math.round(
                spec.height * (SHORTEST + (1 - SHORTEST) * ((index + 1) / total)),
              ),
            }}
          />
        ))}
      </span>
      {hasVisibleText ? <span data-slot="signal-bars-label">{children}</span> : null}
    </span>
  )
}