Stage Bars

Pipeline stage progress card.

Stages

Pipeline stage progress.

Pipeline

1,180

+2.4%
function StageBarsDemo() {
  return <StageBarsCard stages={STAGE_BARS_STAGES} ranges={STAGE_BARS_RANGES} />;
}
function StageBarsDemo() {
  return <StageBarsCard stages={STAGE_BARS_STAGES} ranges={STAGE_BARS_RANGES} />;
}

Installation

npx shadcn@latest add https://boardcn.dev/r/stage-bars-card.json
npx shadcn@latest add https://boardcn.dev/r/stage-bars-card.json

BoardCN dependencies

The CLI installs these for you — you do not need to add them yourself.

Source

The file the CLI copies into your project.

components/charts/stage-bars-card.tsx
"use client";

import {
  useEffect,
  useState,
  type HTMLAttributes,
  type KeyboardEvent,
  type ReactNode,
} from "react";
import { Chip } from "@/components/base/badges/chip";
import { ChartRangeControl } from "@/components/charts/chart-range-control";
import { useCountUp, usePrefersReducedMotion } from "@/hooks/use-count-up";
import { cx } from "@/utils/cx";

export interface StageBarsStage {
  label: string;
  value: number;
  /** Any CSS color. The BoardCN chart palette is used when omitted. */
  color?: string;
  /** Hover/focus color. A custom color is darkened when omitted. */
  activeColor?: string;
  /** Optional 14px icon rendered at the left edge of the stage pill. */
  icon?: ReactNode;
}

export interface StageBarsRange {
  id: string;
  label: string;
  stages: readonly StageBarsStage[];
  /** Decimal change, for example `0.024` renders as `+2.4%`. */
  delta?: number;
  /** Resting headline. Defaults to the first stage's value. */
  headline?: number;
}

export interface StageBarsCardProps
  extends Omit<HTMLAttributes<HTMLElement>, "title"> {
  title?: string;
  stages: readonly StageBarsStage[];
  mono?: boolean;
  showIcons?: boolean;
  headline?: number;
  delta?: number;
  /** Static period pill. `ranges` takes precedence when both are supplied. */
  range?: string;
  ranges?: readonly StageBarsRange[];
  defaultRange?: string;
  onRangeChange?: (rangeId: string) => void;
  format?: (value: number) => string;
}

type Tone = { color: string; activeColor: string };

const PALETTE_ORDER = [2, 6, 5, 3, 8, 7, 4, 1] as const;
const PALETTE: readonly Tone[] = PALETTE_ORDER.map((tone) => ({
  color: `var(--color-chart-${tone})`,
  activeColor: `var(--color-chart-${tone}-active)`,
}));

const MONO_TONE: Tone = {
  color:
    "light-dark(var(--color-neutral-500), color-mix(in srgb, var(--color-neutral-50) 84%, transparent))",
  activeColor: "light-dark(var(--color-neutral-600), var(--color-neutral-50))",
};

const OPACITY_TRANSITION =
  "transition-opacity duration-200 ease-out motion-reduce:transition-none";

export function formatStageBarsNumber(value: number): string {
  return value.toLocaleString("en-US");
}

function resolveTone(stage: StageBarsStage, index: number, mono: boolean): Tone {
  if (mono) return MONO_TONE;
  if (stage.color) {
    return {
      color: stage.color,
      activeColor:
        stage.activeColor ??
        `color-mix(in srgb, ${stage.color} 82%, black)`,
    };
  }
  return PALETTE[index % PALETTE.length];
}

function describeDelta(delta: number): {
  label: string;
  color: "neutral" | "lime" | "rose";
} {
  const percentage = Math.round(Math.abs(delta) * 1000) / 10;
  if (percentage === 0) return { label: "0.0%", color: "neutral" };
  return {
    label: `${delta > 0 ? "+" : "-"}${percentage}%`,
    color: delta > 0 ? "lime" : "rose",
  };
}

function useAnimatedNumber(value: number): number {
  const precision = Number.isInteger(value)
    ? 0
    : Math.abs(value * 10 - Math.round(value * 10)) < 0.000001
      ? 1
      : 2;
  const multiplier = 10 ** precision;
  return useCountUp(Math.round(value * multiplier)) / multiplier;
}

function activateWithKeyboard(
  event: KeyboardEvent<HTMLElement>,
  activate: () => void,
  clear: () => void,
) {
  if (event.key === "Enter" || event.key === " ") {
    event.preventDefault();
    activate();
  } else if (event.key === "Escape") {
    event.preventDefault();
    clear();
    event.currentTarget.blur();
  }
}

/**
 * BoardCN's stage funnel rendered as rows of animated rounded bars and a
 * three-column stat-tile grid. Hovering or focusing either representation
 * follows the active stage in the headline and dims the remaining stages.
 */
export function StageBarsCard({
  title = "Pipeline",
  stages,
  mono = false,
  showIcons = true,
  headline,
  delta,
  range,
  ranges,
  defaultRange,
  onRangeChange,
  format = formatStageBarsNumber,
  className,
  ...props
}: StageBarsCardProps) {
  const [activeIndex, setActiveIndex] = useState<number | null>(null);
  const [selectedRangeId, setSelectedRangeId] = useState(defaultRange);
  const reducedMotion = usePrefersReducedMotion();
  const [revealed, setRevealed] = useState(reducedMotion);

  const availableRanges = ranges;
  const selectedRange =
    availableRanges?.find((item) => item.id === selectedRangeId) ??
    availableRanges?.[0];
  const resolvedStages = selectedRange?.stages ?? stages;
  const resolvedHeadline = selectedRange?.headline ?? headline;
  const resolvedDelta = selectedRange?.delta ?? delta;
  const tones = resolvedStages.map((stage, index) =>
    resolveTone(stage, index, mono),
  );
  const topValue = Math.max(1, resolvedStages[0]?.value ?? 1);
  const hasActiveStage = activeIndex !== null && activeIndex < resolvedStages.length;
  const headerLabel = hasActiveStage ? resolvedStages[activeIndex].label : title;
  const headerValue = hasActiveStage
    ? resolvedStages[activeIndex].value
    : (resolvedHeadline ?? topValue);
  const animatedHeadline = useAnimatedNumber(headerValue);
  const deltaDescription =
    resolvedDelta === undefined ? undefined : describeDelta(resolvedDelta);
  useEffect(() => {
    if (reducedMotion) {
      setRevealed(true);
      return;
    }
    const timer = window.setTimeout(() => setRevealed(true), 60);
    return () => window.clearTimeout(timer);
  }, [reducedMotion]);

  function selectRange(id: string) {
    setActiveIndex(null);
    setSelectedRangeId(id);
    onRangeChange?.(id);
  }

  return (
    <section
      className={cx(
        "flex h-auto min-h-[329px] w-full min-w-0 flex-col gap-4 rounded-2xl bg-background-secondary-default px-4 pt-4 pb-3",
        className,
      )}
      aria-label={`${title} stage bars`}
      {...props}
    >
      <div className="flex w-full items-start justify-between gap-3">
        <div className="flex min-w-0 flex-1 flex-col gap-0.5">
          <p className="w-full truncate text-body-medium text-text-secondary">
            {headerLabel}
          </p>
          <div className="flex w-full items-center gap-2">
            <p
              key={`${selectedRange?.id ?? ""}:${activeIndex}`}
              className="animate-number-fade text-title-1-medium whitespace-nowrap text-text-primary tabular-nums motion-reduce:animate-none"
              aria-live="polite"
            >
              {format(animatedHeadline)}
            </p>
            {deltaDescription && (
              <Chip
                variant="bold"
                color={deltaDescription.color}
                className={hasActiveStage ? "invisible" : undefined}
              >
                {deltaDescription.label}
              </Chip>
            )}
          </div>
        </div>

        {availableRanges?.length ? (
          <ChartRangeControl
            ranges={availableRanges}
            value={selectedRange?.id}
            onValueChange={selectRange}
          />
        ) : range ? (
          <ChartRangeControl label={range} />
        ) : null}
      </div>

      <div
        className="grid min-h-0 w-full flex-1 content-center items-center gap-x-3 gap-y-3 py-2"
        style={{
          gridTemplateColumns: "auto minmax(0, 1fr) auto",
          gridTemplateRows: `repeat(${Math.max(1, resolvedStages.length)}, auto)`,
        }}
        role="list"
        aria-label={`${title} stages`}
      >
        {resolvedStages.map((stage, index) => {
          const active = activeIndex === index;
          const dimmed = hasActiveStage && !active;
          const share = Math.round((stage.value / topValue) * 100);
          return (
            <div
              key={`${stage.label}-${index}`}
              className="group contents outline-none"
              role="listitem"
              tabIndex={0}
              aria-label={`${stage.label}: ${format(stage.value)}, ${share}%`}
              onMouseEnter={() => setActiveIndex(index)}
              onMouseLeave={() => setActiveIndex(null)}
              onFocus={() => setActiveIndex(index)}
              onBlur={() => setActiveIndex(null)}
              onKeyDown={(event) =>
                activateWithKeyboard(
                  event,
                  () => setActiveIndex(index),
                  () => setActiveIndex(null),
                )
              }
            >
              <span
                className={cx(
                  "text-right text-body-regular whitespace-nowrap text-text-secondary",
                  OPACITY_TRANSITION,
                )}
                style={{ opacity: dimmed ? 0.35 : 1 }}
                aria-hidden
              >
                {stage.label}
              </span>
              <div
                className={cx(
                  "relative h-5 min-w-0 overflow-hidden rounded-full bg-chart-track ring-offset-1 ring-offset-background-secondary-default group-focus-visible:ring-2 group-focus-visible:ring-border-focus-ring",
                  OPACITY_TRANSITION,
                )}
                style={{ opacity: dimmed ? 0.35 : 1 }}
                aria-hidden
              >
                <div
                  className="absolute inset-y-0 left-0 rounded-full transition-[width,background-color] duration-500 ease-out motion-reduce:transition-none"
                  data-testid={`stage-bar-${stage.label}`}
                  style={{
                    width: revealed
                      ? `${Math.max(2, (stage.value / topValue) * 100)}%`
                      : 0,
                    backgroundColor: active
                      ? tones[index].activeColor
                      : tones[index].color,
                  }}
                />
                {showIcons && stage.icon && (
                  <span className="pointer-events-none absolute inset-y-0 left-1 flex items-center">
                    {stage.icon}
                  </span>
                )}
              </div>
              <span
                className={cx(
                  "flex items-baseline justify-end gap-1.5 whitespace-nowrap",
                  OPACITY_TRANSITION,
                )}
                style={{ opacity: dimmed ? 0.35 : 1 }}
                aria-hidden
              >
                <span className="text-body-medium text-text-primary tabular-nums">
                  {format(stage.value)}
                </span>
                <span className="text-caption-1-medium text-text-tertiary tabular-nums">
                  {share}%
                </span>
              </span>
            </div>
          );
        })}
      </div>

      <div className="-mx-2 -mb-1 grid grid-cols-3 gap-2" role="list">
        {resolvedStages.map((stage, index) => {
          const active = activeIndex === index;
          const dimmed = hasActiveStage && !active;
          return (
            <div
              key={`${stage.label}-${index}`}
              className="flex min-w-0 flex-col items-start gap-px rounded-2lg bg-background-inner-default px-2.5 py-2 outline-none ring-offset-1 ring-offset-background-secondary-default transition-opacity duration-200 ease-out focus-visible:ring-2 focus-visible:ring-border-focus-ring motion-reduce:transition-none"
              style={{ opacity: dimmed ? 0.5 : 1 }}
              role="listitem"
              tabIndex={0}
              aria-label={`${stage.label}: ${format(stage.value)}`}
              onMouseEnter={() => setActiveIndex(index)}
              onMouseLeave={() => setActiveIndex(null)}
              onFocus={() => setActiveIndex(index)}
              onBlur={() => setActiveIndex(null)}
              onKeyDown={(event) =>
                activateWithKeyboard(
                  event,
                  () => setActiveIndex(index),
                  () => setActiveIndex(null),
                )
              }
            >
              <div className="flex min-w-0 max-w-full items-center gap-1.5" aria-hidden>
                {!mono && (
                  <span
                    className="size-3 shrink-0 rounded-[4px] transition-colors duration-150 ease-out motion-reduce:transition-none"
                    style={{
                      backgroundColor: active
                        ? tones[index].activeColor
                        : tones[index].color,
                    }}
                  />
                )}
                <span className="truncate text-body-regular text-text-secondary">
                  {stage.label}
                </span>
              </div>
              <span
                className="text-body-medium whitespace-nowrap text-text-primary tabular-nums"
                aria-hidden
              >
                {format(stage.value)}
              </span>
            </div>
          );
        })}
      </div>
    </section>
  );
}
"use client";

import {
  useEffect,
  useState,
  type HTMLAttributes,
  type KeyboardEvent,
  type ReactNode,
} from "react";
import { Chip } from "@/components/base/badges/chip";
import { ChartRangeControl } from "@/components/charts/chart-range-control";
import { useCountUp, usePrefersReducedMotion } from "@/hooks/use-count-up";
import { cx } from "@/utils/cx";

export interface StageBarsStage {
  label: string;
  value: number;
  /** Any CSS color. The BoardCN chart palette is used when omitted. */
  color?: string;
  /** Hover/focus color. A custom color is darkened when omitted. */
  activeColor?: string;
  /** Optional 14px icon rendered at the left edge of the stage pill. */
  icon?: ReactNode;
}

export interface StageBarsRange {
  id: string;
  label: string;
  stages: readonly StageBarsStage[];
  /** Decimal change, for example `0.024` renders as `+2.4%`. */
  delta?: number;
  /** Resting headline. Defaults to the first stage's value. */
  headline?: number;
}

export interface StageBarsCardProps
  extends Omit<HTMLAttributes<HTMLElement>, "title"> {
  title?: string;
  stages: readonly StageBarsStage[];
  mono?: boolean;
  showIcons?: boolean;
  headline?: number;
  delta?: number;
  /** Static period pill. `ranges` takes precedence when both are supplied. */
  range?: string;
  ranges?: readonly StageBarsRange[];
  defaultRange?: string;
  onRangeChange?: (rangeId: string) => void;
  format?: (value: number) => string;
}

type Tone = { color: string; activeColor: string };

const PALETTE_ORDER = [2, 6, 5, 3, 8, 7, 4, 1] as const;
const PALETTE: readonly Tone[] = PALETTE_ORDER.map((tone) => ({
  color: `var(--color-chart-${tone})`,
  activeColor: `var(--color-chart-${tone}-active)`,
}));

const MONO_TONE: Tone = {
  color:
    "light-dark(var(--color-neutral-500), color-mix(in srgb, var(--color-neutral-50) 84%, transparent))",
  activeColor: "light-dark(var(--color-neutral-600), var(--color-neutral-50))",
};

const OPACITY_TRANSITION =
  "transition-opacity duration-200 ease-out motion-reduce:transition-none";

export function formatStageBarsNumber(value: number): string {
  return value.toLocaleString("en-US");
}

function resolveTone(stage: StageBarsStage, index: number, mono: boolean): Tone {
  if (mono) return MONO_TONE;
  if (stage.color) {
    return {
      color: stage.color,
      activeColor:
        stage.activeColor ??
        `color-mix(in srgb, ${stage.color} 82%, black)`,
    };
  }
  return PALETTE[index % PALETTE.length];
}

function describeDelta(delta: number): {
  label: string;
  color: "neutral" | "lime" | "rose";
} {
  const percentage = Math.round(Math.abs(delta) * 1000) / 10;
  if (percentage === 0) return { label: "0.0%", color: "neutral" };
  return {
    label: `${delta > 0 ? "+" : "-"}${percentage}%`,
    color: delta > 0 ? "lime" : "rose",
  };
}

function useAnimatedNumber(value: number): number {
  const precision = Number.isInteger(value)
    ? 0
    : Math.abs(value * 10 - Math.round(value * 10)) < 0.000001
      ? 1
      : 2;
  const multiplier = 10 ** precision;
  return useCountUp(Math.round(value * multiplier)) / multiplier;
}

function activateWithKeyboard(
  event: KeyboardEvent<HTMLElement>,
  activate: () => void,
  clear: () => void,
) {
  if (event.key === "Enter" || event.key === " ") {
    event.preventDefault();
    activate();
  } else if (event.key === "Escape") {
    event.preventDefault();
    clear();
    event.currentTarget.blur();
  }
}

/**
 * BoardCN's stage funnel rendered as rows of animated rounded bars and a
 * three-column stat-tile grid. Hovering or focusing either representation
 * follows the active stage in the headline and dims the remaining stages.
 */
export function StageBarsCard({
  title = "Pipeline",
  stages,
  mono = false,
  showIcons = true,
  headline,
  delta,
  range,
  ranges,
  defaultRange,
  onRangeChange,
  format = formatStageBarsNumber,
  className,
  ...props
}: StageBarsCardProps) {
  const [activeIndex, setActiveIndex] = useState<number | null>(null);
  const [selectedRangeId, setSelectedRangeId] = useState(defaultRange);
  const reducedMotion = usePrefersReducedMotion();
  const [revealed, setRevealed] = useState(reducedMotion);

  const availableRanges = ranges;
  const selectedRange =
    availableRanges?.find((item) => item.id === selectedRangeId) ??
    availableRanges?.[0];
  const resolvedStages = selectedRange?.stages ?? stages;
  const resolvedHeadline = selectedRange?.headline ?? headline;
  const resolvedDelta = selectedRange?.delta ?? delta;
  const tones = resolvedStages.map((stage, index) =>
    resolveTone(stage, index, mono),
  );
  const topValue = Math.max(1, resolvedStages[0]?.value ?? 1);
  const hasActiveStage = activeIndex !== null && activeIndex < resolvedStages.length;
  const headerLabel = hasActiveStage ? resolvedStages[activeIndex].label : title;
  const headerValue = hasActiveStage
    ? resolvedStages[activeIndex].value
    : (resolvedHeadline ?? topValue);
  const animatedHeadline = useAnimatedNumber(headerValue);
  const deltaDescription =
    resolvedDelta === undefined ? undefined : describeDelta(resolvedDelta);
  useEffect(() => {
    if (reducedMotion) {
      setRevealed(true);
      return;
    }
    const timer = window.setTimeout(() => setRevealed(true), 60);
    return () => window.clearTimeout(timer);
  }, [reducedMotion]);

  function selectRange(id: string) {
    setActiveIndex(null);
    setSelectedRangeId(id);
    onRangeChange?.(id);
  }

  return (
    <section
      className={cx(
        "flex h-auto min-h-[329px] w-full min-w-0 flex-col gap-4 rounded-2xl bg-background-secondary-default px-4 pt-4 pb-3",
        className,
      )}
      aria-label={`${title} stage bars`}
      {...props}
    >
      <div className="flex w-full items-start justify-between gap-3">
        <div className="flex min-w-0 flex-1 flex-col gap-0.5">
          <p className="w-full truncate text-body-medium text-text-secondary">
            {headerLabel}
          </p>
          <div className="flex w-full items-center gap-2">
            <p
              key={`${selectedRange?.id ?? ""}:${activeIndex}`}
              className="animate-number-fade text-title-1-medium whitespace-nowrap text-text-primary tabular-nums motion-reduce:animate-none"
              aria-live="polite"
            >
              {format(animatedHeadline)}
            </p>
            {deltaDescription && (
              <Chip
                variant="bold"
                color={deltaDescription.color}
                className={hasActiveStage ? "invisible" : undefined}
              >
                {deltaDescription.label}
              </Chip>
            )}
          </div>
        </div>

        {availableRanges?.length ? (
          <ChartRangeControl
            ranges={availableRanges}
            value={selectedRange?.id}
            onValueChange={selectRange}
          />
        ) : range ? (
          <ChartRangeControl label={range} />
        ) : null}
      </div>

      <div
        className="grid min-h-0 w-full flex-1 content-center items-center gap-x-3 gap-y-3 py-2"
        style={{
          gridTemplateColumns: "auto minmax(0, 1fr) auto",
          gridTemplateRows: `repeat(${Math.max(1, resolvedStages.length)}, auto)`,
        }}
        role="list"
        aria-label={`${title} stages`}
      >
        {resolvedStages.map((stage, index) => {
          const active = activeIndex === index;
          const dimmed = hasActiveStage && !active;
          const share = Math.round((stage.value / topValue) * 100);
          return (
            <div
              key={`${stage.label}-${index}`}
              className="group contents outline-none"
              role="listitem"
              tabIndex={0}
              aria-label={`${stage.label}: ${format(stage.value)}, ${share}%`}
              onMouseEnter={() => setActiveIndex(index)}
              onMouseLeave={() => setActiveIndex(null)}
              onFocus={() => setActiveIndex(index)}
              onBlur={() => setActiveIndex(null)}
              onKeyDown={(event) =>
                activateWithKeyboard(
                  event,
                  () => setActiveIndex(index),
                  () => setActiveIndex(null),
                )
              }
            >
              <span
                className={cx(
                  "text-right text-body-regular whitespace-nowrap text-text-secondary",
                  OPACITY_TRANSITION,
                )}
                style={{ opacity: dimmed ? 0.35 : 1 }}
                aria-hidden
              >
                {stage.label}
              </span>
              <div
                className={cx(
                  "relative h-5 min-w-0 overflow-hidden rounded-full bg-chart-track ring-offset-1 ring-offset-background-secondary-default group-focus-visible:ring-2 group-focus-visible:ring-border-focus-ring",
                  OPACITY_TRANSITION,
                )}
                style={{ opacity: dimmed ? 0.35 : 1 }}
                aria-hidden
              >
                <div
                  className="absolute inset-y-0 left-0 rounded-full transition-[width,background-color] duration-500 ease-out motion-reduce:transition-none"
                  data-testid={`stage-bar-${stage.label}`}
                  style={{
                    width: revealed
                      ? `${Math.max(2, (stage.value / topValue) * 100)}%`
                      : 0,
                    backgroundColor: active
                      ? tones[index].activeColor
                      : tones[index].color,
                  }}
                />
                {showIcons && stage.icon && (
                  <span className="pointer-events-none absolute inset-y-0 left-1 flex items-center">
                    {stage.icon}
                  </span>
                )}
              </div>
              <span
                className={cx(
                  "flex items-baseline justify-end gap-1.5 whitespace-nowrap",
                  OPACITY_TRANSITION,
                )}
                style={{ opacity: dimmed ? 0.35 : 1 }}
                aria-hidden
              >
                <span className="text-body-medium text-text-primary tabular-nums">
                  {format(stage.value)}
                </span>
                <span className="text-caption-1-medium text-text-tertiary tabular-nums">
                  {share}%
                </span>
              </span>
            </div>
          );
        })}
      </div>

      <div className="-mx-2 -mb-1 grid grid-cols-3 gap-2" role="list">
        {resolvedStages.map((stage, index) => {
          const active = activeIndex === index;
          const dimmed = hasActiveStage && !active;
          return (
            <div
              key={`${stage.label}-${index}`}
              className="flex min-w-0 flex-col items-start gap-px rounded-2lg bg-background-inner-default px-2.5 py-2 outline-none ring-offset-1 ring-offset-background-secondary-default transition-opacity duration-200 ease-out focus-visible:ring-2 focus-visible:ring-border-focus-ring motion-reduce:transition-none"
              style={{ opacity: dimmed ? 0.5 : 1 }}
              role="listitem"
              tabIndex={0}
              aria-label={`${stage.label}: ${format(stage.value)}`}
              onMouseEnter={() => setActiveIndex(index)}
              onMouseLeave={() => setActiveIndex(null)}
              onFocus={() => setActiveIndex(index)}
              onBlur={() => setActiveIndex(null)}
              onKeyDown={(event) =>
                activateWithKeyboard(
                  event,
                  () => setActiveIndex(index),
                  () => setActiveIndex(null),
                )
              }
            >
              <div className="flex min-w-0 max-w-full items-center gap-1.5" aria-hidden>
                {!mono && (
                  <span
                    className="size-3 shrink-0 rounded-[4px] transition-colors duration-150 ease-out motion-reduce:transition-none"
                    style={{
                      backgroundColor: active
                        ? tones[index].activeColor
                        : tones[index].color,
                    }}
                  />
                )}
                <span className="truncate text-body-regular text-text-secondary">
                  {stage.label}
                </span>
              </div>
              <span
                className="text-body-medium whitespace-nowrap text-text-primary tabular-nums"
                aria-hidden
              >
                {format(stage.value)}
              </span>
            </div>
          );
        })}
      </div>
    </section>
  );
}

Props

Generated from the component's TypeScript types. Standard DOM and React Aria props are omitted.

StageBarsCard

BoardCN's stage funnel rendered as rows of animated rounded bars and a three-column stat-tile grid. Hovering or focusing either representation follows the active stage in the headline and dims the remaining stages.

PropTypeDefaultDescription
stagesrequiredreadonly StageBarsStage[]
defaultRangestring
deltanumber
format(value: number) => string
headlinenumber
monobooleanfalse
onRangeChange(rangeId: string) => void
rangestringStatic period pill. `ranges` takes precedence when both are supplied.
rangesreadonly StageBarsRange[]
showIconsbooleantrue
titlestringPipeline