shadimage
turbopills-ui
componentprogress-cellssource: ok · hit

Progress (Cells)

Segmented progress bar with optional labels/tooltips.

npx shadcn add "https://ui.turbopills.com/r/progress-cells.json"
free

Interaction

tooltip

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
ui-primitives@radix-ui/react-progress

Registry dependencies

tooltip

Files2 · source cached 10/8/2026, 8:15:32 PM

registry/turbopills/ui/progress/progress.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from "@/components/ui/tooltip";

export interface ProgressStep {
  label?: string;
  tooltip?: string;
}

export interface ProgressProps {
  value?: number;
  animated?: boolean;
  filledClassName?: string;
  unfilledClassName?: string;
  currentClassName?: string;
  className?: string;
}

export function useProgressHelpers({
  value = 0,
  stepCount,
  highlightCurrent = false,
  animated = true,
  filledClassName,
  unfilledClassName,
  currentClassName,
}: {
  value?: number;
  stepCount: number;
  highlightCurrent?: boolean;
  animated?: boolean;
  filledClassName?: string;
  unfilledClassName?: string;
  currentClassName?: string;
}) {
  const transitionClass = animated ? "transition-all duration-500" : "";
  const clampedValue = Math.min(Math.max(value, 0), 100);

  const getStepState = (index: number) => {
    const midpoint = ((index + 0.5) / stepCount) * 100;
    const isFilled = value >= midpoint;
    const nextMidpoint = ((index + 1.5) / stepCount) * 100;
    const isCurrent = isFilled && value < nextMidpoint;
    return { isFilled, isCurrent };
  };

  const getStateClasses = (isFilled: boolean, isCurrent: boolean) => {
    if (isCurrent) {
      return cn(
        "bg-primary",
        highlightCurrent ? "ring-2 ring-primary/70" : "",
        currentClassName,
      );
    }
    return isFilled
      ? cn("bg-primary", filledClassName)
      : cn("bg-muted", unfilledClassName);
  };

  return {
    transitionClass,
    clampedValue,
    getStepState,
    getStateClasses,
  };
}

export function StepLabel({ label }: { label?: string }) {
  if (!label) return null;
  return (
    <span className="absolute top-full mt-2 text-xs text-muted-foreground whitespace-nowrap">
      {label}
    </span>
  );
}

export function WithTooltip({
  children,
  tooltip,
}: {
  children: React.ReactElement;
  tooltip?: string;
}) {
  if (!tooltip) return children;
  return (
    <Tooltip>
      <TooltipTrigger asChild>{children}</TooltipTrigger>
      <TooltipContent>{tooltip}</TooltipContent>
    </Tooltip>
  );
}

export const ProgressContainer = React.forwardRef<
  HTMLDivElement,
  {
    children: React.ReactNode;
    hasLabels: boolean;
  }
>(({ children, hasLabels }, ref) => {
  return (
    <div ref={ref} className={cn("relative w-full", hasLabels && "pb-6")}>
      {children}
    </div>
  );
});

ProgressContainer.displayName = "ProgressContainer";

export function renderStepsArray(
  count: number,
  steps: ProgressStep[] | undefined,
  renderItem: (index: number, step?: ProgressStep) => React.ReactNode,
) {
  return Array.from({ length: count }, (_, index) =>
    renderItem(index, steps?.[index]),
  );
}
registry/turbopills/ui/progress/progress-cells.tsx
"use client";

import { forwardRef } from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils";
import {
  type ProgressProps,
  type ProgressStep,
  useProgressHelpers,
  ProgressContainer,
  StepLabel,
  WithTooltip,
  renderStepsArray,
} from "./progress";

export type ProgressCellsProps = ProgressProps & {
  highlightCurrent?: boolean;
  cellGap?: number;
  steps?: ProgressStep[] | number;
};

export const ProgressCells = forwardRef<HTMLDivElement, ProgressCellsProps>(
  (
    {
      className,
      value = 0,
      animated = true,
      highlightCurrent = false,
      filledClassName,
      unfilledClassName,
      currentClassName,
      cellGap = 8,
      steps,
    },
    ref,
  ) => {
    const effectiveStepCount = Array.isArray(steps)
      ? steps.length
      : typeof steps === "number"
        ? steps
        : 4;
    const hasLabels = Array.isArray(steps) ? steps.some((s) => s.label) : false;

    const { transitionClass, getStepState, getStateClasses } =
      useProgressHelpers({
        value,
        stepCount: effectiveStepCount,
        highlightCurrent,
        animated,
        filledClassName,
        unfilledClassName,
        currentClassName,
      });

    return (
      <ProgressContainer ref={ref} hasLabels={hasLabels}>
        <ProgressPrimitive.Root
          className={cn("relative rounded-full w-full h-2", className)}
        >
          <div className="flex h-full w-full" style={{ gap: `${cellGap}px` }}>
            {renderStepsArray(
              effectiveStepCount,
              Array.isArray(steps) ? steps : undefined,
              (index, step) => {
                const { isFilled, isCurrent } = getStepState(index);
                return (
                  <div
                    key={index}
                    className="relative flex-1 flex flex-col items-center"
                  >
                    <WithTooltip tooltip={step?.tooltip}>
                      <div
                        className={cn(
                          "h-full w-full rounded-full",
                          transitionClass,
                          getStateClasses(isFilled, isCurrent),
                        )}
                      />
                    </WithTooltip>
                    <StepLabel label={step?.label} />
                  </div>
                );
              },
            )}
          </div>
        </ProgressPrimitive.Root>
      </ProgressContainer>
    );
  },
);

ProgressCells.displayName = "ProgressCells";

More from turbopills-ui

Browse all components in this library →