UI

Progress Bars

A minimal textured bar chart with vertical and horizontal layouts, shipping a line fill and a dot matrix fill. Each item needs only a value, with an optional label and tooltip.

Props

Orientation
Max100
Height240px
ScaleAxis and guide lines
ValuesPercentage under each bar
TooltipsDetail line on hover and focus

Installation

Progress Bars is published as a shadcn registry item. The CLI drops the file into your project, installs its dependencies, and adds any primitives it relies on.

$npx shadcn@latest add @akoder/progress-bars

The @akoder namespace ships with the shadcn CLI, so there is nothing to configure first. Install any other component the same way from the Components page.

Usage

import { ProgressBars } from "@/components/ui/progress-bars";
 
const milestones = [
  { id: "dashboard", label: "dashboard", value: 82 },
  { id: "api", label: "api", value: 64 },
  { id: "launch", label: "launch", value: 49 },
];
 
export function MilestoneProgress() {
  return <ProgressBars items={milestones} />;
}

An item only needs a value. label and tooltip are optional.

Horizontal rows

<ProgressBars items={milestones} orientation="horizontal" />

Dot variant

DotProgress takes exactly the same props and draws the same bars, except the completed portion is filled with a matrix of dots instead of etched rules.

import { DotProgress } from "@/components/ui/progress-bars";
 
<DotProgress items={milestones} />
<DotProgress items={milestones} orientation="horizontal" />

Props

ProgressBars

PropTypeDefaultNotes
items RequiredProgressBarItem[]-Each item has id and value, plus optional label and tooltip.
orientation"vertical" | "horizontal""vertical"Vertical draws a scaled chart; horizontal draws compact rows.
maxnumber100Largest possible value. Values outside 0..max are clipped.
showScalebooleantrueScale and guide lines, vertical orientation only.
showValuesbooleantrueFormatted value beside or above each bar.
showTooltipbooleantrueHover and keyboard-focus tooltips.
formatValue(value, max) => stringpercentageFormats values and scale labels.
heightnumber240Vertical plot height in pixels.
animatebooleantrueGrows bars once when they enter the viewport.
ariaLabelstring"progress bars"Accessible name for the chart.
classNamestringโ€”Extra classes for the root.

DotProgress accepts the identical prop set; only the fill texture differs.

Notes & features

  • Chart only. No card, heading, or summary strip is included. Compose those around it.
  • Textured fill, outlined remainder. Only the completed portion carries the fill โ€” rules for ProgressBars, dots for DotProgress. The rest of the track stays empty behind a hairline outline. Both textures are anchored to the bottom left so the pattern holds still as a bar grows, and both use a token color with a literal fallback so they render outside this site.
  • Keyboard and tooltips. Each bar or dot row takes focus and exposes its label and formatted value to assistive technology.
  • Reduced motion. Motion is skipped and final sizes render immediately when the operating system asks for reduced motion.
  • Responsive. Vertical columns keep a readable width and scroll horizontally rather than collapsing.

Setup notes

Mount shadcn's TooltipProvider once near your application root, otherwise the value tooltips never open.

Manual installation

Rather not use the CLI? Everything Progress Bars needs is below. Install its dependencies, then copy the file into the matching path in your project.

Dependencies

$npm install motion radix-ui

shadcn primitives

$npx shadcn@latest add tooltip

Source

components/ui/progress-bars.tsx280 lines
"use client";

import { motion, useReducedMotion } from "motion/react";
import type { CSSProperties, ReactNode } from "react";

import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip";

export interface ProgressBarItem {
  id: string;
  /** Optional caption rendered next to the bar. */
  label?: string;
  value: number;
  /** Extra detail line shown inside the tooltip. */
  tooltip?: ReactNode;
}

export interface ProgressBarsProps {
  items: ProgressBarItem[];
  orientation?: "vertical" | "horizontal";
  /** Largest possible item value. */
  max?: number;
  /** Show the scale and guide lines in vertical orientation. */
  showScale?: boolean;
  showValues?: boolean;
  showTooltip?: boolean;
  formatValue?: (value: number, max: number) => string;
  /** Vertical plot height in pixels. */
  height?: number;
  animate?: boolean;
  ariaLabel?: string;
  className?: string;
}

/**
 * Fill textures. Both are anchored to the bottom left so the pattern stays put
 * while a bar grows, and both fall back to a literal color outside this site.
 */
const LINE_FILL: CSSProperties = {
  backgroundImage:
    "repeating-linear-gradient(to bottom, var(--foreground, #111111) 0, var(--foreground, #111111) 1px, transparent 1px, transparent 3px)",
  backgroundPosition: "bottom left",
};

const DOT_FILL: CSSProperties = {
  backgroundImage:
    "radial-gradient(circle at center, var(--foreground, #111111) 1px, transparent 1.3px)",
  backgroundSize: "5px 5px",
  backgroundPosition: "bottom left",
};

const SCALE_STEPS = [0, 25, 50, 75, 100];

const EASE = [0.22, 1, 0.36, 1] as const;

const PERCENT_FORMATTER = new Intl.NumberFormat(undefined, {
  style: "percent",
  maximumFractionDigits: 0,
});

function defaultFormatValue(value: number, max: number) {
  return PERCENT_FORMATTER.format(value / max);
}

function clampValue(value: number, max: number) {
  if (!Number.isFinite(value) || max <= 0) return 0;
  return Math.min(Math.max(value, 0), max);
}

function ItemTooltip({
  item,
  value,
  enabled,
  children,
}: {
  item: ProgressBarItem;
  value: string;
  enabled: boolean;
  children: ReactNode;
}) {
  if (!enabled) return <>{children}</>;

  return (
    <Tooltip>
      <TooltipTrigger asChild>{children}</TooltipTrigger>
      <TooltipContent side="top" sideOffset={8} className="max-w-56">
        <div className="space-y-0.5">
          <p className="font-medium">{item.label ? `${item.label} ยท ${value}` : value}</p>
          {item.tooltip ? <div className="text-muted-foreground">{item.tooltip}</div> : null}
        </div>
      </TooltipContent>
    </Tooltip>
  );
}

function BarChart({
  fill,
  items,
  orientation = "vertical",
  max = 100,
  showScale = true,
  showValues = true,
  showTooltip = true,
  formatValue = defaultFormatValue,
  height = 240,
  animate = true,
  ariaLabel,
  className,
}: ProgressBarsProps & { fill: CSSProperties; ariaLabel: string }) {
  const reducedMotion = useReducedMotion();
  const shouldAnimate = animate && !reducedMotion;
  const range = max > 0 ? max : 100;

  if (items.length === 0) return null;

  const fillClassName =
    "transition-opacity group-hover:opacity-70 group-focus-visible:opacity-70";
  const trackClassName = "overflow-hidden ring-1 ring-inset ring-border/70 rounded-lg";

  if (orientation === "horizontal") {
    return (
      <section aria-label={ariaLabel} className={cn("w-full space-y-3", className)} role="list">
        {items.map((item, index) => {
          const percentage = (clampValue(item.value, range) / range) * 100;
          const formatted = formatValue(item.value, range);

          return (
            <ItemTooltip key={item.id} item={item} value={formatted} enabled={showTooltip}>
              <div
                role="listitem"
                tabIndex={0}
                aria-label={item.label ? `${item.label}, ${formatted}` : formatted}
                className="group grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-4 gap-y-1.5 outline-none focus-visible:ring-1 focus-visible:ring-ring sm:grid-cols-[minmax(6rem,0.5fr)_minmax(0,1fr)_3rem]"
              >
                {item.label ? <p className="truncate text-sm leading-tight">{item.label}</p> : <span />}
                {showValues ? (
                  <span className="col-start-2 row-start-1 text-right text-xs tabular-nums sm:col-start-3">
                    {formatted}
                  </span>
                ) : null}
                <div
                  role="progressbar"
                  aria-valuenow={clampValue(item.value, range)}
                  aria-valuemin={0}
                  aria-valuemax={range}
                  aria-valuetext={formatted}
                  aria-label={item.label || ariaLabel}
                  className={cn("col-span-2 h-7 sm:col-span-1 sm:col-start-2 sm:row-start-1", trackClassName)}
                >
                  <motion.span
                    className={cn("block h-full", fillClassName)}
                    style={fill}
                    initial={shouldAnimate ? { width: "0%" } : false}
                    whileInView={{ width: `${percentage}%` }}
                    viewport={{ once: true, amount: 0.35 }}
                    transition={{
                      duration: shouldAnimate ? 0.3 : 0,
                      delay: shouldAnimate ? index * 0.05 : 0,
                      ease: EASE,
                    }}
                  />
                </div>
              </div>
            </ItemTooltip>
          );
        })}
      </section>
    );
  }

  return (
    <section aria-label={ariaLabel} className={cn("w-full", className)}>
      <div className={cn("grid min-w-0 gap-x-3", showScale ? "grid-cols-[2.5rem_minmax(0,1fr)]" : "grid-cols-1")}>
        {showScale ? (
          <div
            aria-hidden="true"
            className="relative mt-7 text-right font-mono text-[10px] tabular-nums text-muted-foreground"
            style={{ height }}
          >
            {SCALE_STEPS.map((step) => (
              <span
                key={step}
                className={cn("absolute right-0 leading-none", step === 0 ? "translate-y-1/2" : "-translate-y-1/2")}
                style={{ bottom: `${step}%` }}
              >
                {formatValue((step / 100) * range, range)}
              </span>
            ))}
          </div>
        ) : null}

        {/* Value labels sit above each fill. The plot uses fractional grid tracks
            instead of fixed-width bars so it always fits its container. */}
        <div className="min-w-0 pt-7 pb-1">
          <div className="relative min-w-0">
            <div aria-hidden="true" className="absolute inset-x-0 top-0" style={{ height }}>
              {SCALE_STEPS.filter((step) => step > 0).map((step) => (
                <span
                  key={step}
                  className="absolute inset-x-0  border-dashed border-border/80"
                  style={{ bottom: `${step}%` }}
                />
              ))}
            </div>

            <div
              className="relative grid min-w-0 gap-x-1.5 sm:gap-x-3"
              role="list"
              style={{ gridTemplateColumns: `repeat(${items.length}, minmax(0, 1fr))` }}
            >
              {items.map((item, index) => {
                const percentage = (clampValue(item.value, range) / range) * 100;
                const formatted = formatValue(item.value, range);

                return (
                  <ItemTooltip key={item.id} item={item} value={formatted} enabled={showTooltip}>
                    <div
                      role="listitem"
                      tabIndex={0}
                      aria-label={item.label ? `${item.label}, ${formatted}` : formatted}
                      className="group min-w-0 outline-none focus-visible:ring-1 focus-visible:ring-ring"
                    >
                      <div className="relative" style={{ height }}>
                        {showValues ? (
                          <span
                            className="absolute inset-x-0 -translate-y-1.5 truncate text-center text-[10px] tabular-nums sm:text-xs"
                            style={{ bottom: `${percentage}%` }}
                          >
                            {formatted}
                          </span>
                        ) : null}
                        <div
                          role="progressbar"
                          aria-valuenow={clampValue(item.value, range)}
                          aria-valuemin={0}
                          aria-valuemax={range}
                          aria-valuetext={formatted}
                          aria-label={item.label || ariaLabel}
                          className={cn("absolute inset-x-px bottom-0 top-0 sm:inset-x-1.5", trackClassName)}
                        >
                          <motion.span
                            className={cn("absolute inset-x-0 bottom-0", fillClassName)}
                            style={fill}
                            initial={shouldAnimate ? { height: "0%" } : false}
                            whileInView={{ height: `${percentage}%` }}
                            viewport={{ once: true, amount: 0.35 }}
                            transition={{
                              duration: shouldAnimate ? 0.3 : 0,
                              delay: shouldAnimate ? index * 0.05 : 0,
                              ease: EASE,
                            }}
                          />
                        </div>
                      </div>
                      {item.label ? (
                        <p className="mt-3 truncate pt-3 text-center text-xs leading-tight">
                          {item.label}
                        </p>
                      ) : null}
                    </div>
                  </ItemTooltip>
                );
              })}
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

/** Bars filled with etched horizontal rules. */
export function ProgressBars({ ariaLabel = "Progress bars", ...props }: ProgressBarsProps) {
  return <BarChart {...props} fill={LINE_FILL} ariaLabel={ariaLabel} />;
}

/** The same bars filled with a matrix of dots instead of rules. */
export function DotProgress({ ariaLabel = "Dot progress", ...props }: ProgressBarsProps) {
  return <BarChart {...props} fill={DOT_FILL} ariaLabel={ariaLabel} />;
}