Repository: expo/expo
Pull request #36964: [ui][android] Add shape component.
Author: login: aleqsio
Labels: name: bot: passed checks
description: ExpoBot has nothing to complain about
name: published
description: Changes from the PR have been published to npm
name: bot: fingerprint changed
Created: 2025-05-19T15:20:11Z | Merged: 2025-05-21T15:27:25Z

# Why

Adds material 3 shapes as a separate compose component.



https://github.com/user-attachments/assets/8f569640-28fc-4d86-8c61-6d300146dc24


# How

Used the official shapes library.

# Test Plan

Tested in bare-expo.

# Checklist

<!--
Please check the appropriate items below if they apply to your diff.
-->

- [ ] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting)
- [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin).
- [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md)



Diff:
diff --git a/apps/native-component-list/src/screens/UI/ShapeScreen.android.tsx b/apps/native-component-list/src/screens/UI/ShapeScreen.android.tsx
new file mode 100644
index 00000000000000..15a060c5bd8d67
--- /dev/null
+++ b/apps/native-component-list/src/screens/UI/ShapeScreen.android.tsx
@@ -0,0 +1,126 @@
+import { Shape } from '@expo/ui/jetpack-compose';
+import { useEffect } from 'react';
+import { View } from 'react-native';
+import Animated, {
+  Easing,
+  useSharedValue,
+  withRepeat,
+  withSequence,
+  withTiming,
+} from 'react-native-reanimated';
+
+import { Page } from '../../components/Page';
+
+const AnimatedPolygon = Animated.createAnimatedComponent(Shape.Polygon);
+const AnimatedRectangle = Animated.createAnimatedComponent(Shape.Rectangle);
+const AnimatedStar = Animated.createAnimatedComponent(Shape.Star);
+
+const pastelPalette = {
+  color1: '#541388',
+  color2: '#d1d646',
+  color3: '#d90368',
+  color4: '#2e294e',
+  color5: '#ffd400',
+};
+
+export default function UIScreen() {
+  const oneToZero = useSharedValue<number | undefined>(1);
+
+  useEffect(() => {
+    oneToZero.set(
+      withRepeat(
+        withSequence(
+          withTiming(1, { duration: 1000, easing: Easing.cubic }),
+          withTiming(0, { duration: 1000, easing: Easing.cubic })
+        ),
+        -1,
+        true
+      )
+    );
+  }, []);
+
+  const zeroToPointTwo = useSharedValue<number | undefined>(1);
+
+  useEffect(() => {
+    zeroToPointTwo.set(
+      withRepeat(
+        withSequence(
+          withTiming(0, { duration: 1000, easing: Easing.cubic }),
+          withTiming(0.2, { duration: 1000, easing: Easing.cubic })
+        ),
+        -1,
+        true
+      )
+    );
+  }, []);
+
+  const oneToPointEight = useSharedValue<number | undefined>(1);
+
+  useEffect(() => {
+    oneToPointEight.set(
+      withRepeat(
+        withSequence(
+          withTiming(0.8, { duration: 1000, easing: Easing.cubic }),
+          withTiming(0.6, { duration: 1000, easing: Easing.cubic })
+        ),
+        -1,
+        true
+      )
+    );
+  }, []);
+
+  return (
+    <Page>
+      <View
+        style={{
+          display: 'flex',
+          flexDirection: 'row',
+          flexWrap: 'wrap',
+        }}>
+        <AnimatedPolygon
+          style={{ width: 180, height: 180 }}
+          smoothing={1}
+          cornerRounding={zeroToPointTwo}
+          verticesCount={3}
+          color={pastelPalette.color1}
+        />
+        <AnimatedStar
+          style={{ width: 180, height: 180 }}
+          innerRadius={oneToPointEight}
+          smoothing={1}
+          radius={1}
+          cornerRounding={0.1}
+          verticesCount={12}
+          color={pastelPalette.color2}
+        />
+        <View style={{ width: 180, height: 180, justifyContent: 'center' }}>
+          <Shape.PillStar
+            style={{ width: 180, height: 100 }}
+            radius={1}
+            innerRadius={0.7}
+            smoothing={1}
+            cornerRounding={0.05}
+            verticesCount={12}
+            color={pastelPalette.color3}
+          />
+        </View>
+
+        <AnimatedPolygon
+          style={{ width: 180, height: 180 }}
+          cornerRounding={zeroToPointTwo}
+          smoothing={1}
+          verticesCount={6}
+          color={pastelPalette.color4}
+        />
+        <View style={{ width: 180, height: 180, alignItems: 'center' }}>
+          <AnimatedRectangle
+            style={{ width: 100, height: 180, transform: [{ rotate: '45deg' }] }}
+            smoothing={1}
+            cornerRounding={oneToZero}
+            color={pastelPalette.color5}
+          />
+        </View>
+      </View>
+    </Page>
+  );
+}
diff --git a/apps/native-component-list/src/screens/UI/UIScreen.android.tsx b/apps/native-component-list/src/screens/UI/UIScreen.android.tsx
index 1bfba8226ce031..852d614d5fc7f1 100644
--- a/apps/native-component-list/src/screens/UI/UIScreen.android.tsx
+++ b/apps/native-component-list/src/screens/UI/UIScreen.android.tsx
@@ -34,6 +34,14 @@ export const UIScreens = [
       return optionalRequire(() => require('./SwitchScreen'));
     },
   },
+  {
+    name: 'Shape component',
+    route: 'ui/shape',
+    options: {},
+    getComponent() {
+      return optionalRequire(() => require('./ShapeScreen'));
+    },
+  },
   {
     name: 'Section component',
     route: 'ui/section',
diff --git a/packages/expo-ui/CHANGELOG.md b/packages/expo-ui/CHANGELOG.md
index b205227628709f..d058fd2c3d9c15 100644
--- a/packages/expo-ui/CHANGELOG.md
+++ b/packages/expo-ui/CHANGELOG.md
@@ -6,6 +6,8 @@
 
 ### 🎉 New features
 
+- [android] Add shape component. ([#36964](https://github.com/expo/expo/pull/36964) by [@aleqsio](https://github.com/aleqsio))
+
 ### 🐛 Bug fixes
 
 ### 💡 Others
diff --git a/packages/expo-ui/android/build.gradle b/packages/expo-ui/android/build.gradle
index ee2dfc439c2e8f..436c13b3c48f97 100644
--- a/packages/expo-ui/android/build.gradle
+++ b/packages/expo-ui/android/build.gradle
@@ -35,4 +35,5 @@ dependencies {
   implementation 'androidx.lifecycle:lifecycle-runtime:2.8.7'
   implementation 'androidx.fragment:fragment-ktx:1.8.5'
   implementation 'androidx.compose.material3:material3-android:1.3.1'
+  implementation "androidx.graphics:graphics-shapes:1.0.1"
 }
diff --git a/packages/expo-ui/android/src/main/java/expo/modules/ui/ExpoUIModule.kt b/packages/expo-ui/android/src/main/java/expo/modules/ui/ExpoUIModule.kt
index e3cc7f01f16b88..20c2db6c367b2a 100644
--- a/packages/expo-ui/android/src/main/java/expo/modules/ui/ExpoUIModule.kt
+++ b/packages/expo-ui/android/src/main/java/expo/modules/ui/ExpoUIModule.kt
@@ -26,6 +26,8 @@ class ExpoUIModule : Module() {
       Events("onValueChanged")
     }
 
+    View(ShapeView::class)
+
     View(DateTimePickerView::class) {
       Events("onDateSelected")
     }
diff --git a/packages/expo-ui/android/src/main/java/expo/modules/ui/ShapeView.kt b/packages/expo-ui/android/src/main/java/expo/modules/ui/ShapeView.kt
new file mode 100644
index 00000000000000..3e0cfd8f6e54bb
--- /dev/null
+++ b/packages/expo-ui/android/src/main/java/expo/modules/ui/ShapeView.kt
@@ -0,0 +1,149 @@
+package expo.modules.ui
+
+import android.content.Context
+import expo.modules.kotlin.views.ExpoComposeView
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.ui.graphics.Color
+import expo.modules.kotlin.AppContext
+import expo.modules.kotlin.views.ComposeProps
+import androidx.compose.foundation.layout.Box
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithCache
+import androidx.graphics.shapes.RoundedPolygon
+import androidx.graphics.shapes.toPath
+import androidx.compose.ui.graphics.asComposePath
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Path
+import androidx.graphics.shapes.CornerRounding
+import androidx.graphics.shapes.circle
+import androidx.graphics.shapes.pill
+import androidx.graphics.shapes.pillStar
+import androidx.graphics.shapes.rectangle
+import androidx.graphics.shapes.star
+import expo.modules.kotlin.types.Enumerable
+import android.graphics.Color as GraphicsColor
+
+enum class ShapeType(val value: String) : Enumerable {
+  STAR("star"),
+  PILL_STAR("pillStar"),
+  PILL("pill"),
+  CIRCLE("circle"),
+  RECTANGLE("rectangle"),
+  POLYGON("polygon")
+}
+
+data class ShapeProps(
+  val cornerRounding: MutableState<Float> = mutableFloatStateOf(0.0f),
+  val smoothing: MutableState<Float> = mutableFloatStateOf(0.0f),
+  val verticesCount: MutableState<Int> = mutableIntStateOf(6),
+  val innerRadius: MutableState<Float> = mutableFloatStateOf(0.0f),
+  val radius: MutableState<Float> = mutableFloatStateOf(0.0f),
+  val type: MutableState<ShapeType> = mutableStateOf(ShapeType.CIRCLE),
+  val color: MutableState<GraphicsColor?> = mutableStateOf(null),
+) : ComposeProps
+
+private fun Size.centerX() = this.width / 2
+private fun Size.centerY() = this.height / 2
+
+private fun createStarPath(size: Size, cornerRounding: Float, smoothing: Float, innerRadius: Float, radius: Float, verticesCount: Int): Path {
+  val rounding = CornerRounding(size.minDimension * cornerRounding, smoothing = smoothing)
+  return RoundedPolygon.star(
+    numVerticesPerRadius = verticesCount,
+    innerRadius = size.minDimension * 0.5f * innerRadius.coerceAtMost(radius - 0.001f).coerceAtLeast(0.001f),
+    radius = size.minDimension * 0.5f * radius.coerceAtLeast(0.002f),
+    centerX = size.centerX(),
+    centerY = size.centerY(),
+    rounding = rounding
+  ).toPath().asComposePath()
+}
+
+private fun createPillStarPath(size: Size, cornerRounding: Float, smoothing: Float, innerRadius: Float, verticesCount: Int): Path {
+  val rounding = CornerRounding(size.minDimension * cornerRounding, smoothing = smoothing)
+  return RoundedPolygon.pillStar(
+    numVerticesPerRadius = verticesCount,
+    width = size.width / 2,
+    height = size.height / 2,
+    innerRadiusRatio = innerRadius.coerceAtMost(1f - 0.001f).coerceAtLeast(0.001f),
+    centerX = size.centerX(),
+    centerY = size.centerY(),
+    rounding = rounding
+  ).toPath().asComposePath()
+}
+
+private fun createPillPath(size: Size, smoothing: Float): Path {
+  return RoundedPolygon.pill(
+    centerX = size.centerX(),
+    centerY = size.centerY(),
+    width = size.width,
+    height = size.height,
+    smoothing = smoothing
+  ).toPath().asComposePath()
+}
+
+private fun createPolygonPath(size: Size, cornerRounding: Float, smoothing: Float, verticesCount: Int): Path {
+  val rounding = CornerRounding(size.minDimension * cornerRounding, smoothing = smoothing)
+  return RoundedPolygon(
+    numVertices = verticesCount.coerceAtLeast(3),
+    radius = size.minDimension / 2,
+    centerX = size.centerX(),
+    centerY = size.centerY(),
+    rounding = rounding,
+  ).toPath().asComposePath()
+}
+
+private fun createCirclePath(size: Size, radius: Float, verticesCount: Int): Path {
+  return RoundedPolygon.circle(
+    centerX = size.centerX(),
+    centerY = size.centerY(),
+    radius = size.minDimension * 0.5f * radius.coerceAtLeast(0.002f),
+    numVertices = verticesCount.coerceAtLeast(3),
+  ).toPath().asComposePath()
+}
+
+private fun createRectanglePath(size: Size, cornerRounding: Float, smoothing: Float): Path {
+  val rounding = CornerRounding(size.minDimension * cornerRounding, smoothing = smoothing)
+  return RoundedPolygon.rectangle(
+    centerX = size.centerX(),
+    centerY = size.centerY(),
+    rounding = rounding,
+    width = size.width,
+    height = size.height,
+  ).toPath().asComposePath()
+}
+
+class ShapeView(context: Context, appContext: AppContext) : ExpoComposeView<ShapeProps>(context, appContext) {
+  override val props = ShapeProps()
+  init {
+    setContent {
+      val (smoothing) = props.smoothing
+      val (cornerRounding) = props.cornerRounding
+      val (innerRadius) = props.innerRadius
+      val (radius) = props.radius
+      val (shapeType) = props.type
+      val (verticesCount) = props.verticesCount
+      val (color) = props.color
+      Box(
+        modifier = Modifier
+          .drawWithCache {
+            val path = when (shapeType) {
+              ShapeType.STAR -> createStarPath(size = size, cornerRounding = cornerRounding, smoothing = smoothing, innerRadius = innerRadius, radius = radius, verticesCount = verticesCount)
+              ShapeType.PILL_STAR -> createPillStarPath(size = size, cornerRounding = cornerRounding, smoothing = smoothing, innerRadius = innerRadius, verticesCount = verticesCount)
+              ShapeType.PILL -> createPillPath(size = size, smoothing = smoothing)
+              ShapeType.CIRCLE -> createCirclePath(size = size, radius = radius, verticesCount = verticesCount)
+              ShapeType.RECTANGLE -> createRectanglePath(size = size, cornerRounding = cornerRounding, smoothing = smoothing)
+              ShapeType.POLYGON -> createPolygonPath(size = size, cornerRounding = cornerRounding, smoothing = smoothing, verticesCount = verticesCount)
+            }
+
+            onDrawBehind {
+              drawPath(path, color = color.composeOrNull ?: Color.Transparent)
+            }
+          }
+          .fillMaxSize()
+      )
+    }
+  }
+}
diff --git a/packages/expo-ui/build/jetpack-compose/Shape/index.d.ts b/packages/expo-ui/build/jetpack-compose/Shape/index.d.ts
new file mode 100644
index 00000000000000..e4c1a1a9349bd2
--- /dev/null
+++ b/packages/expo-ui/build/jetpack-compose/Shape/index.d.ts
@@ -0,0 +1,48 @@
+import { ViewStyle } from 'react-native';
+export type ShapeProps = {
+    /**
+     * Corner rounding percentage. Multiplied by the shorter dimension of the view to produce pixel values.
+     * @default 0.0
+     */
+    cornerRounding?: number;
+    /**
+     * Number between `0.0` and `1.0` that determines how much each line between vertices is "smoothed".
+     * @default 0.0
+     */
+    smoothing?: number;
+    /**
+     * Number of vertices. For `'POLYGON'` it must be at least `3.0`. For `'STAR'` and `'PILL_STAR'` it is a number of vertices for each of two radii (A 5-pointed star has 10 vertices.)
+     * @default 6.0
+     */
+    verticesCount?: number;
+    /**
+     * Inner radius of star-related shapes (`'STAR'` and `'PILL_STAR'`). Multiplied by the shorter dimension of the view to produce pixel values.
+     * @default 1.0
+     */
+    innerRadius?: number;
+    /**
+     * Radius of the circular shape. Multiplied by the shorter dimension of the view to produce pixel values.
+     * @default 1.0
+     */
+    radius?: number;
+    /** Style of the component */
+    style?: ViewStyle;
+    /** Color of the shape */
+    color?: string;
+};
+declare function Star(props: ShapeProps): import("react").JSX.Element;
+declare function PillStar(props: ShapeProps): import("react").JSX.Element;
+declare function Pill(props: Pick<ShapeProps, 'smoothing' | 'style' | 'color'>): import("react").JSX.Element;
+declare function Circle(props: Pick<ShapeProps, 'radius' | 'verticesCount' | 'style' | 'color'>): import("react").JSX.Element;
+declare function Rectangle(props: Pick<ShapeProps, 'smoothing' | 'cornerRounding' | 'style' | 'color'>): import("react").JSX.Element;
+declare function Polygon(props: Pick<ShapeProps, 'smoothing' | 'cornerRounding' | 'verticesCount' | 'style' | 'color'>): import("react").JSX.Element;
+export declare const Shape: {
+    Star: typeof Star;
+    PillStar: typeof PillStar;
+    Pill: typeof Pill;
+    Circle: typeof Circle;
+    Rectangle: typeof Rectangle;
+    Polygon: typeof Polygon;
+};
+export {};
+//# sourceMappingURL=index.d.ts.map
\ No newline at end of file
diff --git a/packages/expo-ui/build/jetpack-compose/Shape/index.d.ts.map b/packages/expo-ui/build/jetpack-compose/Shape/index.d.ts.map
new file mode 100644
index 00000000000000..b2f97e31a38221
--- /dev/null
+++ b/packages/expo-ui/build/jetpack-compose/Shape/index.d.ts.map
@@ -0,0 +1 @@
+{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/jetpack-compose/Shape/index.tsx"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAEzC,MAAM,MAAM,UAAU,GAAG;IACvB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,6BAA6B;IAC7B,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,yBAAyB;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAIF,iBAAS,IAAI,CAAC,KAAK,EAAE,UAAU,+BAE9B;AAED,iBAAS,QAAQ,CAAC,KAAK,EAAE,UAAU,+BAElC;AAED,iBAAS,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,UAAU,EAAE,WAAW,GAAG,OAAO,GAAG,OAAO,CAAC,+BAErE;AAED,iBAAS,MAAM,CAAC,KAAK,EAAE,IAAI,CAAC,UAAU,EAAE,QAAQ,GAAG,eAAe,GAAG,OAAO,GAAG,OAAO,CAAC,+BAEtF;AAED,iBAAS,SAAS,CAAC,KAAK,EAAE,IAAI,CAAC,UAAU,EAAE,WAAW,GAAG,gBAAgB,GAAG,OAAO,GAAG,OAAO,CAAC,+BAE7F;AAED,iBAAS,OAAO,CACd,KAAK,EAAE,IAAI,CAAC,UAAU,EAAE,WAAW,GAAG,gBAAgB,GAAG,eAAe,GAAG,OAAO,GAAG,OAAO,CAAC,+BAG9F;AAED,eAAO,MAAM,KAAK;;;;;;;CAOjB,CAAC"}
\ No newline at end of file
diff --git a/packages/expo-ui/build/jetpack-compose/Shape/index.js b/packages/expo-ui/build/jetpack-compose/Shape/index.js
new file mode 100644
index 00000000000000..fbb95fd5377d8e
--- /dev/null
+++ b/packages/expo-ui/build/jetpack-compose/Shape/index.js
@@ -0,0 +1,29 @@
+import { requireNativeView } from 'expo';
+const ShapeNativeView = requireNativeView('ExpoUI', 'ShapeView');
+function Star(props) {
+    return <ShapeNativeView {...props} style={props.style} type="star"/>;
+}
+function PillStar(props) {
+    return <ShapeNativeView {...props} style={props.style} type="pillStar"/>;
+}
+function Pill(props) {
+    return <ShapeNativeView {...props} style={props.style} type="pill"/>;
+}
+function Circle(props) {
+    return <ShapeNativeView {...props} style={props.style} type="circle"/>;
+}
+function Rectangle(props) {
+    return <ShapeNativeView {...props} style={props.style} type="rectangle"/>;
+}
+function Polygon(props) {
+    return <ShapeNativeView {...props} style={props.style} type="polygon"/>;
+}
+export const Shape = {
+    Star,
+    PillStar,
+    Pill,
+    Circle,
+    Rectangle,
+    Polygon,
+};
+//# sourceMappingURL=index.js.map
\ No newline at end of file
diff --git a/packages/expo-ui/build/jetpack-compose/Shape/index.js.map b/packages/expo-ui/build/jetpack-compose/Shape/index.js.map
new file mode 100644
index 00000000000000..491f1b89f9e657
--- /dev/null
+++ b/packages/expo-ui/build/jetpack-compose/Shape/index.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/jetpack-compose/Shape/index.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AAmCzC,MAAM,eAAe,GAA6B,iBAAiB,CAAC,QAAQ,EAAE,WAAW,CAAC,CAAC;AAE3F,SAAS,IAAI,CAAC,KAAiB;IAC7B,OAAO,CAAC,eAAe,CAAC,IAAI,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,MAAM,EAAG,CAAC;AACxE,CAAC;AAED,SAAS,QAAQ,CAAC,KAAiB;IACjC,OAAO,CAAC,eAAe,CAAC,IAAI,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,UAAU,EAAG,CAAC;AAC5E,CAAC;AAED,SAAS,IAAI,CAAC,KAAwD;IACpE,OAAO,CAAC,eAAe,CAAC,IAAI,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,MAAM,EAAG,CAAC;AACxE,CAAC;AAED,SAAS,MAAM,CAAC,KAAuE;IACrF,OAAO,CAAC,eAAe,CAAC,IAAI,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,QAAQ,EAAG,CAAC;AAC1E,CAAC;AAED,SAAS,SAAS,CAAC,KAA2E;IAC5F,OAAO,CAAC,eAAe,CAAC,IAAI,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,WAAW,EAAG,CAAC;AAC7E,CAAC;AAED,SAAS,OAAO,CACd,KAA6F;IAE7F,OAAO,CAAC,eAAe,CAAC,IAAI,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,SAAS,EAAG,CAAC;AAC3E,CAAC;AAED,MAAM,CAAC,MAAM,KAAK,GAAG;IACnB,IAAI;IACJ,QAAQ;IACR,IAAI;IACJ,MAAM;IACN,SAAS;IACT,OAAO;CACR,CAAC","sourcesContent":["import { requireNativeView } from 'expo';\nimport { ViewStyle } from 'react-native';\n\nexport type ShapeProps = {\n  /**\n   * Corner rounding percentage. Multiplied by the shorter dimension of the view to produce pixel values.\n   * @default 0.0\n   */\n  cornerRounding?: number;\n  /**\n   * Number between `0.0` and `1.0` that determines how much each line between vertices is \"smoothed\".\n   * @default 0.0\n   */\n  smoothing?: number;\n  /**\n   * Number of vertices. For `'POLYGON'` it must be at least `3.0`. For `'STAR'` and `'PILL_STAR'` it is a number of vertices for each of two radii (A 5-pointed star has 10 vertices.)\n   * @default 6.0\n   */\n  verticesCount?: number;\n  /**\n   * Inner radius of star-related shapes (`'STAR'` and `'PILL_STAR'`). Multiplied by the shorter dimension of the view to produce pixel values.\n   * @default 1.0\n   */\n  innerRadius?: number;\n  /**\n   * Radius of the circular shape. Multiplied by the shorter dimension of the view to produce pixel values.\n   * @default 1.0\n   */\n  radius?: number;\n  /** Style of the component */\n  style?: ViewStyle;\n  /** Color of the shape */\n  color?: string;\n};\n\nconst ShapeNativeView: React.ComponentType<any> = requireNativeView('ExpoUI', 'ShapeView');\n\nfunction Star(props: ShapeProps) {\n  return <ShapeNativeView {...props} style={props.style} type=\"star\" />;\n}\n\nfunction PillStar(props: ShapeProps) {\n  return <ShapeNativeView {...props} style={props.style} type=\"pillStar\" />;\n}\n\nfunction Pill(props: Pick<ShapeProps, 'smoothing' | 'style' | 'color'>) {\n  return <ShapeNativeView {...props} style={props.style} type=\"pill\" />;\n}\n\nfunction Circle(props: Pick<ShapeProps, 'radius' | 'verticesCount' | 'style' | 'color'>) {\n  return <ShapeNativeView {...props} style={props.style} type=\"circle\" />;\n}\n\nfunction Rectangle(props: Pick<ShapeProps, 'smoothing' | 'cornerRounding' | 'style' | 'color'>) {\n  return <ShapeNativeView {...props} style={props.style} type=\"rectangle\" />;\n}\n\nfunction Polygon(\n  props: Pick<ShapeProps, 'smoothing' | 'cornerRounding' | 'verticesCount' | 'style' | 'color'>\n) {\n  return <ShapeNativeView {...props} style={props.style} type=\"polygon\" />;\n}\n\nexport const Shape = {\n  Star,\n  PillStar,\n  Pill,\n  Circle,\n  Rectangle,\n  Polygon,\n};\n"]}
\ No newline at end of file
diff --git a/packages/expo-ui/build/jetpack-compose/index.d.ts b/packages/expo-ui/build/jetpack-compose/index.d.ts
index 05a13ea8d6a07f..d267a0263f1db4 100644
--- a/packages/expo-ui/build/jetpack-compose/index.d.ts
+++ b/packages/expo-ui/build/jetpack-compose/index.d.ts
@@ -6,4 +6,5 @@ export * from './Progress';
 export * from './Slider';
 export * from './Switch';
 export * from './TextInput';
+export * from './Shape';
 //# sourceMappingURL=index.d.ts.map
\ No newline at end of file
diff --git a/packages/expo-ui/build/jetpack-compose/index.d.ts.map b/packages/expo-ui/build/jetpack-compose/index.d.ts.map
index 3d9b1e6c91555d..17f1bfff7c58c1 100644
--- a/packages/expo-ui/build/jetpack-compose/index.d.ts.map
+++ b/packages/expo-ui/build/jetpack-compose/index.d.ts.map
@@ -1 +1 @@
-{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/jetpack-compose/index.tsx"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,eAAe,CAAC;AAC9B,cAAc,cAAc,CAAC;AAC7B,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,UAAU,CAAC;AACzB,cAAc,UAAU,CAAC;AACzB,cAAc,aAAa,CAAC"}
\ No newline at end of file
+{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/jetpack-compose/index.tsx"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,eAAe,CAAC;AAC9B,cAAc,cAAc,CAAC;AAC7B,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,UAAU,CAAC;AACzB,cAAc,UAAU,CAAC;AACzB,cAAc,aAAa,CAAC;AAC5B,cAAc,SAAS,CAAC"}
\ No newline at end of file
diff --git a/packages/expo-ui/build/jetpack-compose/index.js b/packages/expo-ui/build/jetpack-compose/index.js
index 216b83cad95e83..16f733e8c269d7 100644
--- a/packages/expo-ui/build/jetpack-compose/index.js
+++ b/packages/expo-ui/build/jetpack-compose/index.js
@@ -6,4 +6,5 @@ export * from './Progress';
 export * from './Slider';
 export * from './Switch';
 export * from './TextInput';
+export * from './Shape';
 //# sourceMappingURL=index.js.map
\ No newline at end of file
diff --git a/packages/expo-ui/build/jetpack-compose/index.js.map b/packages/expo-ui/build/jetpack-compose/index.js.map
index e8f6639b30a0a6..82bb5b6a548355 100644
--- a/packages/expo-ui/build/jetpack-compose/index.js.map
+++ b/packages/expo-ui/build/jetpack-compose/index.js.map
@@ -1 +1 @@
-{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/jetpack-compose/index.tsx"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,eAAe,CAAC;AAC9B,cAAc,cAAc,CAAC;AAC7B,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,UAAU,CAAC;AACzB,cAAc,UAAU,CAAC;AACzB,cAAc,aAAa,CAAC","sourcesContent":["export * from './Button';\nexport * from './ContextMenu';\nexport * from './DatePicker';\nexport * from './Picker';\nexport * from './Progress';\nexport * from './Slider';\nexport * from './Switch';\nexport * from './TextInput';\n"]}
\ No newline at end of file
+{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/jetpack-compose/index.tsx"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,eAAe,CAAC;AAC9B,cAAc,cAAc,CAAC;AAC7B,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,UAAU,CAAC;AACzB,cAAc,UAAU,CAAC;AACzB,cAAc,aAAa,CAAC;AAC5B,cAAc,SAAS,CAAC","sourcesContent":["export * from './Button';\nexport * from './ContextMenu';\nexport * from './DatePicker';\nexport * from './Picker';\nexport * from './Progress';\nexport * from './Slider';\nexport * from './Switch';\nexport * from './TextInput';\nexport * from './Shape';\n"]}
\ No newline at end of file
diff --git a/packages/expo-ui/src/jetpack-compose/Shape/index.tsx b/packages/expo-ui/src/jetpack-compose/Shape/index.tsx
new file mode 100644
index 00000000000000..badbff833f3b7b
--- /dev/null
+++ b/packages/expo-ui/src/jetpack-compose/Shape/index.tsx
@@ -0,0 +1,71 @@
+import { requireNativeView } from 'expo';
+import { ViewStyle } from 'react-native';
+
+export type ShapeProps = {
+  /**
+   * Corner rounding percentage. Multiplied by the shorter dimension of the view to produce pixel values.
+   * @default 0.0
+   */
+  cornerRounding?: number;
+  /**
+   * Number between `0.0` and `1.0` that determines how much each line between vertices is "smoothed".
+   * @default 0.0
+   */
+  smoothing?: number;
+  /**
+   * Number of vertices. For `'POLYGON'` it must be at least `3.0`. For `'STAR'` and `'PILL_STAR'` it is a number of vertices for each of two radii (A 5-pointed star has 10 vertices.)
+   * @default 6.0
+   */
+  verticesCount?: number;
+  /**
+   * Inner radius of star-related shapes (`'STAR'` and `'PILL_STAR'`). Multiplied by the shorter dimension of the view to produce pixel values.
+   * @default 1.0
+   */
+  innerRadius?: number;
+  /**
+   * Radius of the circular shape. Multiplied by the shorter dimension of the view to produce pixel values.
+   * @default 1.0
+   */
+  radius?: number;
+  /** Style of the component */
+  style?: ViewStyle;
+  /** Color of the shape */
+  color?: string;
+};
+
+const ShapeNativeView: React.ComponentType<any> = requireNativeView('ExpoUI', 'ShapeView');
+
+function Star(props: ShapeProps) {
+  return <ShapeNativeView {...props} style={props.style} type="star" />;
+}
+
+function PillStar(props: ShapeProps) {
+  return <ShapeNativeView {...props} style={props.style} type="pillStar" />;
+}
+
+function Pill(props: Pick<ShapeProps, 'smoothing' | 'style' | 'color'>) {
+  return <ShapeNativeView {...props} style={props.style} type="pill" />;
+}
+
+function Circle(props: Pick<ShapeProps, 'radius' | 'verticesCount' | 'style' | 'color'>) {
+  return <ShapeNativeView {...props} style={props.style} type="circle" />;
+}
+
+function Rectangle(props: Pick<ShapeProps, 'smoothing' | 'cornerRounding' | 'style' | 'color'>) {
+  return <ShapeNativeView {...props} style={props.style} type="rectangle" />;
+}
+
+function Polygon(
+  props: Pick<ShapeProps, 'smoothing' | 'cornerRounding' | 'verticesCount' | 'style' | 'color'>
+) {
+  return <ShapeNativeView {...props} style={props.style} type="polygon" />;
+}
+
+export const Shape = {
+  Star,
+  PillStar,
+  Pill,
+  Circle,
+  Rectangle,
+  Polygon,
+};
diff --git a/packages/expo-ui/src/jetpack-compose/index.tsx b/packages/expo-ui/src/jetpack-compose/index.tsx
index bb7bca050b54e2..d79015414339f4 100644
--- a/packages/expo-ui/src/jetpack-compose/index.tsx
+++ b/packages/expo-ui/src/jetpack-compose/index.tsx
@@ -6,3 +6,4 @@ export * from './Progress';
 export * from './Slider';
 export * from './Switch';
 export * from './TextInput';
+export * from './Shape';


Reviews:
user: login: behenate
body: Looks great 🚀 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-20T11:18:04Z
user: login: lukmccall
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T08:57:10Z
user: login: aleqsio
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T13:55:48Z

Review comments:
diff_hunk: @@ -0,0 +1,126 @@
+import { Shape } from '@expo/ui/jetpack-compose';
+import * as React from 'react';
+import { View } from 'react-native';
+import Animated, {
+  Easing,
+  useSharedValue,
+  withRepeat,
+  withSequence,
+  withTiming,
+} from 'react-native-reanimated';
+
+import { Page } from '../../components/Page';
+
+const AnimatedPolygon = Animated.createAnimatedComponent(Shape.Polygon);
+const AnimatedRectangle = Animated.createAnimatedComponent(Shape.Rectangle);
+const AnimatedStar = Animated.createAnimatedComponent(Shape.Star);
+
+const pastelPalette = {
+  color1: '#541388',
+  color2: '#d1d646',
+  color3: '#d90368',
+  color4: '#2e294e',
+  color5: '#ffd400',
+};
+
+export default function UIScreen() {
+  const oneToZero = useSharedValue<number | undefined>(1);
+
+  React.useEffect(() => {
path: apps/native-component-list/src/screens/UI/ShapeScreen.android.tsx
user: login: behenate
body: I prefer the notation without `React.*`, but it's just my thing.
```suggestion
import { useEffect } from 'react'
  [...]
  useEffect(() => {
```
created_at: 2025-05-20T10:38:32Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,71 @@
+import { requireNativeView } from 'expo';
+import { ViewStyle } from 'react-native';
+
+export type ShapeProps = {
+  /**
+   * Corner rounding percentage. Multiplied by the shorter dimension of the view to produce pixel values.
+   * @default 0.0
+   */
+  cornerRounding?: number;
+  /**
+   * Number between `0.0` and `1.0` that determines how much each line between vertices is "smoothed".
+   * @default 0.0
+   */
+  smoothing?: number;
+  /**
+   * Number of vertices. For `'POLYGON'` it must be at least `3.0`. For `'STAR'` and `'PILL_STAR'` it is a number of vertices for each of two radii (A 5-pointed star has 10 vertices.)
+   * @default 6.0
+   */
+  verticesCount?: number;
+  /**
+   * Inner radius of star-related shapes (`'STAR'` and `'PILL_STAR'`). Multiplied by the shorter dimension of the view to produce pixel values.
+   * @default 1.0
+   */
+  innerRadius?: number;
+  /**
+   * Radius of the circular shape. Multiplied by the shorter dimension of the view to produce pixel values.
+   * @default 1.0
+   */
+  radius?: number;
+  /** Style of the component */
+  style?: ViewStyle;
+  /** Color of the shape */
+  color?: string;
+};
+
+const ShapeNativeView: React.ComponentType<any> = requireNativeView('ExpoUI', 'ShapeView');
+
+function Star(props: ShapeProps) {
+  return <ShapeNativeView {...props} style={props.style} type="STAR" />;
+}
+
+function PillStar(props: ShapeProps) {
+  return <ShapeNativeView {...props} style={props.style} type="PILL_STAR" />;
+}
+
+function Pill(props: Pick<ShapeProps, 'smoothing' | 'style' | 'color'>) {
+  return <ShapeNativeView {...props} style={props.style} type="PILL" />;
+}
+
+function Circle(props: Pick<ShapeProps, 'smoothing' | 'verticesCount' | 'style' | 'color'>) {
path: packages/expo-ui/src/jetpack-compose/Shape/index.tsx
user: login: behenate
body: I'm pretty sure that the native circle is not using `smoothing` but it does use `redius` 
```suggestion
function Circle(props: Pick<ShapeProps, 'radius' | 'verticesCount' | 'style' | 'color'>) {
```
created_at: 2025-05-20T11:12:27Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,158 @@
+package expo.modules.ui
+
+import android.content.Context
+import expo.modules.kotlin.views.ExpoComposeView
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.ui.graphics.Color
+import expo.modules.kotlin.AppContext
+import expo.modules.kotlin.views.ComposeProps
+import androidx.compose.foundation.layout.Box
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithCache
+import androidx.graphics.shapes.RoundedPolygon
+import androidx.graphics.shapes.toPath
+import androidx.compose.ui.graphics.asComposePath
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Path
+import androidx.graphics.shapes.CornerRounding
+import androidx.graphics.shapes.circle
+import androidx.graphics.shapes.pill
+import androidx.graphics.shapes.pillStar
+import androidx.graphics.shapes.rectangle
+import androidx.graphics.shapes.star
+import expo.modules.kotlin.types.Enumerable
+import android.graphics.Color as GraphicsColor
+
+enum class ShapeType : Enumerable {
+  STAR,
+  PILL_STAR,
+  PILL,
+  CIRCLE,
+  RECTANGLE,
+  POLYGON
+}
path: packages/expo-ui/android/src/main/java/expo/modules/ui/ShapeView.kt
user: login: behenate
body: Something to consider to be more in-line with our other modules. We usually use lowercase letters on the JS side. In here it doesn't make that much difference though, since users won't be using the enum directly so I'm fine with going either way.

```suggestion
enum class ShapeType(val value: String) : Enumerable {
  STAR("star"),
  PILL_STAR("pillStar"),
  PILL("pill"),
  CIRCLE("circle"),
  RECTANGLE("rectangle"),
  POLYGON("polygon")
}
```
created_at: 2025-05-20T11:17:24Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,158 @@
+package expo.modules.ui
+
+import android.content.Context
+import expo.modules.kotlin.views.ExpoComposeView
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.ui.graphics.Color
+import expo.modules.kotlin.AppContext
+import expo.modules.kotlin.views.ComposeProps
+import androidx.compose.foundation.layout.Box
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithCache
+import androidx.graphics.shapes.RoundedPolygon
+import androidx.graphics.shapes.toPath
+import androidx.compose.ui.graphics.asComposePath
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Path
+import androidx.graphics.shapes.CornerRounding
+import androidx.graphics.shapes.circle
+import androidx.graphics.shapes.pill
+import androidx.graphics.shapes.pillStar
+import androidx.graphics.shapes.rectangle
+import androidx.graphics.shapes.star
+import expo.modules.kotlin.types.Enumerable
+import android.graphics.Color as GraphicsColor
+
+enum class ShapeType(val value: String) : Enumerable {
+  STAR("star"),
+  PILL_STAR("pillStar"),
+  PILL("pill"),
+  CIRCLE("circle"),
+  RECTANGLE("rectangle"),
+  POLYGON("polygon")
+}
+
+data class ShapeProps(
+  val cornerRounding: MutableState<Float> = mutableFloatStateOf(0.0f),
+  val smoothing: MutableState<Float> = mutableFloatStateOf(0.0f),
+  val verticesCount: MutableState<Int> = mutableIntStateOf(6),
+  val innerRadius: MutableState<Float> = mutableFloatStateOf(0.0f),
+  val radius: MutableState<Float> = mutableFloatStateOf(0.0f),
+  val type: MutableState<ShapeType> = mutableStateOf(ShapeType.CIRCLE),
+  val color: MutableState<GraphicsColor?> = mutableStateOf(null),
+) : ComposeProps
+
+private fun createStarPath(size: Size, cornerRounding: Float, smoothing: Float, innerRadius: Float, radius: Float, verticesCount: Int): Path {
+  val centerX = size.width / 2
+  val centerY = size.height / 2
path: packages/expo-ui/android/src/main/java/expo/modules/ui/ShapeView.kt
user: login: lukmccall
body: ```suggestion
private fun Size.centerPoint() = this.width / 2 to this.height / 2

private fun createStarPath(size: Size, cornerRounding: Float, smoothing: Float, innerRadius: Float, radius: Float, verticesCount: Int): Path {
  val (centerX, centerY) = size.centerPoint()
```
Up to you!
created_at: 2025-05-21T08:56:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,158 @@
+package expo.modules.ui
+
+import android.content.Context
+import expo.modules.kotlin.views.ExpoComposeView
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.ui.graphics.Color
+import expo.modules.kotlin.AppContext
+import expo.modules.kotlin.views.ComposeProps
+import androidx.compose.foundation.layout.Box
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithCache
+import androidx.graphics.shapes.RoundedPolygon
+import androidx.graphics.shapes.toPath
+import androidx.compose.ui.graphics.asComposePath
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Path
+import androidx.graphics.shapes.CornerRounding
+import androidx.graphics.shapes.circle
+import androidx.graphics.shapes.pill
+import androidx.graphics.shapes.pillStar
+import androidx.graphics.shapes.rectangle
+import androidx.graphics.shapes.star
+import expo.modules.kotlin.types.Enumerable
+import android.graphics.Color as GraphicsColor
+
+enum class ShapeType(val value: String) : Enumerable {
+  STAR("star"),
+  PILL_STAR("pillStar"),
+  PILL("pill"),
+  CIRCLE("circle"),
+  RECTANGLE("rectangle"),
+  POLYGON("polygon")
+}
+
+data class ShapeProps(
+  val cornerRounding: MutableState<Float> = mutableFloatStateOf(0.0f),
+  val smoothing: MutableState<Float> = mutableFloatStateOf(0.0f),
+  val verticesCount: MutableState<Int> = mutableIntStateOf(6),
+  val innerRadius: MutableState<Float> = mutableFloatStateOf(0.0f),
+  val radius: MutableState<Float> = mutableFloatStateOf(0.0f),
+  val type: MutableState<ShapeType> = mutableStateOf(ShapeType.CIRCLE),
+  val color: MutableState<GraphicsColor?> = mutableStateOf(null),
+) : ComposeProps
+
+private fun createStarPath(size: Size, cornerRounding: Float, smoothing: Float, innerRadius: Float, radius: Float, verticesCount: Int): Path {
+  val centerX = size.width / 2
+  val centerY = size.height / 2
path: packages/expo-ui/android/src/main/java/expo/modules/ui/ShapeView.kt
user: login: aleqsio
body: Thanks, did something similar:

```
private fun Size.centerX() = this.width / 2
private fun Size.centerY() = this.height / 2
```
created_at: 2025-05-21T13:55:48Z
author_association: CONTRIBUTOR

PR comments:
user: login: expo-bot
created_at: 2025-05-19T15:22:39Z
body: <!-- pr-labeler comment -->
The Pull Request introduced fingerprint changes against the base commit: 80e2b97278815e80900134a92af81e4271093ff7
<details><summary>Fingerprint diff</summary>

```json
[
  {
    "op": "changed",
    "beforeSource": {
      "type": "dir",
      "filePath": "../../packages/expo-ui/android",
      "reasons": [
        "expoAutolinkingAndroid"
      ],
      "hash": "591587b2450b6578845cb17a5568c377bbdd303d"
    },
    "afterSource": {
      "type": "dir",
      "filePath": "../../packages/expo-ui/android",
      "reasons": [
        "expoAutolinkingAndroid"
      ],
      "hash": "5fe8f60e44103790d849d858234f439cef94e916"
    }
  }
]
```

</details>

---
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*

author_association: COLLABORATOR
user: login: github-actions[bot]
created_at: 2025-05-20T09:56:28Z
body: Subscribed to pull request
| File Patterns | Mentions |
| - | - |
| packages/expo-ui/\*\* | @behenate |

Generated by [CodeMention](https://github.com/expo/expo/actions/workflows/codemention.yaml)
<!-- codemention header -->
author_association: CONTRIBUTOR