turbopills-ui
componentprogress-numberedsource: ok · hit
Progress (Numbered)
Numbered step progress with collapse/ellipsis.
npx shadcn add "https://ui.turbopills.com/r/progress-numbered.json"free
Interaction
accordion
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightRegistry dependencies
tooltip
Files2 · source cached 10/14/2026, 1:05:33 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-numbered.tsx
"use client";
import { forwardRef, useRef, useState, useEffect, useMemo } from "react";
import { cn } from "@/lib/utils";
import {
type ProgressProps,
type ProgressStep,
useProgressHelpers,
ProgressContainer,
StepLabel,
WithTooltip,
} from "./progress";
const STEP_SIZE = 32;
const STEP_GAP = 4;
const CONNECTOR_HEIGHT = 4;
export type ProgressNumberedProps = ProgressProps & {
highlightCurrent?: boolean;
steps?: ProgressStep[] | number;
};
function getVisibleIndices(
total: number,
maxSlots: number,
activeIdx: number,
): number[] | null {
if (total <= maxSlots) return null;
const active = Math.max(0, Math.min(total - 1, activeIdx));
const pinned = new Set<number>([0, total - 1, active]);
for (let d = 1; pinned.size < maxSlots; d++) {
if (active - d >= 0) pinned.add(active - d);
if (pinned.size < maxSlots && active + d < total) pinned.add(active + d);
if (active - d < 0 && active + d >= total) break;
}
const sorted = Array.from(pinned).sort((a, b) => a - b);
const countEllipses = (arr: number[]) =>
arr.reduce(
(count, value, i) =>
i > 0 && value - arr[i - 1] > 1 ? count + 1 : count,
0,
);
while (sorted.length + countEllipses(sorted) > maxSlots && sorted.length > 2) {
let worstIndex = -1;
let worstDistance = -1;
for (let i = 1; i < sorted.length - 1; i++) {
const distance = Math.abs(sorted[i] - active);
if (distance > worstDistance) {
worstDistance = distance;
worstIndex = i;
}
}
if (worstIndex === -1) break;
sorted.splice(worstIndex, 1);
}
return sorted;
}
export const ProgressNumbered = forwardRef<HTMLDivElement, ProgressNumberedProps>(
(
{
className,
value = 0,
animated = true,
highlightCurrent = false,
filledClassName,
unfilledClassName,
currentClassName,
steps,
},
ref,
) => {
const stepCount = Array.isArray(steps)
? steps.length
: typeof steps === "number"
? steps
: 4;
const stepsArray = Array.isArray(steps) ? steps : undefined;
const hasLabels = stepsArray?.some((s) => s.label) ?? false;
const innerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState<number | null>(null);
useEffect(() => {
const el = innerRef.current;
if (!el) return;
const update = () => setContainerWidth(el.offsetWidth);
update();
const observer = new ResizeObserver(update);
observer.observe(el);
return () => observer.disconnect();
}, []);
const { transitionClass, getStepState, getStateClasses } =
useProgressHelpers({
value,
stepCount,
highlightCurrent,
animated,
filledClassName,
unfilledClassName,
currentClassName,
});
const activeIdx = Math.max(
0,
Math.min(stepCount - 1, Math.round((value * stepCount) / 100) - 1),
);
const maxSlots =
containerWidth !== null
? Math.floor((containerWidth + STEP_GAP) / (STEP_SIZE + STEP_GAP))
: stepCount;
const visibleIndices = useMemo(
() => getVisibleIndices(stepCount, maxSlots, activeIdx),
[stepCount, maxSlots, activeIdx],
);
const isCollapsed = visibleIndices !== null;
const renderConnector = (fromIndex: number) => {
const { isFilled } = getStepState(fromIndex + 1);
return (
<div
key={`connector-${fromIndex}`}
className={cn(
"rounded-full self-center",
transitionClass,
isFilled ? "bg-primary" : "bg-muted",
)}
style={{ flex: "1 1 0", minWidth: 0, height: CONNECTOR_HEIGHT }}
/>
);
};
const renderStep = (index: number, step?: ProgressStep) => {
const { isFilled, isCurrent } = getStepState(index);
return (
<div key={index} className="relative flex flex-col items-center">
<WithTooltip tooltip={step?.tooltip}>
<div
className={cn(
"flex items-center justify-center rounded-full w-8 h-8 text-sm font-medium",
transitionClass,
getStateClasses(isFilled, isCurrent),
isFilled || isCurrent
? "text-primary-foreground"
: "text-muted-foreground",
)}
>
{index + 1}
</div>
</WithTooltip>
{!isCollapsed && <StepLabel label={step?.label} />}
</div>
);
};
const renderContent = () => {
if (!isCollapsed) {
const elements = [];
for (let i = 0; i < stepCount; i++) {
elements.push(renderStep(i, stepsArray?.[i]));
if (i < stepCount - 1) {
elements.push(renderConnector(i));
}
}
return elements;
}
return visibleIndices.flatMap((idx, i) => {
const prev = visibleIndices[i - 1];
const hasGap = i > 0 && idx - prev > 1;
const elements = [];
if (i > 0) {
if (hasGap) {
elements.push(
<div
key={`ellipsis-${i}`}
className="flex items-center justify-center w-8 h-8 text-sm text-muted-foreground"
>
…
</div>,
);
} else {
elements.push(renderConnector(prev));
}
}
elements.push(renderStep(idx, stepsArray?.[idx]));
return elements;
});
};
return (
<ProgressContainer ref={ref} hasLabels={hasLabels && !isCollapsed}>
<div
ref={innerRef}
className={cn("relative flex items-center gap-1", className)}
>
{renderContent()}
</div>
</ProgressContainer>
);
},
);
ProgressNumbered.displayName = "ProgressNumbered";