Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

Progress

A determinate or indeterminate progress bar.

A linear progress bar. Android draws the MD3 4px track with the primary indicator and a dual-segment indeterminate animation; iOS draws the thin UIProgressView-style track. Set value for determinate progress, or indeterminate for continuous activity.

Installation

npx touchcn add progress

Usage

<tcn-progress [value]="62" />
<tcn-progress indeterminate />
import { TcnProgress } from '@/components/ui/progress';

<TcnProgress value={62} />
<TcnProgress indeterminate />
<script setup lang="ts">
import { TcnProgress } from '@/components/ui/progress';
</script>

<template>
  <TcnProgress :value="62" />
  <TcnProgress indeterminate />
</template>

The bar carries role="progressbar" with aria-valuemin / aria-valuemax / aria-valuenow; the value attributes are omitted while indeterminate.

Props

Prop Type Default Description
value number 0 Completion 0–100 (determinate).
indeterminate boolean false Run the continuous animation.
Source · Angular
export * from './tcn-progress';
import { booleanAttribute, Component, computed, input, numberAttribute } from '@angular/core';

/**
 * Progress bar. Determinate mode fills the indicator to `value` (0–100),
 * value-driven through the `--tcn-progress-value` custom property so no
 * per-frame JS styling is needed; `indeterminate` runs the MD3 dual-segment
 * sweep (a single travelling segment on iOS). MD3 draws a 4px track with the
 * primary indicator; iOS draws the thin UIProgressView-style track. Carries
 * `role="progressbar"` with `aria-value*` (omitted while indeterminate).
 */
@Component({
  selector: 'tcn-progress',
  template: `
    <div class="tcn-progress-track">
      <div class="tcn-progress-indicator" [style.--tcn-progress-value]="fill()"></div>
      <div class="tcn-progress-seg tcn-progress-seg-1" aria-hidden="true"></div>
      <div class="tcn-progress-seg tcn-progress-seg-2" aria-hidden="true"></div>
    </div>
  `,
  host: {
    role: 'progressbar',
    class: 'tcn-progress',
    '[attr.data-indeterminate]': 'indeterminate() || null',
    '[attr.aria-valuemin]': 'indeterminate() ? null : 0',
    '[attr.aria-valuemax]': 'indeterminate() ? null : 100',
    '[attr.aria-valuenow]': 'indeterminate() ? null : clamped()',
  },
})
export class TcnProgress {
  readonly value = input(0, { transform: numberAttribute });
  readonly indeterminate = input(false, { transform: booleanAttribute });

  protected readonly clamped = computed(() => Math.max(0, Math.min(100, this.value())));
  protected readonly fill = computed(() => `${this.clamped()}%`);
}
Source · React
export * from './tcn-progress';
import { forwardRef } from 'react';
import type { ComponentPropsWithoutRef, CSSProperties } from 'react';
import { cn } from '@touchcn/core';

export interface TcnProgressProps extends Omit<ComponentPropsWithoutRef<'div'>, 'role'> {
  /** Completion 0–100 (determinate mode). */
  value?: number;
  /** Run the continuous indeterminate animation instead of a fixed fill. */
  indeterminate?: boolean;
}

/**
 * Progress bar. Determinate mode fills the indicator to `value` (0–100),
 * value-driven through the `--tcn-progress-value` custom property so no
 * per-frame JS styling is needed; `indeterminate` runs the MD3 dual-segment
 * sweep (a single travelling segment on iOS). MD3 draws a 4px track with the
 * primary indicator; iOS draws the thin UIProgressView-style track. Emits the
 * same `tcn-progress*` markup as the Angular component with `role="progressbar"`
 * and `aria-value*` (omitted while indeterminate).
 */
export const TcnProgress = forwardRef<HTMLDivElement, TcnProgressProps>(function TcnProgress(
  { value = 0, indeterminate = false, className, ...props },
  ref,
) {
  const clamped = Math.max(0, Math.min(100, value));

  return (
    <div
      ref={ref}
      role="progressbar"
      className={cn('tcn-progress', className)}
      data-indeterminate={indeterminate || undefined}
      aria-valuemin={indeterminate ? undefined : 0}
      aria-valuemax={indeterminate ? undefined : 100}
      aria-valuenow={indeterminate ? undefined : clamped}
      {...props}
    >
      <div className="tcn-progress-track">
        <div className="tcn-progress-indicator" style={{ '--tcn-progress-value': `${clamped}%` } as CSSProperties} />
        <div className="tcn-progress-seg tcn-progress-seg-1" aria-hidden="true" />
        <div className="tcn-progress-seg tcn-progress-seg-2" aria-hidden="true" />
      </div>
    </div>
  );
});
Source · Vue
<script setup lang="ts">
import { computed } from 'vue';
import type { CSSProperties } from 'vue';

/**
 * Progress bar. Determinate mode fills the indicator to `value` (0–100),
 * value-driven through the `--tcn-progress-value` custom property so no
 * per-frame JS styling is needed; `indeterminate` runs the MD3 dual-segment
 * sweep (a single travelling segment on iOS). MD3 draws a 4px track with the
 * primary indicator; iOS draws the thin UIProgressView-style track. Emits the
 * same `tcn-progress*` markup as the Angular / React component with
 * `role="progressbar"` and `aria-value*` (omitted while indeterminate).
 */
const props = withDefaults(defineProps<{ value?: number; indeterminate?: boolean }>(), {
  value: 0,
  indeterminate: false,
});

const clamped = computed(() => Math.max(0, Math.min(100, props.value)));

const indicatorStyle = computed<CSSProperties>(
  () => ({ '--tcn-progress-value': `${clamped.value}%` }) as CSSProperties,
);
</script>

<template>
  <div
    role="progressbar"
    class="tcn-progress"
    :data-indeterminate="indeterminate || undefined"
    :aria-valuemin="indeterminate ? undefined : 0"
    :aria-valuemax="indeterminate ? undefined : 100"
    :aria-valuenow="indeterminate ? undefined : clamped"
  >
    <div class="tcn-progress-track">
      <div class="tcn-progress-indicator" :style="indicatorStyle" />
      <div class="tcn-progress-seg tcn-progress-seg-1" aria-hidden="true" />
      <div class="tcn-progress-seg tcn-progress-seg-2" aria-hidden="true" />
    </div>
  </div>
</template>
export { default as TcnProgress } from './TcnProgress.vue';

Last updated on July 24, 2026

Was this page helpful?