component — feedback

Progress Bar

Accessible progress bar built on react-aria-components, styled with the quebi design system. A brand-teal fill over a cyan-tinted track, with optional header and value text, and a determinate or indeterminate state.

  • progress
  • loading
  • status
  • feedback

04 examples

Default

A determinate bar with a header label and value.

Uploading65 %

Track only

Just the bar, no header.

Indeterminate

An unknown-duration loading state.

Loading

Animated

Value driven over time to show the fill transition.

Syncing0 %

source

progress-bar.tsx

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

"use client"

import { createContext, use } from "react"
import {
  composeRenderProps,
  ProgressBar as ProgressBarPrimitive,
  type ProgressBarProps,
  type ProgressBarRenderProps,
} from "react-aria-components"
import { cn } from "@/lib/utils"

/**
 * ProgressBar — quebi design system
 *
 * Built on react-aria-components. A thin square track in `hairline` with a
 * `signal` fill — progress is state — that animates its width. Supports determinate and
 * indeterminate states, plus an optional header with label and value — the
 * value set in mono, like every count in the system.
 */
const ProgressBarContext = createContext<ProgressBarRenderProps | null>(null)

export function ProgressBar({ className, children, ...props }: ProgressBarProps) {
  return (
    <ProgressBarPrimitive
      data-slot="control"
      className={composeRenderProps(className, (resolved) =>
        cn(
          "flex w-full flex-col gap-2",
          "*:data-[slot=progress-bar-header]:font-medium",
          resolved,
        ),
      )}
      {...props}
    >
      {(values) => (
        <ProgressBarContext value={values}>
          {typeof children === "function" ? children(values) : children}
        </ProgressBarContext>
      )}
    </ProgressBarPrimitive>
  )
}

export function ProgressBarHeader({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="progress-bar-header"
      className={cn("flex items-center justify-between text-sm text-quebi-fg", className)}
      {...props}
    />
  )
}

export function ProgressBarValue({
  className,
  ...props
}: Omit<React.ComponentProps<"span">, "children">) {
  const ctx = use(ProgressBarContext)
  if (!ctx) throw new Error("ProgressBarValue must be used within a ProgressBar")
  const { valueText } = ctx
  return (
    <span
      data-slot="progress-bar-value"
      className={cn("font-mono text-quebi-caption text-quebi-fg-subtle tabular-nums", className)}
      {...props}
    >
      {valueText}
    </span>
  )
}

export function ProgressBarTrack({ className, ref, ...props }: React.ComponentProps<"div">) {
  const ctx = use(ProgressBarContext)
  if (!ctx) throw new Error("ProgressBarTrack must be used within a ProgressBar")
  const { isIndeterminate, percentage } = ctx
  return (
    <span data-slot="progress-bar-track" className="relative block w-full">
      <style>{`
        @keyframes quebi-progress-slide {
          0% { inset-inline-start: -40% }
          100% { inset-inline-start: 100% }
        }
      `}</style>
      <div
        ref={ref}
        data-slot="progress-container"
        className={cn(
          "relative h-1 w-full min-w-52 overflow-hidden bg-quebi-hairline will-change-transform",
          className,
        )}
        {...props}
      >
        {!isIndeterminate ? (
          <div
            data-slot="progress-content"
            className="absolute start-0 top-0 h-full bg-quebi-signal transition-[width] duration-200 ease-linear will-change-[width] motion-reduce:transition-none forced-colors:bg-[Highlight]"
            style={{ width: `${percentage}%` }}
          />
        ) : (
          <div
            data-slot="progress-content"
            className="absolute top-0 h-full w-2/5 animate-[quebi-progress-slide_1500ms_ease-in-out_infinite] bg-quebi-signal forced-colors:bg-[Highlight]"
          />
        )}
      </div>
    </span>
  )
}