diff --git a/src/components/CircularProgress/CircularProgress.tsx b/src/components/CircularProgress/CircularProgress.tsx
new file mode 100644
index 0000000000..7736079744
--- /dev/null
+++ b/src/components/CircularProgress/CircularProgress.tsx
@@ -0,0 +1,111 @@
+import * as React from 'react';
+import { StyleSheet, View, ViewStyle } from 'react-native';
+
+import type { CircularProgressProps } from './types';
+import { useInternalTheme } from '../../core/theming';
+
+const clamp = (v: number, min: number, max: number) =>
+ Math.min(Math.max(v, min), max);
+
+const styles = StyleSheet.create({
+ container: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ } as ViewStyle,
+});
+
+/**
+ * Material 3 determinate circular progress indicator.
+ *
+ * Shows a circular track with a progress arc covering `progress` of the
+ * circle (0 to 1). Unlike {@link ActivityIndicator}, it represents a known
+ * amount of progress rather than an indeterminate spin.
+ *
+ * @param props
+ */
+function CircularProgress({
+ progress,
+ size = 48,
+ thickness = 4,
+ color,
+ trackColor,
+ style,
+ theme: themeOverrides,
+ testID = 'circular-progress',
+}: CircularProgressProps) {
+ const theme = useInternalTheme(themeOverrides);
+ const p = clamp(progress, 0, 1);
+
+ const progressColor =
+ color ?? (theme.isV3 ? theme.colors.primary : theme.colors.accent);
+ const backgroundColor =
+ trackColor ??
+ (theme.isV3 ? theme.colors.primary : theme.colors.accent) + '33';
+
+ const radius = size / 2;
+ // The progress arc is drawn with two half-discs that rotate as the progress
+ // passes 0.5, which lets a circular arc be rendered without an SVG dep.
+ const firstHalfAngle = p <= 0.5 ? p * 360 : 180;
+ const secondHalfAngle = p > 0.5 ? (p - 0.5) * 360 : 0;
+
+ const trackStyle: ViewStyle = {
+ width: size,
+ height: size,
+ borderRadius: radius,
+ borderWidth: thickness,
+ borderColor: backgroundColor,
+ };
+
+ // Each progress half is a full circle clipped to its half, rotated by the
+ // angle that exposes the right amount of arc.
+ const halfSize = radius;
+ const arcLayerStyle = (angle: number, flipSecond: boolean): ViewStyle => ({
+ width: size,
+ height: halfSize,
+ overflow: 'hidden',
+ transform: [
+ { rotate: `${angle}deg` },
+ ...(flipSecond ? [{ translateY: -halfSize }] : []),
+ ],
+ ...(flipSecond ? { top: halfSize } : { top: 0 }),
+ left: 0,
+ position: 'absolute',
+ });
+
+ const arcDotStyle: ViewStyle = {
+ width: size,
+ height: size,
+ borderRadius: radius,
+ borderWidth: thickness,
+ borderColor: progressColor,
+ // Hide the track border on the progress disc by only drawing the arc.
+ borderBottomColor: 'transparent',
+ borderLeftColor: 'transparent',
+ transform: [{ rotate: '-45deg' }],
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+export default CircularProgress;
diff --git a/src/components/CircularProgress/types.ts b/src/components/CircularProgress/types.ts
new file mode 100644
index 0000000000..cdc64d3b72
--- /dev/null
+++ b/src/components/CircularProgress/types.ts
@@ -0,0 +1,25 @@
+import type { StyleProp, ViewStyle } from 'react-native';
+
+import type { ThemeProp } from '../../types';
+
+/**
+ * Material 3 determinate circular progress indicator.
+ */
+export type CircularProgressProps = {
+ /** Progress from 0 to 1 (clamped). 0 is empty, 1 is a full circle. */
+ progress: number;
+ /** Diameter of the indicator in pixels. @default 48 */
+ size?: number;
+ /** Stroke thickness in pixels. @default 4 */
+ thickness?: number;
+ /** Color of the progress arc. Defaults to the theme primary color. */
+ color?: string;
+ /** Color of the background track. Defaults to a transparent track. */
+ trackColor?: string;
+ /** Style applied to the container. */
+ style?: StyleProp;
+ /** @optional */
+ theme?: ThemeProp;
+ /** TestID for testing. */
+ testID?: string;
+};
diff --git a/src/index.tsx b/src/index.tsx
index 1b20528787..c33d773cfb 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -32,6 +32,7 @@ export { default as Banner } from './components/Banner';
export { default as BottomNavigation } from './components/BottomNavigation/BottomNavigation';
export { default as Button } from './components/Button/Button';
export { default as Card } from './components/Card/Card';
+export { default as CircularProgress } from './components/CircularProgress/CircularProgress';
export { default as Checkbox } from './components/Checkbox';
export { default as Chip } from './components/Chip/Chip';
export { default as DataTable } from './components/DataTable/DataTable';
@@ -86,6 +87,7 @@ export type {
} from './components/BottomNavigation/BottomNavigation';
export type { Props as ButtonProps } from './components/Button/Button';
export type { Props as CardProps } from './components/Card/Card';
+export type { CircularProgressProps } from './components/CircularProgress/types';
export type { Props as CardActionsProps } from './components/Card/CardActions';
export type { Props as CardContentProps } from './components/Card/CardContent';
export type { Props as CardCoverProps } from './components/Card/CardCover';