Styled progress bar with value-driven indicator. Note: no animated indeterminate mode (undefined renders an empty track).

Preview
Style
Progress Bar
With Label
Upload progress56%
Controlled
Use slider to control value (static demo at 50%)
File Upload List
document.pdf
2m 30s
presentation.pptx
45s
spreadsheet.xlsx
5m 12s
image.jpg
Complete
src/demo/views/progress.ts107 lines
import type { Html, HtmlBuilder } from 'foldkit/html'

import { Progress } from '../../generated/registry/ui/progress'
import { Item } from '../../generated/registry/ui/item'
import { icon } from '../../generated/registry/lib/icons'
import { File } from 'lucide'

import { defineSlice } from '../slice'
import type { Message, Model } from '../assemble'

const files = [
  { id: '1', name: 'document.pdf', progress: 45, time: '2m 30s' },
  { id: '2', name: 'presentation.pptx', progress: 78, time: '45s' },
  { id: '3', name: 'spreadsheet.xlsx', progress: 12, time: '5m 12s' },
  { id: '4', name: 'image.jpg', progress: 100, time: 'Complete' },
]

export const progressView = (_model: Model, h: HtmlBuilder<Message>): Html =>
  h.div(
    [h.Class('flex w-full flex-col gap-8')],
    [
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Progress Bar']),
          h.div(
            [h.Class('flex w-full flex-col gap-4')],
            [
              Progress<Message>({ value: 0 }, h),
              Progress<Message>({ value: 25, className: 'w-full' }, h),
              Progress<Message>({ value: 50 }, h),
              Progress<Message>({ value: 75 }, h),
              Progress<Message>({ value: 100 }, h),
            ],
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['With Label']),
          h.div(
            [h.Class('flex w-full flex-col gap-1')],
            [
              h.div(
                [h.Class('flex items-center justify-between')],
                [
                  Progress.label<Message>({}, ['Upload progress'], h),
                  Progress.value<Message>({}, ['56%'], h),
                ],
              ),
              Progress<Message>({ value: 56 }, h),
            ],
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Controlled']),
          h.div(
            [h.Class('flex w-full flex-col gap-4')],
            [
              Progress<Message>({ value: 50, className: 'w-full' }, h),
              h.div(
                [h.Class('text-xs text-muted-foreground')],
                ['Use slider to control value (static demo at 50%)'],
              ),
            ],
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['File Upload List']),
          h.div(
            [h.Class('flex w-full flex-col divide-y rounded-lg border')],
            files.map((file) =>
              Item<Message>(
                { className: 'px-3 py-2' },
                [
                  Item.media<Message>({}, [icon(h, File, 'size-5')], h),
                  Item.content<Message>({}, [Item.title<Message>({}, [file.name], h)], h),
                  Item.content<Message>(
                    {},
                    [Progress<Message>({ value: file.progress, className: 'w-32' }, h)],
                    h,
                  ),
                  h.span([h.Class('text-sm text-muted-foreground w-16 text-right')], [file.time]),
                ],
                h,
              ),
            ),
          ),
        ],
      ),
    ],
  )

export const slice = defineSlice({
  fields: {},
  init: {},
  messages: [],
  handlers: () => ({}),
})

This code is shown verbatim — it’s the exact file used for this preview. Much of the surrounding wiring (slice, fields, messages, handlers) belongs to this site’s demo harness; you only need the view and the state that matters for your own app. Adapt what you need.

Installation

Add this component to your project:

pnpm dlx shadcn@latest add @foldcn/progress

Source

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,
})