Progress
Styled progress bar with value-driven indicator. Note: no animated indeterminate mode (undefined renders an empty track).
Differences vs shadcn/ui
- Indeterminate state renders an empty track — animated indeterminacy awaits primitive support.
Installation
Add this component to your project:
pnpm dlx shadcn@latest add @foldcn/progressSource
The component ships as plain source — no build step, no wrapper. Copy it and make it yours.
registry/default/ui/progress.ts104 lines/** ⚠ BEHAVIOR GAP vs upstream shadcn: no animated indeterminate mode — `value: undefined` renders an empty track until foldkit's primitives support it.
* The styled surface matches, but this behavior is absent — do not use
* where that behavior is required.
*/
import type { Html, HtmlBuilder } from 'foldkit/html'
type Child = Html | string
import { cn } from '@/lib/utils'
/**
* foldkit gap: upstream drives the indicator transform and the value text
* from the Base UI Progress primitive. Here `value` (0–100) positions the
* indicator directly; `undefined` renders an EMPTY track — animated
* indeterminate needs primitive support. The label/value builders render
* static content the consumer owns.
*
*/
export const progressClass = 'flex flex-wrap gap-3'
export const progressTrackClass =
'bg-muted h-1 rounded-full relative flex w-full items-center overflow-x-hidden'
export const progressIndicatorClass = 'bg-primary h-full transition-all'
export const progressLabelClass = 'text-sm font-medium'
export const progressValueClass = 'text-muted-foreground ml-auto text-sm tabular-nums'
type StyleConfig = Readonly<{ className?: string }>
type ProgressConfig = Readonly<{ value?: number; className?: string }>
const clampValue = (value: number): number => Math.min(100, Math.max(0, value))
const progressIndicator = <M>(value: number | undefined, h: HtmlBuilder<M>): Html =>
h.div(
[
h.Class(progressIndicatorClass),
h.DataAttribute('slot', 'progress-indicator'),
// Undefined = indeterminate: empty track until primitives can animate.
h.Style({
transform: `translateX(-${100 - (value === undefined ? 0 : clampValue(value))}%)`,
}),
],
[],
)
/** Styled progress bar with an accessible track. */
export const progress = <M>(config: ProgressConfig, h: HtmlBuilder<M>): Html =>
h.div(
[
h.Class(cn(progressClass, config.className)),
h.Role('progressbar'),
h.AriaValuemin(0),
h.AriaValuemax(100),
...(config.value === undefined ? [] : [h.AriaValuenow(clampValue(config.value))]),
h.DataAttribute('slot', 'progress'),
],
[
h.div(
[h.Class(progressTrackClass), h.DataAttribute('slot', 'progress-track')],
[progressIndicator(config.value, h)],
),
],
)
/** Static label for the bar (consumer-owned text). */
export const progressLabel = <M>(
config: StyleConfig,
children: ReadonlyArray<Child>,
h: HtmlBuilder<M>,
): Html =>
h.span(
[h.Class(cn(progressLabelClass, config.className)), h.DataAttribute('slot', 'progress-label')],
children,
)
/** Static value readout (consumer-owned text, e.g. "3 of 5"). */
export const progressValue = <M>(
config: StyleConfig,
children: ReadonlyArray<Child>,
h: HtmlBuilder<M>,
): Html =>
h.span(
[h.Class(cn(progressValueClass, config.className)), h.DataAttribute('slot', 'progress-value')],
children,
)
export const Progress = Object.assign(progress, {
track: <M>(config: StyleConfig, children: ReadonlyArray<Child>, h: HtmlBuilder<M>): Html =>
h.div(
[
h.Class(cn(progressTrackClass, config.className)),
h.DataAttribute('slot', 'progress-track'),
],
children,
),
indicator: progressIndicator,
label: progressLabel,
value: progressValue,
})