Skip to content
Andrew SmithSolutions
← Component library

Progress

Feedback

A determinate bar and a correct indeterminate one in the same component, because the difference between them is one attribute and getting it wrong misreports the job.

role=progressbarIndeterminateReduced motionCSS only — no dependency

Preview

Importing bookings

50%

Controls

Notes

Indeterminate omits aria-valuenow entirely. That omission is the signal — progress is happening, the amount is unknown. A bar that animates while reporting aria-valuenow="0" tells a screen reader the job is stuck.

The bar is labelled by the visible caption through aria-labelledby rather than given a duplicate aria-label, which is one more string to drift out of step.

Under reduced motion the indeterminate bar breathes in place instead of travelling. It cannot simply stop: a still indeterminate bar reads as frozen, which is the opposite of what it means.

Determinate width is animated with a transition rather than a keyframe, so a value that arrives mid-animation moves from where the bar actually is instead of snapping back.

Source

Show
"use client";

import { useId } from "react";
import { cn } from "@/lib/cn";

/**
 * Determinate and indeterminate in one component, because the difference is
 * one attribute and getting it wrong is a real lie to assistive tech.
 *
 * Indeterminate OMITS aria-valuenow entirely. That omission is the signal:
 * "progress is happening, the amount is unknown". A bar that animates while
 * reporting aria-valuenow="0" tells a screen reader the job is stuck.
 */
const css = `
@keyframes progress-slide {
  from { translate: -60% 0; }
  to { translate: 160% 0; }
}
@keyframes progress-breathe {
  0%, 100% { opacity: 0.3; }
  50% { opacity: 0.85; }
}
.progress-indeterminate {
  inline-size: 40%;
  animation: progress-slide 1.4s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
/* It cannot simply stop — a still indeterminate bar reads as frozen. So it
   breathes in place instead of travelling. */
@media (prefers-reduced-motion: reduce) {
  .progress-indeterminate {
    inline-size: 100%;
    translate: 0;
    animation: progress-breathe 2.4s ease-in-out infinite;
  }
}
`;

const sizes = { sm: "h-1", md: "h-2" } as const;

export type ProgressSize = keyof typeof sizes;

export function Progress({
  label,
  value = 0,
  indeterminate = false,
  showValue = true,
  size = "md",
  className,
}: {
  label: string;
  /** 0–100. Ignored when indeterminate. */
  value?: number;
  indeterminate?: boolean;
  showValue?: boolean;
  size?: ProgressSize;
  className?: string;
}) {
  const id = useId();
  const clamped = Math.min(100, Math.max(0, value));

  return (
    <div className={cn("w-full", className)}>
      <style href="progress-bar" precedence="default">
        {css}
      </style>

      <div className="flex items-baseline justify-between gap-4">
        <p id={id} className="text-[13px] text-ink-muted">
          {label}
        </p>
        {showValue && (
          <p className="font-mono text-[11px] tabular-nums text-ink-faint">
            {indeterminate ? "Working" : `${clamped}%`}
          </p>
        )}
      </div>

      <div
        role="progressbar"
        // Labelled by the caption above rather than given a duplicate
        // aria-label that would drift from it.
        aria-labelledby={id}
        aria-valuemin={0}
        aria-valuemax={100}
        aria-valuenow={indeterminate ? undefined : clamped}
        className={cn(
          "mt-2 overflow-hidden rounded-full bg-paper-sunken",
          sizes[size],
        )}
      >
        <div
          className={cn(
            "h-full rounded-full bg-accent",
            indeterminate ? "progress-indeterminate" : "transition-[width] duration-500 ease-out",
          )}
          style={indeterminate ? undefined : { width: `${clamped}%` }}
        />
      </div>
    </div>
  );
}

export default function Demo({
  value = "60",
  indeterminate = false,
  showValue = true,
  size = "md",
}: {
  value?: string;
  indeterminate?: boolean;
  showValue?: boolean;
  size?: ProgressSize;
}) {
  return (
    <div className="w-full max-w-sm">
      <Progress
        label={indeterminate ? "Reconciling bookings" : "Importing bookings"}
        value={Number(value) || 0}
        indeterminate={indeterminate}
        showValue={showValue}
        size={size}
      />
    </div>
  );
}

Next step

Tell me where the business is losing time.

No pitch deck and no discovery-call funnel. Describe the bottleneck and I will tell you plainly whether it is worth building something for, and roughly what that would take.